@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 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
- The existing resource transport limits still apply: requests default to 4 MiB
233
- and responses to 8 MiB, including their serialized envelope. This API does not
234
- provide streaming uploads.
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
@@ -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(provider.publication.publish(value, context)) },
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 selected = binding(await send(new Request(endpoint, { method: 'GET', redirect: 'error', ...(options.signal ? { signal: options.signal } : {}) })));
38
- const bound = bindingValue(selected);
39
- const client = createResourceClient({ endpoint, identity: selected.identity, publication: true, reconciliation: true,
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, identity: Object.freeze(selected.identity), workspace: Object.freeze(selected.workspace),
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)),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-files",
3
- "version": "0.4.23",
3
+ "version": "0.4.25",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",