OpenSpace's background HTTP file uploader for React Native. On iOS it uses a
background URLSession; on Android it uses WorkManager (a CoroutineWorker)
with OkHttp. Uploads continue while the app is backgrounded and resume after it
is killed.
Requires React Native ≥ 0.84 with the New Architecture enabled, and React ≥ 19. This is a codegen TurboModule; it does not support the legacy bridge.
yarn add react-native-background-upload
cd ios && pod install && cd ..
pod install is required after installing. It runs codegen to generate the native
spec this module implements.
The package ships TypeScript source with no build step, so it resolves through Metro (and
tsc) but not through plain Node. If you import it from a non-Metro context, such as a script or Jest without a transform, add it to yourtransformIgnorePatternsallowlist or mock it.
So uploads that finish while the app is terminated can relaunch it and be
journaled, add this to your AppDelegate:
// AppDelegate.m (Objective-C). Diana uses this form.
@import react_native_background_upload;
- (void)application:(UIApplication *)application
handleEventsForBackgroundURLSession:(NSString *)identifier
completionHandler:(void (^)(void))completionHandler {
[RNBackgroundUpload setBackgroundSessionCompletionHandler:completionHandler
forIdentifier:identifier];
}CocoaPods wires the module map for the app target, so
@importworks in an Objective-C.mfile with the default static-library setup. If your AppDelegate is Objective-C++ (.mm),@importis not available. Add"$(PODS_CONFIGURATION_BUILD_DIR)/react-native-background-upload/Swift Compatibility Header"to the app target'sHEADER_SEARCH_PATHS, then use#import <React/RCTBridgeModule.h>followed by#import "react_native_background_upload-Swift.h". The example app'sAppDelegate.mmdoes this.
This hook is load-bearing beyond just calling the completion handler: it is what
brings the library's background URLSession back to life in a process the system
relaunched with no JS running, so queued completions get journaled. RNFileUploader
is the TurboModule and is deliberately not reachable from plain Objective-C, because its
generated header is Objective-C++ only. So the handler lives on RNBackgroundUpload.
The library owns a durable queue of HTTP requests: JSON bodies, multipart
forms, whole files, and chunked files. You describe each request kind one
time with define(), enqueue instances with mutate(), and receive every
outcome through the definition's handlers. Outcomes survive app death,
because the native side journals them before it tells JS.
import { createUploadClient } from 'react-native-background-upload';
export const uploads = createUploadClient();
// One definition per request kind. `request` runs one time, at mutate().
// `vars` is any JSON-serializable object, at most 1 MB by default; native
// persists it next to the entry. Generated API request types work as they are.
type AddCommentVars = { siteId: string; noteId: string; comment: string };
export const addComment = uploads.define({
key: 'note.comment.add', // persisted with every entry; rename with care
request: ({ siteId, noteId, comment }: AddCommentVars) => ({
url: `https://api.example.com/sites/${siteId}/notes/${noteId}/comments`,
data: { comment }, // JSON body. Default method is POST.
}),
// Parses the JSON body before onSuccess. Zod users pass schema.parse. The
// parser also receives the entry's vars as a second argument.
response: (raw) => (raw as { content: Comment[] }).content,
onSuccess: (content, { noteId }, meta) => {
// Runs after the server accepted the request, possibly on a later launch.
store.dispatch(commentsLoaded({ noteId, content }));
},
onError: (error, vars, meta) => {
// error.errorKind: 'http' | 'network' | 'file' | 'expired' | 'truncated' | 'unknown'
},
});
// At boot, after every define() call. Replay of journaled outcomes starts here.
uploads.configure({
headers: () => ({ Authorization: `Bearer ${currentToken()}` }),
android: { notificationTitle: 'Uploading', notificationChannel: 'uploads' },
});
// Anywhere. Resolves when the entry and its staged body are on disk, never
// on the network. You may delete a source file after this resolves.
const { id } = await addComment.mutate(
{ siteId, noteId, comment },
{ id: localCommentId }, // optional; makes a re-dispatch idempotent
);Handlers must be idempotent. The library acknowledges an outcome only after the handler's promise resolves, so a crash before that point redelivers the outcome at the next launch.
request(vars) returns a plain object. Set at most one body kind. A GET sets
none, and mutate() rejects a GET with a body. A DELETE, or a POST whose
meaning is in the URL, may also set none.
A field outside this table makes mutate() reject and name the field, because
TypeScript does not flag a misspelled key on an inferred arrow return.
| Field | Notes |
|---|---|
url |
Required unless parts is set. |
method |
POST (default), PUT, PATCH, DELETE, GET. With parts it applies to every part. |
headers |
Merged over configure().headers(), names matched without regard to case. Every chunked part inherits the result. |
data |
JSON body. Any JSON-serializable value. null sends the JSON body null; omit data for no body. |
form |
multipart/form-data: [{ name, contentType, string }] or [{ name, contentType, path, fileName? }]. File parts are copied. |
file |
Whole file body. Copied. Moved when parts is set. |
parts |
Chunked over file: [{ url, headers?, range: { start, end } }], bytes, end exclusive, tiling the file from 0. |
accept |
Non-2xx responses to treat as success: [{ status, bodyIncludes? }]. |
expiresAt |
Epoch ms. Default now + lifetimeMs (14 days). Past it: error with errorKind: 'expired'. |
retry |
Per-request override of the configure() retry defaults: backoff, terminalHttp.exempt, and transient rules. See "Reliable delivery". |
wifiOnly |
true waits for Wi-Fi before each attempt; false never waits. Overrides setWifiOnly() for this entry. Omit it to follow setWifiOnly(), including later toggles. |
priority |
'high' or 'normal' (default). On Android at most 4 requests transmit at once, and one of those slots is kept for 'high'. Normal requests can use only the other 3, so a high-priority request, such as a small JSON POST that creates a session, gets a slot without waiting behind a backlog of file parts. It can still wait when other high-priority requests hold every slot. iOS ignores it for now. |
android |
{ noNotification?: boolean }. See Silent uploads. |
vars and data cross to native as JSON strings, and native parses them.
React Native on iOS drops object keys whose value is null, so a string is
the only form in which { status: null } arrives intact.
A descriptor with file and parts sends one file as many part requests but
stays one entry: one id, byte-weighted progress, and completed only when
every part is accepted. Author the ranges with chunkPlan so the part count
you tell your server and the parts the library sends derive from one array.
type CaptureFileVars = { path: string; size: number; uploadId: string };
const captureFile = uploads.define({
key: 'capture.file',
request: ({ path, size, uploadId }: CaptureFileVars) => {
const ranges = uploads.chunkPlan(size, { min: 8 * 2 ** 20, max: 20 * 2 ** 20 });
return {
method: 'PUT',
file: path, // moved into the library directory
// Derive each part URL from its index. The part count you tell the
// server and the parts sent here then come from the one chunkPlan call.
parts: ranges.map((range, i) => ({
url: partUrl(uploadId, i + 1), // part numbers are 1-indexed
headers: { 'Content-Range': `${range.start}-${range.end - 1}/${size}` },
range,
})),
accept: [{ status: 409, bodyIncludes: 'already completed' }],
// A 404 on a part means the server-side multipart is gone. Make it
// terminal so onError can recreate under a fresh server upload id.
retry: { terminalHttp: { exempt: [] } },
};
},
onError: (error, vars) => { /* recreate: mutate() again with new parts */ },
});File ownership. A chunked file is moved into the library's directory at
mutate(); a single file body and every form part path are copied. Bytes
are deleted after a completed outcome is acknowledged, or on cancel().
Nothing else deletes them.
Same id, again. The body is data, form, file, or parts, and a
different url or method counts as a different body. mutate() with an
id that exists:
- Same body: resume. New headers,
expiresAt, andvarsreplace the stored ones. - Different body (
data,form,file, orparts) and the entry is not running (queued, paused, awaiting-auth, or settled): the descriptor, staged body,vars, headers, andexpiresAtare replaced and the entry reopens. It settles once more. - Different body, entry running:
mutate()rejects withE_RUNNING. - A cancelled entry whose outcome is not yet acknowledged: a fresh generation. The old outcome's ack forgets the entry only when the generation matches.
- A completed entry whose outcome is not yet acknowledged: the journaled outcome is emitted again. The request does not run again.
android: { noNotification: true } runs the request without a progress
notification. That notification is also the worker's foreground-service
notification, so a silent request runs as an ordinary background worker and
the OS may defer or restart it. Reserve it for small payloads.
Headless time limit. On API 31 and later, a WorkManager run that starts
from the background usually cannot start its foreground service. The run
then has JobScheduler's limit of about 10 minutes. A single body (data,
form, file) that does not finish in that time starts again from byte 0
at the next run, after a growing backoff. A body that needs more than
10 minutes headless cannot finish that way. Use parts for large bodies:
accepted parts are kept across runs. iOS has no equal limit.
Backups. The queue store (files/rnbgupload-chunked/) and the journal
(files/rnbgupload-settled/) hold request headers, including auth tokens,
and staged bodies. Set android:allowBackup="false" in the host app, or
exclude those two directories in its backup rules.
Bodies are files. A background URLSession uploads from files only, so
the library writes every data and form body to a file in its own
directory at mutate(). The bytes stay there until the entry is forgotten.
The store and journal are excluded from iCloud and iTunes backups.
No global cap. iOS has no hard limit on requests in flight. Each of the
two background sessions (cellular allowed, and Wi-Fi only) sets
httpMaximumConnectionsPerHost = 4. That caps connections to one host, not
requests: over HTTP/2, every queued part can be in flight at once.
Every part is queued at once. A chunked upload creates a task for every
part at mutate(). Each part task reads its own part file, so a chunked
upload needs the file's size again in free space until its parts are sent.
Not at the system's discretion. Both sessions set
isDiscretionary = false, but iOS only honors that for a task created while
the app is in the foreground. It starts a task created in the background at
its own discretion, for example when the device charges. Tasks are often
created in the background: a retry scheduled while the app is there, a
mutate() from a background wake, and the tasks a relaunch rebuilds. So the library records whether the app was active when it created
each task. Two seconds after the app becomes active, it replaces every task
created while the app was not active, unless the task has sent a byte of its
body or has a response. The delay lets the system report what the task did
while the app was suspended. A simple request's replacement keeps the
attempt's request id; a part's replacement is a new attempt. Either keeps
what is left of a backoff. A request with an empty body is never replaced,
because it cannot show that it started. Tasks created by an earlier version
of the library count as background tasks, so the first activation after an
upgrade replaces those too.
Two limits remain. A task created in the background can wait for the system
until the app next becomes active. And if the system has sent a body without
reporting it yet, the replacement sends it again. The server sees the same
X-Request-Id for a simple request, and either copy that is accepted
completes the entry.
Force-quit. When the user swipes the app away, iOS cancels the session's
tasks. The library treats that as a transient failure: no outcome is
produced, the entry stays queued or running, and it is sent again at the
next launch. Accepted chunked parts are kept. A suspension or a system
termination is different: the tasks keep running, and the AppDelegate hook
above lets the library journal their outcomes.
Relaunch. When the app comes back and an entry's task is gone but a completion may still be in flight from the daemon, the library waits up to 10 s for it before it sends again. This is what stops a request that finished while the app was dead from being sent twice.
- Write-ahead. Entry, descriptor, and staged body persist before any
attempt.
mutate()resolves after the row and every staged body copy are durably on disk (temp file plus rename), so the caller may delete its source file then. A native failure rejects with a code:E_INVALID,E_RUNNING,E_FILE_MISSING, orE_STORAGE.E_INVALIDis malformed input native cannot send: a non-http(s) URL, header names or values the platform HTTP client rejects, a GET with a body, or parts that do not tile the moved file. - Journal before emit, ack after the handler. Every terminal outcome is
journaled natively, then delivered. The library acknowledges after the
handler's promise resolves. A rejection, or app death before the ack,
redelivers at the next launch. A handler that has not settled after 30 s
gets a console warning and keeps waiting.
Meta.deliveriescounts deliveries that reached a JS listener: 1 on the first, +1 per replay. An outcome journaled while no listener exists starts at 0 and is not emitted live. A handler that keeps throwing sees the number grow. The library never gives up on its own; the app decides a poison policy from that number. - One outcome per settle cycle.
pause()produces none, for the whole queue or for a set of keys. A paused entry pastexpiresAtsettleserrorwitherrorKind: 'expired'when it is resumed. A same-idmutate()on a settled entry reopens it, and it settles once more. - Never before
mutate()resolves. Delivery for an id waits for the caller's promise. While native has not answeredenqueue, the id's outcomes wait too. They stay journaled and unacknowledged, so none is lost. - Replay starts after
configure(). Outcomes journaled by a dead session deliver then. Call everydefine()first. - Unknown key is loud. An outcome whose key has no definition stays
unacknowledged and reaches
statelisteners withreason: 'unhandled-key'. - Completed entries are forgotten after ack. Row and bytes go. An
errororexpiredentry keeps both untilcancel()or a same-idmutate(). - Ordering holds per id only. The outcomes of one id are delivered in order. There is no ordering guarantee between different ids.
attemptevents are live-only. They are never journaled or replayed.
Retry classes:
| Attempt outcome | Action |
|---|---|
2xx, or an accept rule matches |
settle completed |
| network failure, 5xx, 408, 429 | exponential backoff (base 1 s, max 2 h, jitter 0.2) until expiresAt |
| 401, 403 | park as awaiting-auth; resume on updateHeaders() |
other 4xx matching a retry.transient rule (status, optional bodyIncludes), under the rule's maxRetries |
as transient. The first matching rule in the list wins. maxRetries: 3 retries three matching responses; the fourth settles. No maxRetries means no cap. See "Transient rule counts" below. A rule for 401, 403, 408 or 429 is rejected at the call |
other 4xx in retry.terminalHttp.exempt (default [404]), when no rule matched |
as transient, with no cap |
other 4xx, or a rule past its maxRetries |
settle error with errorKind: 'http', response attached |
| payload missing on disk | settle error with errorKind: 'file' |
expiresAt passed |
settle error with errorKind: 'expired'; bytes kept |
response body over 1 MB with a response parser |
onError with errorKind: 'truncated'; the entry is completed |
Transient rule counts. The library counts the responses each rule
excuses. It keeps the count by the rule's status and bodyIncludes, not by
its place in the list:
- Reorder the rules, or add a rule in front, and each rule keeps its count.
- Change a rule's
maxRetries, and the new cap applies to the count so far. - Change a rule's
statusorbodyIncludes, and it is a new rule. Its count starts at 0. - A simple request has one count per rule.
- A chunked request has one count per rule for each part. Each part gets the
full
maxRetries. The request settleserrorwhen one part goes past the cap, with that part's response. - A same-id
mutate()that reopens a settled entry starts every count at 0. A same-idmutate()on a live entry keeps the counts.
Every attempt sends an X-Request-Id header, minted per attempt. Meta.requestId
carries the last one.
createUploadClient() returns a client. The default export is one client;
an app needs one.
type Definition<V extends object | null, T> =
| {
key: string;
request: (vars: V) => RequestDescriptor;
response: (raw: unknown, vars: V) => T; // JSON-parsed body, or undefined when there is none
onSuccess?: (data: T, vars: V, meta: Meta) => void | Promise<void>;
onError?: (error: OutcomeError, vars: V, meta: Meta) => void | Promise<void>;
}
| {
key: string;
request: (vars: V) => RequestDescriptor;
response?: undefined;
onSuccess?: (data: RawResponse, vars: V, meta: Meta) => void | Promise<void>;
onError?: (error: OutcomeError, vars: V, meta: Meta) => void | Promise<void>;
};V infers from the request parameter annotation, T from the response
return type. response also receives the entry's vars, for a parser that
needs the request context; a one-argument parser such as schema.parse is
assignable as it is. Without response, onSuccess receives the RawResponse
({ status?, headers?, body?, bodyTruncated }), and an onSuccess annotated
with any other type is a compile error. V is any object or null, so a
generated API request type works as it is. mutate() rejects vars and
data that do not serialize: a cycle, a function, a BigInt, or a value that
JSON.stringify turns into a primitive. Methods on a class instance are
dropped. A request that declares no parameter gives V = null, and
mutate() then takes no arguments. When response is set and
the body was truncated, onError gets errorKind: 'truncated'. When
response throws, onError gets errorKind: 'unknown' with the thrown
message; the entry still settles as completed. A key that is already defined
is replaced, with a warning in development. A cancelled outcome calls no
handler.
Meta is { id, key, at, attempts, requestId?, deliveries }; at is the
native outcome time. attempts counts attempts in the current generation; a
same-id mutate() that reopens the entry starts a new one. deliveries
counts deliveries that reached a JS listener: 1 on the first, +1 per replay.
An outcome journaled while no listener exists starts at 0 and is not emitted
live, so its first delivery, at the boot replay, is 1.
Runs request(vars) once, merges configure().headers() under the
descriptor's headers, validates the descriptor, defaults expiresAt, and
persists the entry. Resolves with the id after the row and every staged body
copy are on disk. Rejects on a malformed descriptor, an unknown descriptor
field, a GET with a body, input native cannot send (E_INVALID), a missing
file (E_FILE_MISSING), a storage failure (E_STORAGE), a running entry
with a different body (E_RUNNING), or vars over
maxVarsBytes (1 MB by default). Only vars are capped. mutate() has no
timeout: it settles only with native's answer, and warns one time when that
takes longer than enqueueTimeoutMs. id defaults to a UUID. For a definition
whose request takes no vars, call mutate() with no arguments; native stores
null.
Call one time at boot, after every define(). Starts replay of journaled
outcomes. A second call updates the settings and does not replay again.
| Option | Notes |
|---|---|
lifetimeMs |
Default expiresAt distance. Default 14 days. |
maxVarsBytes |
Cap on the JSON length of vars. Default 1 MB. mutate() rejects above it. JS-side only. |
retry |
{ backoff?: { baseMs, maxMs, jitter }, terminalHttp?: { exempt }, transient?: Array<{ status, bodyIncludes?, maxRetries? }> }. Each object is optional, but one you give must be complete. Defaults 1 s, 2 h, 0.2, [404], []. A descriptor's retry replaces each field it names. |
headers |
() => Record<string, string>, called at mutate(). The descriptor merges over it. |
enqueueTimeoutMs |
Default 10 s. mutate() warns one time when native enqueue has not answered by then, and keeps waiting. It does not make mutate() reject. Enqueue includes the time to stage a copy of a file body and of form path parts, and it waits behind earlier enqueues, so a large file takes longer. |
onWarning |
(message, detail?) => void. Receives every warning that the library's JavaScript raises, instead of console.warn: a slow enqueue, a slow or rejected handler, a failed ack, and the rest. Native warnings do not come here; read them with takeDiagnostics(). detail is the error or event, when there is one. Warnings raised before configure() go to console.warn. If the sink throws, the warning and the throw go to console.warn. |
android |
Notification text and identity: notificationId/Title/TitleNoWifi/TitleNoInternet/Channel. Persisted natively. |
scope is { keys?: string[] }. No scope pauses or resumes the whole queue.
{ keys } pauses or resumes the entries of those definition keys, queued and
future. An empty keys list changes nothing. A scope field other than keys,
keys: undefined, or a key that is not a non-empty string rejects, so a
mistake cannot pause the whole queue.
An entry is paused while the whole queue is paused or its key is paused. A resume of one scope does not resume an entry that the other still pauses:
await Upload.pause({ keys: ['capture.upload'] }); // captures wait, notes run
await Upload.pause(); // everything waits
await Upload.resume(); // notes run, captures still wait
await Upload.resume({ keys: ['capture.upload'] }); // captures runBoth states are persisted natively. Each entry that moves emits one state
event (paused, then queued). No outcome is produced and the bytes are
kept. A paused entry past expiresAt settles error with
errorKind: 'expired' when it is resumed.
A live entry settles cancelled with reason user and is forgotten after
its ack. A settled entry is forgotten now: row, bytes, and its
unacknowledged outcomes. An id with no row resolves. If its directory has
no entry file, the directory is deleted. That is a v9 upload's bytes, or a
chunked file kept after an enqueue that rejected E_STORAGE. The library
had moved that file into its queue, so the delete discards the only copy.
For a live entry, the journaled cancelled outcome is the cancel. Once it is
written, cancel() resolves, even if the entry save that follows fails: the
work stops and the outcome's ack, a retry, or the next launch finishes it. If
the journal (live entry) or the store (settled entry) cannot be written,
cancel() rejects with E_STORAGE and changes nothing. The caller may call
again.
The queue's Wi-Fi setting. Persisted natively. Applies to queued and future
entries whose descriptor does not set wifiOnly. An entry that sets it keeps
its own value. Both are checked before each attempt, so a toggle moves the
queued entries that follow the setting.
Merges the patch into the headers of every entry not yet forgotten and
resumes the entries parked on awaiting-auth. The patch also replaces
same-named headers a part carries. This is how a fresh token
reaches requests that stalled on 401. Each call bumps a header generation: a
401 or 403 from an attempt issued under an older generation re-issues at once
instead of parking. Parking emits one state event per entry. A header name
or value the platform HTTP client cannot send rejects with E_INVALID.
Synchronous, from native's in-memory index, so it works offline. Returns
every entry native has not yet forgotten: queued, running,
awaiting-auth, and paused entries; completed and cancelled entries
until their ack; error entries until cancel() or a same-id mutate();
and rows imported from a v9 install (key legacy, see Upgrading from v9).
filter is { key?, id? }. A row is
{ id, key, vars, state, bytesSent, totalBytes, attempts, updatedAt, nextAttemptAt? };
nextAttemptAt (epoch ms) is set while the entry waits out a retry backoff.
vars is typed Json, because a row does not know its definition. Narrow
it before reading a field, for example to cancel every entry of one capture:
uploads
.getRequests({ key: 'capture.file' })
.filter((row) => (row.vars as { captureId?: string }).captureId === captureId)
.forEach((row) => uploads.cancel(row.id));The library's persisted diagnostic records, oldest first. Each call returns
the records since the last call and clears them, so send them somewhere
they can be seen, such as an error tracker, once per launch. A record is
{ at, level, message, error?, data? }, with level one of info, warn
or error. error is the exception's class and message, such as
java.io.IOException: disk full. iOS errors have no class name, so there it
is the error's domain, code and message, such as
NSCocoaErrorDomain 257: The file couldn't be opened..
When the module starts, both platforms add one info record with the
message boot sweep. On Android that is once per launch plus once per JS
reload. On iOS it is once per process: a JS reload after the record was
taken gets no new one.
Its data.v9Leftovers says how much v9 work is still on the device, so you
can tell when no device needs the v9 upgrade paths any more. The counts do
not overlap: legacyRows + unownedUploads is the number of v9 ids left.
legacyRows: rows with keylegacythat the app has not cancelled.legacyBytes: the v9 manifest and blob those rows still keep.unownedUploads: v9 upload directories with no row.cancel(id)deletes them. A directory is v9 when it holds a v9 manifest. On iOS a file named exactlyblobalso marks it.unownedBytes: the size of the files in those directories, best effort.unownedIds: the upload ids of those directories, sorted, at most 100.unownedUploadsstays exact when there are more.
Its data.orphans ({ dirs, bytes, removedDirs, removedBytes }) is about
the other directories with no entry file. These are the library's own
leftovers, from a crash between staging a body and saving its entry, or from
an enqueue whose entry save failed. They are not v9 work. On Android a
directory with only a blob and no manifest counts here, because a v10
chunked upload also uses that name.
At each start the library deletes some of these directories by itself, so the app has nothing to do. It deletes a directory only when all of these are true:
- It has no entry file and no
entry.json.tmp. - It is not v9: no v9 manifest, and on iOS no file named exactly
blob. - Every file in it is a request body the library copied or wrote, or the
temporary file of such a write. On Android the names are
body-<n>.json,body-<n>.multipartandfile-<n>, each with or without.tmp. On iOS they arebody-<uuid>andbody-<uuid>.tmp. - The newest file in it, and the directory itself, changed more than 24 hours before the start.
The cleanup relies on these rules together. With no entry file and no
entry.json.tmp, no entry for the directory was ever saved, so its enqueue
did not resolve and the caller still has its source.
It never deletes a directory that holds anything else. A directory with
entry.json.tmp is kept: after a crash or a forced reboot, that file can be
the only trace of an entry whose save did not reach the disk, while its
enqueue had already resolved. A subdirectory, a symbolic link, a file it does
not know, or a file time it cannot use (0, before 1970, or in the future)
also keeps the directory. It never deletes a chunked file (Android blob or
blob-<n>, iOS blob-<uuid>). The library moved that file in from the
caller, so it can be the only copy of the bytes. A mutate() with the same
id still uses it, as after an enqueue that rejected E_STORAGE.
cancel(id) deletes it.
dirs and bytes now count what is left after this cleanup, not
everything the count found. removedDirs and removedBytes count what it
deleted. When a delete fails, a warn record
comes before the snapshot, the directory stays, and the cleanup goes on with
the next one.
The count lists directories and reads file sizes. It opens no file. It runs
after the start has scheduled queued work, and so does the cleanup, which
looks only at the directories the count found. On Android both run on the
module's queue thread, after the boot sweep. On iOS the count runs on a
background queue, then the cleanup runs as one block on the queue that also
runs every enqueue. That block runs before or after the relaunch reconcile,
never during it. On both platforms no enqueue can be in progress while the
cleanup runs, and it checks for the entry file again just before each
delete. When a directory cannot be read, a warn record comes before the
snapshot, and the counts leave that directory out. On a clean install every
count is 0. Zeros are reported, not left out.
Android keeps the newest 50 records. They are the module's warnings and
errors, including those from background runs that had no JS, and the boot
record. A new boot record replaces one that no takeDiagnostics() took, so
launches without a call do not push out warnings and errors. The replaced
record's WorkManager-run detail is lost with it: you see only the newest
launch's runs. The record's
data also counts the entries the start schedules, by state and by key, and
gives the oldest updatedAt among them, all as the start found them: an
entry that was running when its process died counts as running. It also
describes every unfinished WorkManager run the library had before the start
changed them: the count by state, how many are delayed wakes, how many of
those are due more than 24 hours out, the highest run attempt count, and how
many queued or running entries had no run at all.
On iOS the boot record has only v9Leftovers and orphans. The other iOS
records are the warnings of the count and of the orphan cleanup, and a
warning when cancel(id) cannot delete an unowned directory. iOS keeps its records in memory, the
newest 50, so a record that no takeDiagnostics() took is gone when the
process ends. The next launch counts again.
Splits a byte count into contiguous, end-exclusive ranges: a deterministic
greedy walk of max-sized chunks (default 20 MB), with a final remainder
smaller than min (default 8 MB) absorbed into the previous chunk. A file
smaller than min is a single chunk. Also a module export.
See Events. Listeners are global; every event carries the entry's id. Call
.remove() on the result to unsubscribe.
Fires when the Android progress notification is pressed. No event data.
| Event | Data |
|---|---|
state |
A full RequestRow, one per transition, plus reason: 'unhandled-key' for an outcome whose key has no definition. A consumer's reducer is one upsert. |
progress |
{ id, bytesSent, totalBytes }, byte-weighted across a chunked upload's parts. |
attempt |
One HTTP attempt: { id, key, requestId, attempt, url, method, partIndex?, outcome, httpCode?, responseBody? (4 KB cap), responseBodyTruncated?, responseHeaders?, errorKind?, errorMessage?, at }. outcome is completed for an accepted response (2xx or a matching accept rule). Any other HTTP response is error with errorKind: 'http'; a transport failure is error with its own errorKind. Pause, cancel, and supersede emit no attempt event. Live-only; never journaled or replayed. |
Terminal outcomes do not appear here. They go to the definition's handlers.
The CHANGELOG lists every removed v9 export with its replacement. In short:
startUpload becomes a define() plus mutate(); getAllUploads becomes
getRequests(); cancelUpload and removeUpload become cancel(id); the
terminal event names become the definition's handlers; per-upload wifiOnly
stays on the descriptor, and setWifiOnly() sets the value for entries that
omit it.
What happens to work a v9 build left behind:
- The v9 journal becomes
legacyrows. On the first v10 launch, every v9 journal entry that JS never acknowledged becomes a read-only settled row with keylegacy, the v9 upload id, and0/0bytes. No handler runs for them. Read them withgetRequests({ key: 'legacy' }), reconcile your own state, thencancel(id)each one. The import runs before the firstgetRequests()answers. - In-flight v9 work is cancelled natively. Nothing runs unowned.
- v9 chunked uploads resume under the same id. The v9 manifest and bytes
stay on disk. A
mutate()with the v9 upload id and the same parts resumes from the accepted parts; different parts start over on the kept bytes. An id with nomutate()keeps its bytes untilcancel(id), which deletes them. Cancel every v9 upload id you will not send again. - Track what is left. The boot record of
takeDiagnostics()hasdata.v9Leftovers: the legacy rows and the bytes they keep, and the v9 uploads with no row and their bytes. The library never deletes them by itself, because only the app knows which v9 ids it will still send. When every device reports zeros, the v9 paths can go.data.orphansis not v9 work and does not count toward this. The start-up cleanup of orphans never deletes v9 files. A v9 upload with no row is known to the app only by its id, so the record lists them inv9Leftovers.unownedIds. Compare these ids with the transfers the app still means to send, andcancel(id)the rest. Otherwise the count never reaches zero. - Wi-Fi only starts off. Call
setWifiOnly(true)again if the app had it on.
Each mutate() on an id that already exists follows the rules in "Same id,
again" above, so a re-dispatch from persisted app state is safe.
createUploadClient({ native }) takes a fake native module, and
createFakeNative() builds one. The real validation, header merge, and
delivery run on top of it; the fake keeps rows, journals outcomes, and acks,
but never sends HTTP.
Importing the package root resolves the native module at load, so a test must
mock react-native's TurboModuleRegistry first, as below. The fake imports
no react-native code at runtime, so the mock factory can require it.
jest.mock('react-native', () => {
const { createFakeNative } = jest.requireActual(
'react-native-background-upload/src/testing',
);
const fake = createFakeNative();
return {
TurboModuleRegistry: { getEnforcing: () => fake, get: () => fake },
};
});
import { createUploadClient } from 'react-native-background-upload';
import { createFakeNative } from 'react-native-background-upload/src/testing';
const native = createFakeNative();
const uploads = createUploadClient({ native });
const onSuccess = jest.fn();
const ping = uploads.define({
key: 'ping',
request: () => ({ url: 'https://api.test/ping' }),
onSuccess,
});
uploads.configure({});
const { id } = await ping.mutate();
await native.settle(id, { kind: 'completed', response: { body: '{}' } });
expect(onSuccess).toHaveBeenCalled();settle() resolves after delivery acks the outcome, so an async handler
has finished. It rejects after 2 s (ackTimeoutMs) when no ack comes. A
handler that rejects is not acked, so test it with a short timeout:
const native = createFakeNative({ ackTimeoutMs: 50 });
// ... define, configure, and mutate as above ...
await expect(native.settle(id, outcome)).rejects.toThrow(/not acknowledged/);If you fire an event with native.emit.settled() instead, delivery runs the
handler and the ack later. Flush pending promises (for example
await new Promise(setImmediate)) before you assert on
native.ackedEventIds.
The fake does not model the same-id rules (resume, replace, E_RUNNING,
re-emit) or the header merge of updateHeaders(); script those with
failNext() and seedUnacknowledged().
native.entries holds every enqueue as mutate() sent it, with vars and
data parsed back from JSON. failNext() makes the next native call reject
with a code such as E_STORAGE. holdNext('enqueue') keeps the next enqueue
pending, as when native is slow to answer, and returns a function that
releases it. Native runs enqueue calls one at a time, so later enqueues wait
behind a held one, as they do on a device. On release they all run in order,
as usual, and a failure that failNext() queued goes to the first call that
runs. reset() drops held calls and the calls behind them. seedUnacknowledged() and seedRows()
model a journal and rows left by a dead session, so configure() replays
them. seedDiagnostics() adds records for takeDiagnostics(), and
seedBootSnapshot({ v9Leftovers: { unownedUploads: 2 } }) adds a boot record
with those counts, the others 0 and no ids.
An app that builds its client at module load can use the fake from the mock
for every client instead. Get it with
TurboModuleRegistry.getEnforcing('RNFileUploader'), and call
native.reset() in beforeEach.
See CONTRIBUTING.md.