Skip to content
Draft
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
21 changes: 21 additions & 0 deletions src/libs/QueuedFileStorage/index.native.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type {DeleteFiles, GetFile, KeepOnly, QueuedFileRef, StoreFile} from './types';

import {isQueuedFileRef} from './types';

/**
* Native storage has no IndexedDB blob-write limit, so files stay inline in the queue — there is
* no out-of-band store. isFileStorageSupported is false so the queue skips the file swap; the other
* stubs keep the API shape identical to web.
*/
const isFileStorageSupported = false;

const storeFile: StoreFile = () => Promise.reject(new Error('[QueuedFileStorage] storeFile is not supported on native'));

const getFile: GetFile = () => Promise.resolve(undefined);

const deleteFiles: DeleteFiles = () => Promise.resolve();

const keepOnly: KeepOnly = () => Promise.resolve();

export {storeFile, getFile, deleteFiles, keepOnly, isQueuedFileRef, isFileStorageSupported};
export type {QueuedFileRef};
93 changes: 93 additions & 0 deletions src/libs/QueuedFileStorage/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import Log from '@libs/Log';

import type {DeleteFiles, GetFile, KeepOnly, QueuedFileRef, StoreFile} from './types';

import {isQueuedFileRef} from './types';

/**
* Web backend for QueuedFileStorage: keeps a queued upload's bytes out of the Onyx request queue,
* in Cache Storage. Cache Storage uses a different disk backend than the IndexedDB blob path that
* fails with `DataError: Failed to write blobs`, and mirrors Attachment/index.ts.
*/
const isFileStorageSupported = true;

const CACHE_NAME = 'OnyxQueuedFiles';
// Same-origin synthetic path; a real origin avoids any Request-URL validation edge cases.
const KEY_PREFIX = '/__queued-file__/';

let keyCounter = 0;

function generateKey(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
keyCounter += 1;
return `qf-${keyCounter}-${Date.now()}`;
}

/** The synthetic Request URL a queued-file key maps to inside the cache. */
function urlForKey(key: string): string {
return `${KEY_PREFIX}${encodeURIComponent(key)}`;
}

/** Recover the queued-file key from a cached Request URL (the cache stores resolved absolute URLs). */
function keyForUrl(url: string): string | undefined {
const {pathname} = new URL(url);
if (!pathname.startsWith(KEY_PREFIX)) {
return undefined;
}
return decodeURIComponent(pathname.slice(KEY_PREFIX.length));
}

const storeFile: StoreFile = async (blob) => {
const key = generateKey();
try {
const cache = await caches.open(CACHE_NAME);
// Preserve MIME type on the Response so getFile() returns a correctly typed Blob.
const headers = new Headers();
if (blob.type) {
headers.set('Content-Type', blob.type);
}
await cache.put(urlForKey(key), new Response(blob, {headers}));
} catch (error) {
// A store failure means an upload can't be queued; surface it so prod telemetry shows the rate.
Log.alert('[QueuedFileStorage] Failed to store queued file in Cache Storage', {error});
throw error;
}
return key;
};

const getFile: GetFile = async (key) => {
const cache = await caches.open(CACHE_NAME);
const response = await cache.match(urlForKey(key));
if (!response) {
return undefined;
}
return response.blob();
};

const deleteFiles: DeleteFiles = async (keys) => {
if (keys.length === 0) {
return;
}
const cache = await caches.open(CACHE_NAME);
await Promise.all(keys.map((key) => cache.delete(urlForKey(key))));
};

const keepOnly: KeepOnly = async (keysToKeep) => {
const cache = await caches.open(CACHE_NAME);
const requests = await cache.keys();
const keep = new Set(keysToKeep);
await Promise.all(
requests.map((request) => {
const key = keyForUrl(request.url);
if (key === undefined || keep.has(key)) {
return Promise.resolve(false);
}
return cache.delete(request);
}),
);
};

export {storeFile, getFile, deleteFiles, keepOnly, isQueuedFileRef, isFileStorageSupported};
export type {QueuedFileRef};
19 changes: 19 additions & 0 deletions src/libs/QueuedFileStorage/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* A small serializable reference that stands in for a File/Blob inside a queued request.
* The bytes live in a separate file store (see storeFile); this ref points at them.
*/
type QueuedFileRef = {queuedFileKey: string; name?: string; type?: string};

/** Narrow an unknown value to a QueuedFileRef (truthy object carrying a string key). */
function isQueuedFileRef(value: unknown): value is QueuedFileRef {
return typeof value === 'object' && value !== null && 'queuedFileKey' in value && typeof value.queuedFileKey === 'string';
}

type StoreFile = (blob: Blob) => Promise<string>;
// Returns the stored value (a Blob in the browser); typed as unknown because IndexedDB reads are untyped.
type GetFile = (key: string) => Promise<unknown>;
type DeleteFiles = (keys: string[]) => Promise<void>;
type KeepOnly = (keysToKeep: string[]) => Promise<void>;

export {isQueuedFileRef};
export type {QueuedFileRef, StoreFile, GetFile, DeleteFiles, KeepOnly};
Loading
Loading