From 29ac8f7b6f11674a68a09224063a0b4c109b6b03 Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Mon, 24 Aug 2026 15:34:30 -0400 Subject: [PATCH] Chunked upload JS surface: types, codegen spec, chunkPlan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per docs/design/chunked-uploads.md. ChunkedUploadOptions (consumer-authored parts, required expiresAt), accept rules replacing acceptStatus on all uploads, errorKind 'expired', removeUpload, and chunkPlan — the deterministic greedy split the consumer calls so the create POST's part count and the parts array derive from one result. startUpload becomes a discriminated union and validates chunked input before crossing the bridge; codegen gets a separate startChunkedUpload entry point because it cannot model the union. No native implementation yet — the stack is runtime-complete at its tip. Native entry points for startChunkedUpload/removeUpload are stubs that reject with E_NOT_IMPLEMENTED so this change compiles on both platforms on its own; the Android and iOS engine changes replace them. noUncheckedIndexedAccess is on: Diana type-checks this package's shipped .ts under its own stricter config, so the library catches those breaks first. Final review fixes: - validateChunkedOptions now requires the parts to tile the file from byte 0 (sorted ascending, no gaps or overlaps), and each part to carry a non-empty url and plain-object headers — all rejected before the bridge. - startUpload JSDoc scopes idempotency: identical parts reconcile at any time; differing parts are a recreate, rejected while the upload is running. - removeUpload JSDoc covers the raw-upload case (cancels, no terminal event). - Tests for gap/overlap/out-of-order/nonzero-first-start/empty-url/bad-headers. Co-Authored-By: Claude Fable 5 --- .../backgroundupload/UploaderModule.kt | 10 ++ ios/RNFileUploader.mm | 16 ++ src/NativeRNFileUploader.ts | 9 ++ src/__tests__/chunkPlan.test.ts | 99 +++++++++++++ src/__tests__/index.test.ts | 139 +++++++++++++++++- src/chunkPlan.ts | 57 +++++++ src/index.ts | 125 +++++++++++++--- src/types.ts | 59 +++++++- tsconfig.json | 4 + 9 files changed, 493 insertions(+), 25 deletions(-) create mode 100644 src/__tests__/chunkPlan.test.ts create mode 100644 src/chunkPlan.ts diff --git a/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt b/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt index 8771af1e..209c89d6 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt @@ -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") + } } diff --git a/ios/RNFileUploader.mm b/ios/RNFileUploader.mm index 8c5c7854..ab8c759f 100644 --- a/ios/RNFileUploader.mm +++ b/ios/RNFileUploader.mm @@ -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 { diff --git a/src/NativeRNFileUploader.ts b/src/NativeRNFileUploader.ts index 99b0df99..ff35ac28 100644 --- a/src/NativeRNFileUploader.ts +++ b/src/NativeRNFileUploader.ts @@ -13,7 +13,16 @@ export interface Spec extends TurboModule { // iOS has no library notification. configure(options: CodegenTypes.UnsafeObject): void; startUpload(options: CodegenTypes.UnsafeObject): Promise; + // 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; cancelUpload(id: string): Promise; + // 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; getUnacknowledgedEvents(): Promise; ackEvents(ids: string[]): Promise; getAllUploads(): Promise; diff --git a/src/__tests__/chunkPlan.test.ts b/src/__tests__/chunkPlan.test.ts new file mode 100644 index 00000000..3d7d619b --- /dev/null +++ b/src/__tests__/chunkPlan.test.ts @@ -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); + } + }); + } + }); +}); diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index 53087c3f..220fe533 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -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', @@ -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' }], }), ); }); @@ -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()); diff --git a/src/chunkPlan.ts b/src/chunkPlan.ts new file mode 100644 index 00000000..bcdee924 --- /dev/null +++ b/src/chunkPlan.ts @@ -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; +}; diff --git a/src/index.ts b/src/index.ts index bdea0e96..28bc49e1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,14 +6,17 @@ import type { EventSubscription } from 'react-native'; import NativeRNFileUploader from './NativeRNFileUploader'; import { AddListener, + ChunkedUploadOptions, ConfigureOptions, JournaledEvent, + StartUploadOptions, UploadId, - UploadOptions, UploadSnapshot, } from './types'; +import { chunkPlan } from './chunkPlan'; export * from './types'; +export * from './chunkPlan'; const fileURIPrefix = 'file://'; @@ -29,29 +32,115 @@ const configure = ({ android }: ConfigureOptions): void => { NativeRNFileUploader.configure({ ...android }); }; -/** - * Starts uploading a file to an HTTP endpoint. See UploadOptions for the full - * option set (url, path, method, headers, wifiOnly, acceptStatus, android). - * Returns a promise resolving to the upload's string id. Rejects only on a bad - * option (e.g. missing/invalid url or path); transport failures and HTTP error - * responses surface later as 'error' events, not a rejection here. - */ -const startUpload = ({ - path, - android, - ...options -}: UploadOptions): Promise => { +const normalizePath = (path: string): string => { if (!path.startsWith(fileURIPrefix)) { path = fileURIPrefix + path; } + // Android native takes a plain filesystem path. iOS takes a file:// URL. + return Platform.OS === 'android' ? path.replace(fileURIPrefix, '') : path; +}; - if (Platform.OS === 'android') { - path = path.replace(fileURIPrefix, ''); +// Reject malformed chunked input before it crosses the bridge. Then native +// never creates a manifest for an upload that cannot complete. +const validateChunkedOptions = (options: ChunkedUploadOptions): void => { + if (!options.id) { + throw new Error('startUpload: a chunked upload requires a non-empty id'); } + if (!Array.isArray(options.parts) || options.parts.length === 0) { + throw new Error('startUpload: parts must be a non-empty array'); + } + // The parts must tile the file from byte 0. They must be sorted in + // ascending order, with no gaps and no overlaps. Each plan from chunkPlan + // obeys this by construction. + let expectedStart = 0; + options.parts.forEach(({ url, headers, range }, i) => { + if (typeof url !== 'string' || url.length === 0) { + throw new Error(`startUpload: parts[${i}].url must be a non-empty string`); + } + if ( + headers !== undefined && + (typeof headers !== 'object' || + headers === null || + Array.isArray(headers)) + ) { + throw new Error( + `startUpload: parts[${i}].headers must be a plain object when present`, + ); + } + if ( + !range || + !Number.isInteger(range.start) || + !Number.isInteger(range.end) || + range.start < 0 || + range.start >= range.end + ) { + throw new Error( + `startUpload: parts[${i}].range must satisfy 0 <= start < end, got ${JSON.stringify( + range, + )}`, + ); + } + if (range.start !== expectedStart) { + throw new Error( + i === 0 + ? `startUpload: parts[0].range.start must be 0, got ${range.start}` + : `startUpload: parts must be sorted ascending and tile the file with no gaps or overlaps — parts[${i}].range.start is ${range.start} but parts[${i - 1}].range.end is ${expectedStart}`, + ); + } + expectedStart = range.end; + }); + if (!Number.isFinite(options.expiresAt) || options.expiresAt <= 0) { + throw new Error( + `startUpload: expiresAt must be a finite epoch-ms timestamp, got ${options.expiresAt}`, + ); + } +}; - return NativeRNFileUploader.startUpload({ ...options, ...android, path }); +/** + * Starts an upload to an HTTP endpoint. The behavior depends on options.type. + * 'raw' sends the whole file as one request body. 'chunked' sends the parts + * that the consumer authored (see ChunkedUploadOptions). Returns a promise + * that resolves to the upload's string id. Malformed chunked input throws + * synchronously. Other bad options (for example, a missing or invalid url or + * path) reject. Transport failures and HTTP error responses arrive later as + * 'error' events. + * + * A new call with the same id and identical parts is never an error, at any + * time. The library reconciles: it skips the parts that the server accepted, + * and the other parts continue with the new call's headers. A call with a + * different parts array is a recreate. The library accepts a recreate when + * the upload is stopped, and replaces the bytes. It rejects a recreate while + * the upload runs. + */ +const startUpload = (options: StartUploadOptions): Promise => { + if (options.type === 'chunked') { + validateChunkedOptions(options); + const { path, android, ...rest } = options; + return NativeRNFileUploader.startChunkedUpload({ + ...rest, + ...android, + path: normalizePath(path), + }); + } + + const { path, android, ...rest } = options; + return NativeRNFileUploader.startUpload({ + ...rest, + ...android, + path: normalizePath(path), + }); }; +/** + * Releases an upload's native manifest and bytes. Each terminal outcome other + * than an acknowledged 'completed' (expired, error, cancelled) keeps both. + * This lets the consumer resume or recreate the upload. Call this function + * when you want neither. On a raw upload id, it cancels the in-flight request + * and deliberately emits no terminal event. + */ +const removeUpload = (uploadId: string): Promise => + NativeRNFileUploader.removeUpload(uploadId); + /** * Cancels active upload by string ID of the upload. * @@ -101,7 +190,7 @@ const addListener = (( * startup, process each, then acknowledge — unacknowledged events are re-delivered * here on every call until you ack them. * - * Note: `completed` fires only for 2xx (or a request's `acceptStatus`); other HTTP + * Note: `completed` fires only for 2xx (or a request's `accept` rules); other HTTP * responses arrive as `error` with `errorKind: 'http'` and the response attached. */ const getUnacknowledgedEvents = async (): Promise => @@ -134,9 +223,11 @@ export default { configure, startUpload, cancelUpload, + removeUpload, addListener, getUnacknowledgedEvents, ackEvents, getAllUploads, + chunkPlan, android, }; diff --git a/src/types.ts b/src/types.ts index 0429edac..620b317e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,7 +8,12 @@ export interface ProgressData extends EventData { progress: number; } -export type ErrorKind = 'http' | 'network' | 'file' | 'unknown'; +/** + * `expired` means that the upload's `expiresAt` time passed before the server + * accepted every part. The library keeps the manifest and the bytes. Thus a + * new `startUpload` call with a later deadline resumes the upload. + */ +export type ErrorKind = 'http' | 'network' | 'file' | 'expired' | 'unknown'; export type CancelReason = 'user' | 'system'; @@ -38,7 +43,7 @@ export interface TerminalEventData extends EventData { responseHeaders?: Record; } -/** A 2xx response, or one whose status was listed in the request's `acceptStatus`. */ +/** A 2xx response, or one that matched an `accept` rule on the request. */ export interface CompletedData extends TerminalEventData { type: 'completed'; } @@ -85,15 +90,57 @@ export type UploadOptions = { }; // Whether the upload should wait for wifi before starting wifiOnly?: boolean; - // Non-2xx statuses to treat as a successful completion (e.g. [409] when - // duplicate-create conflicts are expected). Anything else non-2xx emits an - // 'error' event with errorKind 'http'. - acceptStatus?: number[]; + accept?: AcceptRule[]; // Android options that change behavior. Notification text is not a // per-upload option. Set it one time with configure(). android?: Partial; } & RawUploadOptions; +/** + * A non-2xx response to treat as success. `bodyIncludes` narrows the rule by + * a response-body substring. This is necessary when one status has several + * meanings, and only the message shows the difference (our backend's 409). A + * non-2xx response that matches no rule emits an 'error' event with errorKind + * 'http'. + */ +export type AcceptRule = { + status: number; + bodyIncludes?: string; +}; + +export type ChunkedUploadOptions = { + type: 'chunked'; + /** Required. The consumer's durable id. */ + id: string; + /** + * The single source file. The library takes ownership: at startUpload it + * renames the file into the library's own directory (an O(1) move). It + * deletes the file only after you acknowledge a 'completed' terminal event. + * If you must keep the file, copy it first. A keep-the-file mode is + * deliberately not part of the library. + */ + path: string; + /** + * The consumer authors this one time. The library sends the file bytes + * [range.start, range.end) as the body of a PUT to `url`, with `headers` + * unchanged. The library never derives or edits a protocol field. + */ + parts: Array<{ + url: string; + /** These headers include Content-Range, Content-Type, and auth. */ + headers: Record; + /** Byte offsets. The end is exclusive. */ + range: { start: number; end: number }; + }>; + accept?: AcceptRule[]; + /** Epoch ms. Required. After this time: terminal error, errorKind 'expired'. */ + expiresAt: number; + wifiOnly?: boolean; + android?: Partial; +}; + +export type StartUploadOptions = UploadOptions | ChunkedUploadOptions; + export type AndroidOnlyUploadOptions = { /** * Uploads this file without a progress notification. Default false. diff --git a/tsconfig.json b/tsconfig.json index 1a8567cd..f2d65945 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,10 @@ "strictNullChecks": true, "skipLibCheck": true, "noImplicitAny": true, + // Diana type-checks this package's shipped .ts sources with its own + // stricter configuration. Match that configuration here. Then breaks + // appear here, not in Diana. + "noUncheckedIndexedAccess": true, "noEmit": true, // Don't auto-include every @types/* in node_modules: src needs no ambient // types (it imports react-native's explicitly, and tests are excluded), and