Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -265,4 +265,14 @@ class UploaderModule(context: ReactApplicationContext) :
promise.reject(exc)
}
}

// The chunked transport arrives in the platform changes that follow this
// spec change. Until then, a rejection satisfies the codegen contract.
override fun startChunkedUpload(options: ReadableMap, promise: Promise) {
promise.reject("E_NOT_IMPLEMENTED", "Chunked uploads are not implemented in this build")
}

override fun removeUpload(id: String, promise: Promise) {
promise.reject("E_NOT_IMPLEMENTED", "removeUpload is not implemented in this build")
}
}
16 changes: 16 additions & 0 deletions ios/RNFileUploader.mm
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,22 @@ - (void)cancelUpload:(NSString *)id
[RNBackgroundUpload.shared cancelUpload:id resolve:resolve reject:reject];
}

// Chunked transport lands in the iOS engine change; until then the spec
// contract is met by rejecting.
- (void)startChunkedUpload:(NSDictionary *)options
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject
{
reject(@"E_NOT_IMPLEMENTED", @"Chunked uploads are not implemented in this build", nil);
}

- (void)removeUpload:(NSString *)id
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject
{
reject(@"E_NOT_IMPLEMENTED", @"removeUpload is not implemented in this build", nil);
}

- (void)getUnacknowledgedEvents:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject
{
Expand Down
9 changes: 9 additions & 0 deletions src/NativeRNFileUploader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,16 @@ export interface Spec extends TurboModule {
// iOS has no library notification.
configure(options: CodegenTypes.UnsafeObject): void;
startUpload(options: CodegenTypes.UnsafeObject): Promise<string>;
// Chunked uploads get their own entry point for two reasons. Codegen cannot
// model the raw/chunked discriminated union. And the native implementations
// share no parsing: startUpload dispatches one request, while
// startChunkedUpload creates or reconciles a durable part manifest. index.ts
// keeps the single public startUpload and routes on options.type.
startChunkedUpload(options: CodegenTypes.UnsafeObject): Promise<string>;
cancelUpload(id: string): Promise<boolean>;
// Releases the manifest and the bytes of a non-completed upload. A completed
// upload releases itself when you acknowledge its terminal event.
removeUpload(id: string): Promise<void>;
getUnacknowledgedEvents(): Promise<CodegenTypes.UnsafeObject[]>;
ackEvents(ids: string[]): Promise<boolean>;
getAllUploads(): Promise<CodegenTypes.UnsafeObject[]>;
Expand Down
99 changes: 99 additions & 0 deletions src/__tests__/chunkPlan.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { chunkPlan } from '../chunkPlan';

const MB = 2 ** 20;
const MIN = 8 * MB;
const MAX = 20 * MB;

const assertContiguousCovering = (
ranges: Array<{ start: number; end: number }>,
size: number,
) => {
expect(ranges.length).toBeGreaterThan(0);
expect(ranges[0].start).toBe(0);
expect(ranges[ranges.length - 1].end).toBe(size);
for (let i = 1; i < ranges.length; i++) {
expect(ranges[i].start).toBe(ranges[i - 1].end);
}
};

describe('chunkPlan', () => {
it('is deterministic', () => {
expect(chunkPlan(137 * MB + 3)).toEqual(chunkPlan(137 * MB + 3));
});

it('walks greedy max-size chunks, contiguous and covering', () => {
const size = 100 * MB;
const ranges = chunkPlan(size);
assertContiguousCovering(ranges, size);
expect(ranges).toHaveLength(5);
ranges.forEach((r) => expect(r.end - r.start).toBe(MAX));
});

it('an exact multiple of max yields equal chunks with no absorption', () => {
const ranges = chunkPlan(60 * MB);
expect(ranges).toEqual([
{ start: 0, end: 20 * MB },
{ start: 20 * MB, end: 40 * MB },
{ start: 40 * MB, end: 60 * MB },
]);
});

it('absorbs a sub-min tail into the previous chunk', () => {
const size = 40 * MB + (MIN - 1);
const ranges = chunkPlan(size);
assertContiguousCovering(ranges, size);
expect(ranges).toHaveLength(2);
// The documented ceiling: the last chunk can reach max + min - 1.
expect(ranges[1].end - ranges[1].start).toBe(MAX + MIN - 1);
});

it('keeps a tail of exactly min as its own chunk', () => {
const size = 40 * MB + MIN;
const ranges = chunkPlan(size);
assertContiguousCovering(ranges, size);
expect(ranges).toHaveLength(3);
expect(ranges[2]).toEqual({ start: 40 * MB, end: size });
});

it('a file smaller than min is a single chunk', () => {
expect(chunkPlan(5 * MB)).toEqual([{ start: 0, end: 5 * MB }]);
expect(chunkPlan(1)).toEqual([{ start: 0, end: 1 }]);
});

it('throws on size 0 and other non-positive-integer sizes', () => {
expect(() => chunkPlan(0)).toThrow(/positive integer/);
expect(() => chunkPlan(-1)).toThrow(/positive integer/);
expect(() => chunkPlan(1.5)).toThrow(/positive integer/);
expect(() => chunkPlan(NaN)).toThrow(/positive integer/);
});

it('throws on nonsensical min/max', () => {
expect(() => chunkPlan(100, { min: 0, max: 20 })).toThrow(/min/);
expect(() => chunkPlan(100, { min: 8, max: 4 })).toThrow(/max/);
});

// A scaled-down min/max sweep over every size from 1 byte to 10×max. It
// shows that the shape properties hold at every boundary, not only at the
// sampled sizes above.
it('every plan is contiguous, covering, and never has a tiny part', () => {
const min = 8;
const max = 20;
for (let size = 1; size <= 200; size++) {
const ranges = chunkPlan(size, { min, max });
assertContiguousCovering(ranges, size);
ranges.forEach((r, i) => {
const len = r.end - r.start;
expect(len).toBeGreaterThan(0);
expect(len).toBeLessThanOrEqual(max + min - 1);
// In a multi-part plan, every part meets the server minimum. Only a
// whole file smaller than min can be under it, as its only part.
if (ranges.length > 1) {
expect(len).toBeGreaterThanOrEqual(min);
}
if (i < ranges.length - 1) {
expect(len).toBe(max);
}
});
}
});
});
139 changes: 137 additions & 2 deletions src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ jest.mock('react-native', () => {
const nativeModule = {
configure: jest.fn(),
startUpload: jest.fn(async () => 'id-1'),
startChunkedUpload: jest.fn(async () => 'id-2'),
cancelUpload: jest.fn(async () => true),
removeUpload: jest.fn(async () => undefined),
getUnacknowledgedEvents: jest.fn(async () => [
{
eventId: 'e1',
Expand Down Expand Up @@ -79,13 +81,13 @@ describe('startUpload', () => {
path: '/tmp/f.bin',
method: 'POST',
type: 'raw',
acceptStatus: [409],
accept: [{ status: 409, bodyIncludes: 'already completed' }],
});
expect(native.startUpload).toHaveBeenCalledWith(
expect.objectContaining({
url: 'https://example.com/up',
path: 'file:///tmp/f.bin',
acceptStatus: [409],
accept: [{ status: 409, bodyIncludes: 'already completed' }],
}),
);
});
Expand All @@ -106,6 +108,139 @@ describe('startUpload', () => {
});
});

describe('startUpload (chunked)', () => {
const chunked = {
type: 'chunked' as const,
id: 'u1',
path: '/tmp/f.bin',
parts: [
{
url: 'https://example.com/up?partNum=1',
headers: { 'Content-Range': 'bytes 0-9/20' },
range: { start: 0, end: 10 },
},
{
url: 'https://example.com/up?partNum=2',
headers: { 'Content-Range': 'bytes 10-19/20' },
range: { start: 10, end: 20 },
},
],
expiresAt: 1735689600000,
};

it('routes to startChunkedUpload, not startUpload', async () => {
native.startUpload.mockClear();
await Upload.startUpload(chunked);
expect(native.startUpload).not.toHaveBeenCalled();
expect(native.startChunkedUpload).toHaveBeenCalledWith(
expect.objectContaining({
id: 'u1',
path: 'file:///tmp/f.bin',
parts: chunked.parts,
expiresAt: chunked.expiresAt,
}),
);
});

it('rejects an empty id', () => {
expect(() => Upload.startUpload({ ...chunked, id: '' })).toThrow(
/non-empty id/,
);
});

it('rejects empty parts', () => {
expect(() => Upload.startUpload({ ...chunked, parts: [] })).toThrow(
/non-empty/,
);
});

it.each([
{ start: -1, end: 10 },
{ start: 10, end: 10 },
{ start: 11, end: 10 },
{ start: 0.5, end: 10 },
{ start: 0, end: NaN },
])('rejects range %p', (range) => {
const parts = [{ ...chunked.parts[0], range }];
expect(() => Upload.startUpload({ ...chunked, parts })).toThrow(
/0 <= start < end/,
);
});

it.each([NaN, Infinity, 0, -5])('rejects expiresAt %p', (expiresAt) => {
expect(() => Upload.startUpload({ ...chunked, expiresAt })).toThrow(
/expiresAt/,
);
});

it('rejects a nonzero first start', () => {
const parts = [
{ ...chunked.parts[0], range: { start: 5, end: 10 } },
{ ...chunked.parts[1], range: { start: 10, end: 20 } },
];
expect(() => Upload.startUpload({ ...chunked, parts })).toThrow(
/parts\[0\]\.range\.start must be 0/,
);
});

it('rejects a gap between parts', () => {
const parts = [
{ ...chunked.parts[0], range: { start: 0, end: 8 } },
{ ...chunked.parts[1], range: { start: 10, end: 20 } },
];
expect(() => Upload.startUpload({ ...chunked, parts })).toThrow(
/no gaps or overlaps/,
);
});

it('rejects overlapping parts', () => {
const parts = [
{ ...chunked.parts[0], range: { start: 0, end: 12 } },
{ ...chunked.parts[1], range: { start: 10, end: 20 } },
];
expect(() => Upload.startUpload({ ...chunked, parts })).toThrow(
/no gaps or overlaps/,
);
});

it('rejects out-of-order parts', () => {
const parts = [chunked.parts[1], chunked.parts[0]];
expect(() => Upload.startUpload({ ...chunked, parts })).toThrow(
/parts\[0\]\.range\.start must be 0/,
);
});

it('rejects an empty part url', () => {
const parts = [{ ...chunked.parts[0], url: '' }, chunked.parts[1]];
expect(() => Upload.startUpload({ ...chunked, parts })).toThrow(
/parts\[0\]\.url must be a non-empty string/,
);
});

it('rejects non-object part headers', () => {
const parts = [
{ ...chunked.parts[0], headers: 'nope' as never },
chunked.parts[1],
];
expect(() => Upload.startUpload({ ...chunked, parts })).toThrow(
/parts\[0\]\.headers must be a plain object/,
);
});

it('throws before reaching native', () => {
native.startChunkedUpload.mockClear();
expect(() => Upload.startUpload({ ...chunked, parts: [] })).toThrow();
expect(native.startChunkedUpload).not.toHaveBeenCalled();
});
});

describe('removeUpload', () => {
it('forwards the id to native', async () => {
await Upload.removeUpload('u1');
expect(native.removeUpload).toHaveBeenCalledWith('u1');
});
});

describe('addListener', () => {
it('subscribes to the matching codegen emitter', () => {
Upload.addListener('progress', jest.fn());
Expand Down
57 changes: 57 additions & 0 deletions src/chunkPlan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
export type ChunkPlanOptions = {
/** The smallest chunk that the server accepts in a multi-part upload. */
min?: number;
/** The target chunk size for the greedy walk. */
max?: number;
};

const DEFAULT_MIN = 8 * 2 ** 20;
const DEFAULT_MAX = 20 * 2 ** 20;

/**
* Splits `sizeBytes` into contiguous, end-exclusive ranges that cover
* [0, size). The walk is greedy and deterministic: it emits `max`-sized
* chunks. When the final remainder is smaller than `min`, the walk absorbs it
* into the previous chunk. Thus the last chunk can hold up to `max + min - 1`
* bytes. A file smaller than `min` is a single chunk, because the server's
* minimum applies to the parts of a multi-part upload, not to the whole file.
*
* The function is pure and deterministic on purpose. The consumer calls it
* one time, and derives the create request's part count and the `parts` array
* from the same result. Thus the two can never disagree.
*/
export const chunkPlan = (
sizeBytes: number,
opts: ChunkPlanOptions = {},
): Array<{ start: number; end: number }> => {
const min = opts.min ?? DEFAULT_MIN;
const max = opts.max ?? DEFAULT_MAX;
if (!Number.isInteger(sizeBytes) || sizeBytes <= 0) {
throw new Error(
`chunkPlan: sizeBytes must be a positive integer, got ${sizeBytes}`,
);
}
if (!Number.isInteger(min) || min < 1) {
throw new Error(`chunkPlan: min must be a positive integer, got ${min}`);
}
if (!Number.isInteger(max) || max < min) {
throw new Error(`chunkPlan: max must be an integer >= min, got ${max}`);
}

const ranges: Array<{ start: number; end: number }> = [];
let start = 0;
while (sizeBytes - start >= max) {
ranges.push({ start, end: start + max });
start += max;
}
const remainder = sizeBytes - start;
if (remainder > 0) {
const last = ranges[ranges.length - 1];
if (remainder < min && last) {
last.end = sizeBytes;
} else {
ranges.push({ start, end: sizeBytes });
}
}
return ranges;
};
Loading
Loading