@hachej/boring-files 0.4.23 → 0.4.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -3
- package/dist/remote.js +9 -1
- package/dist/revision-contracts.d.ts +6 -0
- package/dist/revision-handler.d.ts +9 -0
- package/dist/revision-handler.js +14 -3
- package/dist/revision-protocol.d.ts +12 -0
- package/dist/revision-protocol.js +16 -0
- package/dist/revision.js +17 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -229,9 +229,24 @@ refresh after concurrent changes. Traversal over the configured `maxEntries`
|
|
|
229
229
|
(default 100,000) fails explicitly. History contains revisions retained by the
|
|
230
230
|
workspace journal, not Git commits or an imported filesystem's prior history.
|
|
231
231
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
232
|
+
File search (the `@` picker, the tree's filter) and uploads use this one route
|
|
233
|
+
too: `search` is a catalog request, and an upload is a conditional create
|
|
234
|
+
(`expected: absent`) with a receipt, like any other publication. There is no
|
|
235
|
+
separate search or upload route to serve.
|
|
236
|
+
|
|
237
|
+
`createRevisionHandler({ resolve, maxFileBytes })` names the largest file one
|
|
238
|
+
write may carry, in bytes (default 3 MiB; at most
|
|
239
|
+
about 48 MiB). Bytes travel as base64, so the handler sizes its resource
|
|
240
|
+
requests and responses to that limit (at least 4 MiB and 8 MiB). A larger file
|
|
241
|
+
gets the typed `file_too_large` result (`{ path, size, limit, reason }`) and
|
|
242
|
+
nothing is written; a replay of an operation the journal already has keeps its
|
|
243
|
+
recorded result. A request larger than the transport carries is answered 413,
|
|
244
|
+
which the client reports as `unavailable` (a definite refusal). The binding
|
|
245
|
+
(`GET` without `op`) names the limit as `limits.maxFileBytes`;
|
|
246
|
+
`connectRevisionProvider` sizes its requests to it and exposes it as
|
|
247
|
+
`maxFileBytes`, and the file tree refuses a larger upload before sending it.
|
|
248
|
+
The provider's own file system may refuse a smaller file (SQLite: 1 900 000
|
|
249
|
+
bytes by default). This API does not provide streaming uploads.
|
|
235
250
|
|
|
236
251
|
### Durable Object apps
|
|
237
252
|
|
package/dist/remote.js
CHANGED
|
@@ -2,6 +2,7 @@ import { PublicationNotDispatchedError, identifier, publicationDigest, publicati
|
|
|
2
2
|
import { contentType, encode, envelope, identity, jsonBody, limit, lookupResult, observe, publicationResult, renameResult, readResult, readSnapshot, sameIdentity, schema, version } from './remote-protocol.js';
|
|
3
3
|
import { randomUUID } from './platform.js';
|
|
4
4
|
export { createResourceHandler } from './remote-handler.js';
|
|
5
|
+
const tooLarge = new Error('Resource request too large');
|
|
5
6
|
export function createResourceClient(options) {
|
|
6
7
|
if (options.publication && !options.reconciliation)
|
|
7
8
|
throw new TypeError('A publishing resource client needs reconciliation: a lost save acknowledgement could otherwise never be resolved');
|
|
@@ -29,6 +30,8 @@ export function createResourceClient(options) {
|
|
|
29
30
|
const response = await observe(pending, combined);
|
|
30
31
|
if (response.redirected || response.status !== 200) {
|
|
31
32
|
void response.body?.cancel().catch(() => { });
|
|
33
|
+
if (response.status === 413 && !response.redirected)
|
|
34
|
+
throw tooLarge;
|
|
32
35
|
throw new Error('Remote resource response unavailable');
|
|
33
36
|
}
|
|
34
37
|
const result = envelope(await jsonBody(response, responseLimit, combined));
|
|
@@ -36,9 +39,11 @@ export function createResourceClient(options) {
|
|
|
36
39
|
throw new TypeError('Resource response association mismatch');
|
|
37
40
|
return result.value;
|
|
38
41
|
}
|
|
39
|
-
catch {
|
|
42
|
+
catch (error) {
|
|
40
43
|
if ((call.kind === 'publish' || call.kind === 'rename' || call.kind === 'delete') && !dispatched)
|
|
41
44
|
throw new PublicationNotDispatchedError(call.value.operationId);
|
|
45
|
+
if (error === tooLarge)
|
|
46
|
+
throw error;
|
|
42
47
|
throw new Error('Remote resource operation is unconfirmed');
|
|
43
48
|
}
|
|
44
49
|
finally {
|
|
@@ -73,6 +78,9 @@ export function createResourceClient(options) {
|
|
|
73
78
|
catch (error) {
|
|
74
79
|
if (error instanceof PublicationNotDispatchedError)
|
|
75
80
|
throw error;
|
|
81
|
+
// 413: the server refused the body before reading it as a write (the revision handler's `maxFileBytes`), so nothing was written.
|
|
82
|
+
if (error === tooLarge)
|
|
83
|
+
return { kind: 'unavailable', reason: 'The workspace refused the file as too large' };
|
|
76
84
|
return { kind: 'unknown', operationId: captured.operationId, reason: 'Remote publication is unconfirmed; reconcile the original operation without automatic replay' };
|
|
77
85
|
}
|
|
78
86
|
},
|
|
@@ -94,6 +94,12 @@ export interface RevisionProvider extends ResourceClient {
|
|
|
94
94
|
readonly list: (request: FileListRequest, signal?: AbortSignal) => Promise<FilePage>;
|
|
95
95
|
readonly search: (request: FileSearchRequest, signal?: AbortSignal) => Promise<FilePage>;
|
|
96
96
|
readonly history: (path: string, signal?: AbortSignal) => Promise<readonly SavedRevision[]>;
|
|
97
|
+
/**
|
|
98
|
+
* The host's largest file content per write, in bytes, when the binding names one (`createRevisionHandler({ maxFileBytes })`). The
|
|
99
|
+
* server answers a larger file with the typed `file_too_large` result; a UI may refuse it before sending (the file tree does).
|
|
100
|
+
* Absent: the host named no limit.
|
|
101
|
+
*/
|
|
102
|
+
readonly maxFileBytes?: number;
|
|
97
103
|
/**
|
|
98
104
|
* The workspace's change feed (`GET ?op=changes`, server-sent events), reconnecting after the last `seq` seen until `signal`
|
|
99
105
|
* aborts; a refused binding (another workspace, revoked access) ends it with a `RevisionError`, and so does a route that serves no feed:
|
|
@@ -9,6 +9,15 @@ export interface RevisionHandlerOptions {
|
|
|
9
9
|
}>;
|
|
10
10
|
/** How often the change feed sends a comment line so proxies keep it open, in milliseconds (default 15 000). */
|
|
11
11
|
readonly heartbeatMs?: number;
|
|
12
|
+
/**
|
|
13
|
+
* The largest file one write (an upload, a save) may carry, in bytes. Default 3 MiB (3 145 728), at most about 48 MiB.
|
|
14
|
+
* A larger file is refused with the typed `file_too_large` result before the provider writes anything, as the provider's own limit
|
|
15
|
+
* is. The binding (`GET` without `op`) names it as `limits.maxFileBytes`; `connectRevisionProvider` exposes it as `maxFileBytes` and
|
|
16
|
+
* sizes its requests to it, and the file tree refuses a larger upload before sending it. The resource calls' request and response
|
|
17
|
+
* limits follow from it (base64 plus the envelope), so a request carrying more in total is answered 413. The provider's file system
|
|
18
|
+
* may still refuse a smaller file (SQLite: 1 900 000 bytes by default).
|
|
19
|
+
*/
|
|
20
|
+
readonly maxFileBytes?: number;
|
|
12
21
|
}
|
|
13
22
|
export declare function createRevisionHandler(options: RevisionHandlerOptions): (request: Request) => Promise<Response>;
|
|
14
23
|
//# sourceMappingURL=revision-handler.d.ts.map
|
package/dist/revision-handler.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createResourceHandler } from './remote-handler.js';
|
|
2
|
-
import { binding, bindingHeader, bindingValue, catalogHeader, filePage, record, savedRevisions } from './revision-protocol.js';
|
|
2
|
+
import { binding, bindingHeader, bindingValue, catalogHeader, DEFAULT_MAX_FILE_BYTES, filePage, fileTransport, record, savedRevisions, tooLarge } from './revision-protocol.js';
|
|
3
3
|
import { guardStatus, readJsonBody, RequestGuardError } from './request-guard.js';
|
|
4
4
|
import { accessSnapshot, identifier } from './publication-input.js';
|
|
5
5
|
import { RevisionError } from './revision-contracts.js';
|
|
@@ -97,6 +97,8 @@ export function createRevisionHandler(options) {
|
|
|
97
97
|
const heartbeatMs = options.heartbeatMs ?? 15_000;
|
|
98
98
|
if (!Number.isSafeInteger(heartbeatMs) || heartbeatMs < 1)
|
|
99
99
|
throw new RangeError('The heartbeat interval must be a positive integer');
|
|
100
|
+
const maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
|
|
101
|
+
const transport = fileTransport(maxFileBytes);
|
|
100
102
|
return async (request) => {
|
|
101
103
|
let release;
|
|
102
104
|
const operations = new Set();
|
|
@@ -123,7 +125,7 @@ export function createRevisionHandler(options) {
|
|
|
123
125
|
const respond = (value) => Response.json(value, { headers: { 'cache-control': 'no-store' } });
|
|
124
126
|
const op = new URL(request.url).searchParams.get('op');
|
|
125
127
|
if (request.method === 'GET' && op === null)
|
|
126
|
-
return respond(association);
|
|
128
|
+
return respond({ ...association, limits: { maxFileBytes } });
|
|
127
129
|
// A resource call (POST without the catalog header) is `createResourceHandler`'s protocol, whose envelope carries and checks
|
|
128
130
|
// the identity, so a plain `createResourceClient` works here too. The listing and the change feed need the binding; a binding
|
|
129
131
|
// that is sent is always checked.
|
|
@@ -166,8 +168,17 @@ export function createRevisionHandler(options) {
|
|
|
166
168
|
if (resourceCall)
|
|
167
169
|
return await createResourceHandler({
|
|
168
170
|
authenticate: async () => access,
|
|
171
|
+
...transport,
|
|
169
172
|
reader: { read: (value, context) => hold(provider.read(value, context)) },
|
|
170
|
-
publisher: { publish: (value, context) => hold(
|
|
173
|
+
publisher: { publish: (value, context) => hold((async () => {
|
|
174
|
+
// A file over the limit is refused like the provider's own limit, before the provider writes anything. An operation the
|
|
175
|
+
// journal already knows is left to the provider, so a replay keeps its committed (or unknown) result.
|
|
176
|
+
const oversized = value.changes.find(change => change.kind !== 'delete' && change.bytes.byteLength > maxFileBytes);
|
|
177
|
+
if (oversized && oversized.kind !== 'delete' && (await provider.reconciliation.lookup(value.operationId, context)).kind === 'not-found') {
|
|
178
|
+
return { kind: 'file_too_large', path: oversized.target.resource.path, size: oversized.bytes.byteLength, limit: maxFileBytes, reason: tooLarge(maxFileBytes) };
|
|
179
|
+
}
|
|
180
|
+
return provider.publication.publish(value, context);
|
|
181
|
+
})()) },
|
|
171
182
|
lookup: { lookup: (value, context) => hold(provider.reconciliation.lookup(value, context)) },
|
|
172
183
|
...(provider.renaming ? { renamer: { rename: (value, context) => hold(provider.renaming.rename(value, context)) } } : {}),
|
|
173
184
|
...(provider.deletion ? { deleter: { delete: (value, context) => hold(provider.deletion.delete(value, context)) } } : {}),
|
|
@@ -9,6 +9,18 @@ export interface RevisionBinding {
|
|
|
9
9
|
}
|
|
10
10
|
export declare function record(value: unknown): Record<string, unknown>;
|
|
11
11
|
export declare function binding(value: unknown): RevisionBinding;
|
|
12
|
+
/** The default largest file content one write carries: what the resource transport's 4 MiB request held as base64 before it had a name. */
|
|
13
|
+
export declare const DEFAULT_MAX_FILE_BYTES = 3145728;
|
|
14
|
+
/**
|
|
15
|
+
* The request and response byte limits that carry `maxFileBytes` of file content: base64 (4 bytes per 3) plus 64 KiB for the envelope,
|
|
16
|
+
* and at least the transport's usual 4 MiB request and 8 MiB response. A limit whose request would exceed the transport's 64 MiB cap
|
|
17
|
+
* (about 48 MiB of content) is refused, like zero or a fraction.
|
|
18
|
+
*/
|
|
19
|
+
export declare function fileTransport(maxFileBytes: number): {
|
|
20
|
+
readonly maxRequestBytes: number;
|
|
21
|
+
readonly maxResponseBytes: number;
|
|
22
|
+
};
|
|
23
|
+
export declare const tooLarge: (limit: number) => string;
|
|
12
24
|
export declare const bindingValue: (value: RevisionBinding) => string;
|
|
13
25
|
export declare function filePage(value: unknown): FilePage;
|
|
14
26
|
export declare function savedRevisions(value: unknown): readonly SavedRevision[];
|
|
@@ -15,6 +15,22 @@ export function binding(value) {
|
|
|
15
15
|
incarnation: identifier(workspace.incarnation), viewId: identifier(workspace.viewId),
|
|
16
16
|
} };
|
|
17
17
|
}
|
|
18
|
+
/** The default largest file content one write carries: what the resource transport's 4 MiB request held as base64 before it had a name. */
|
|
19
|
+
export const DEFAULT_MAX_FILE_BYTES = 3_145_728;
|
|
20
|
+
/**
|
|
21
|
+
* The request and response byte limits that carry `maxFileBytes` of file content: base64 (4 bytes per 3) plus 64 KiB for the envelope,
|
|
22
|
+
* and at least the transport's usual 4 MiB request and 8 MiB response. A limit whose request would exceed the transport's 64 MiB cap
|
|
23
|
+
* (about 48 MiB of content) is refused, like zero or a fraction.
|
|
24
|
+
*/
|
|
25
|
+
export function fileTransport(maxFileBytes) {
|
|
26
|
+
if (!Number.isSafeInteger(maxFileBytes) || maxFileBytes < 1)
|
|
27
|
+
throw new RangeError('maxFileBytes must be a positive integer');
|
|
28
|
+
const wire = Math.ceil(maxFileBytes / 3) * 4 + 65_536;
|
|
29
|
+
if (wire > 67_108_864)
|
|
30
|
+
throw new RangeError('maxFileBytes is larger than the resource transport carries (64 MiB with base64)');
|
|
31
|
+
return { maxRequestBytes: Math.max(4_194_304, wire), maxResponseBytes: Math.max(8_388_608, wire) };
|
|
32
|
+
}
|
|
33
|
+
export const tooLarge = (limit) => `The file is larger than the workspace's ${limit}-byte limit`;
|
|
18
34
|
export const bindingValue = (value) => encodeURIComponent(JSON.stringify(binding(value)));
|
|
19
35
|
export function filePage(value) {
|
|
20
36
|
const data = record(value);
|
package/dist/revision.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createResourceClient } from './remote.js';
|
|
2
|
-
import { binding, bindingHeader, bindingValue, catalogHeader, changeEvent, filePage, record, savedRevisions, serverEvents } from './revision-protocol.js';
|
|
2
|
+
import { binding, bindingHeader, bindingValue, catalogHeader, changeEvent, filePage, fileTransport, record, savedRevisions, serverEvents } from './revision-protocol.js';
|
|
3
3
|
import { readJsonBody } from './request-guard.js';
|
|
4
4
|
import { RevisionError } from './revision-contracts.js';
|
|
5
5
|
import { observe } from './remote-protocol.js';
|
|
@@ -34,9 +34,20 @@ export async function connectRevisionProvider(options) {
|
|
|
34
34
|
throw new RevisionError('unavailable', 'The file request could not be completed');
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
-
const
|
|
38
|
-
const bound = bindingValue(selected);
|
|
39
|
-
|
|
37
|
+
const discovered = record(await send(new Request(endpoint, { method: 'GET', redirect: 'error', ...(options.signal ? { signal: options.signal } : {}) })));
|
|
38
|
+
const selected = binding(discovered), bound = bindingValue(selected);
|
|
39
|
+
// The host's file size limit (`createRevisionHandler({ maxFileBytes })`); a host that names none keeps the transport's defaults.
|
|
40
|
+
const maxFileBytes = discovered.limits === undefined ? undefined : record(discovered.limits).maxFileBytes;
|
|
41
|
+
let transport;
|
|
42
|
+
if (maxFileBytes !== undefined) {
|
|
43
|
+
try {
|
|
44
|
+
transport = fileTransport(maxFileBytes);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new RevisionError('invalid', 'Invalid file size limit');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const client = createResourceClient({ endpoint, identity: selected.identity, publication: true, reconciliation: true, ...transport,
|
|
40
51
|
fetch: request => { const headers = new Headers(request.headers); headers.set(bindingHeader, bound); return fetch(new Request(request, { headers })); } });
|
|
41
52
|
async function invoke(kind, value, signal) {
|
|
42
53
|
const requestId = randomUUID();
|
|
@@ -136,7 +147,8 @@ export async function connectRevisionProvider(options) {
|
|
|
136
147
|
if (!client.publish || !client.lookup)
|
|
137
148
|
throw new Error('Publishing client lacks reconciliation');
|
|
138
149
|
return {
|
|
139
|
-
...client, publish: client.publish, lookup: client.lookup,
|
|
150
|
+
...client, publish: client.publish, lookup: client.lookup, ...(transport ? { maxFileBytes: maxFileBytes } : {}),
|
|
151
|
+
identity: Object.freeze(selected.identity), workspace: Object.freeze(selected.workspace),
|
|
140
152
|
locate: path => locator({ resource: { providerId: selected.workspace.providerId, path }, view: { kind: 'published' } }),
|
|
141
153
|
list: async (request, signal) => filePage(await invoke('list', request, signal)),
|
|
142
154
|
search: async (request, signal) => filePage(await invoke('search', request, signal)),
|