@hachej/boring-files 0.0.0-stage → 0.2.1
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/LICENSE +21 -0
- package/README.md +215 -2
- package/dist/client.d.ts +12 -0
- package/dist/client.js +1 -0
- package/dist/contracts.d.ts +194 -0
- package/dist/contracts.js +1 -0
- package/dist/git.d.ts +105 -0
- package/dist/git.js +205 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -0
- package/dist/journal.d.ts +83 -0
- package/dist/journal.js +124 -0
- package/dist/node-http.d.ts +23 -0
- package/dist/node-http.js +102 -0
- package/dist/platform.d.ts +22 -0
- package/dist/platform.js +157 -0
- package/dist/publication-input.d.ts +15 -0
- package/dist/publication-input.js +164 -0
- package/dist/remote-handler.d.ts +11 -0
- package/dist/remote-handler.js +63 -0
- package/dist/remote-protocol.d.ts +34 -0
- package/dist/remote-protocol.js +154 -0
- package/dist/remote.d.ts +26 -0
- package/dist/remote.js +92 -0
- package/dist/request-guard.d.ts +20 -0
- package/dist/request-guard.js +75 -0
- package/dist/revision-contracts.d.ts +89 -0
- package/dist/revision-contracts.js +8 -0
- package/dist/revision-handler.d.ts +14 -0
- package/dist/revision-handler.js +213 -0
- package/dist/revision-protocol.d.ts +22 -0
- package/dist/revision-protocol.js +106 -0
- package/dist/revision.d.ts +8 -0
- package/dist/revision.js +145 -0
- package/dist/sqlite-batch.d.ts +19 -0
- package/dist/sqlite-batch.js +7 -0
- package/dist/sqlite-durable-object.d.ts +24 -0
- package/dist/sqlite-durable-object.js +49 -0
- package/dist/sqlite-filesystem.d.ts +16 -0
- package/dist/sqlite-filesystem.js +423 -0
- package/dist/sqlite-settings.d.ts +61 -0
- package/dist/sqlite-settings.js +104 -0
- package/dist/sqlite.d.ts +37 -0
- package/dist/sqlite.js +50 -0
- package/dist/text.d.ts +16 -0
- package/dist/text.js +29 -0
- package/dist/workspace-catalog.d.ts +17 -0
- package/dist/workspace-catalog.js +147 -0
- package/dist/workspace-identity.d.ts +8 -0
- package/dist/workspace-identity.js +1 -0
- package/dist/workspace.d.ts +108 -0
- package/dist/workspace.js +649 -0
- package/package.json +119 -4
package/dist/remote.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { PublicationNotDispatchedError, identifier, publicationDigest, publicationSnapshot } from './publication-input.js';
|
|
2
|
+
import { contentType, encode, envelope, identity, jsonBody, limit, lookupResult, observe, publicationResult, readResult, readSnapshot, sameIdentity, schema, version } from './remote-protocol.js';
|
|
3
|
+
import { randomUUID } from './platform.js';
|
|
4
|
+
export { createResourceHandler } from './remote-handler.js';
|
|
5
|
+
export function createResourceClient(options) {
|
|
6
|
+
if (options.publication && !options.reconciliation)
|
|
7
|
+
throw new TypeError('A publishing resource client needs reconciliation: a lost save acknowledgement could otherwise never be resolved');
|
|
8
|
+
const selected = identity(options.identity), endpoint = new URL(options.endpoint);
|
|
9
|
+
if (!['https:', 'http:'].includes(endpoint.protocol) || endpoint.username || endpoint.password || endpoint.hash)
|
|
10
|
+
throw new TypeError('Expected an HTTP endpoint without credentials or fragment');
|
|
11
|
+
const fetch = options.fetch;
|
|
12
|
+
const requestLimit = limit(options.maxRequestBytes ?? 4_194_304), responseLimit = limit(options.maxResponseBytes ?? 8_388_608);
|
|
13
|
+
async function invoke(call, signal) {
|
|
14
|
+
let dispatched = false;
|
|
15
|
+
const abort = new AbortController();
|
|
16
|
+
const combined = signal ? AbortSignal.any([signal, abort.signal]) : abort.signal;
|
|
17
|
+
try {
|
|
18
|
+
if (combined.aborted)
|
|
19
|
+
throw new Error('Resource observation aborted');
|
|
20
|
+
const requestId = randomUUID();
|
|
21
|
+
const body = encode({ schema, version, requestId, identity: selected, ...call }, requestLimit);
|
|
22
|
+
const request = new Request(endpoint, { method: 'POST', headers: { 'content-type': contentType }, body, redirect: 'error', signal: combined });
|
|
23
|
+
if (combined.aborted)
|
|
24
|
+
throw new Error('Resource observation aborted');
|
|
25
|
+
dispatched = true;
|
|
26
|
+
const pending = fetch(request);
|
|
27
|
+
void pending.then(response => { if (combined.aborted)
|
|
28
|
+
void response.body?.cancel().catch(() => { }); }, () => { });
|
|
29
|
+
const response = await observe(pending, combined);
|
|
30
|
+
if (response.redirected || response.status !== 200) {
|
|
31
|
+
void response.body?.cancel().catch(() => { });
|
|
32
|
+
throw new Error('Remote resource response unavailable');
|
|
33
|
+
}
|
|
34
|
+
const result = envelope(await jsonBody(response, responseLimit, combined));
|
|
35
|
+
if (result.requestId !== requestId || result.kind !== call.kind || !sameIdentity(result.identity, selected))
|
|
36
|
+
throw new TypeError('Resource response association mismatch');
|
|
37
|
+
return result.value;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
if (call.kind === 'publish' && !dispatched)
|
|
41
|
+
throw new PublicationNotDispatchedError(call.value.operationId);
|
|
42
|
+
throw new Error('Remote resource operation is unconfirmed');
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
abort.abort();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
read: async (request, signal) => {
|
|
50
|
+
const captured = readSnapshot(request);
|
|
51
|
+
try {
|
|
52
|
+
return readResult(await invoke({ kind: 'read', value: captured }, signal), captured, true);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return { kind: 'unavailable', reason: 'Remote resource read is unconfirmed' };
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
...(options.publication ? {
|
|
59
|
+
publish: async (request, signal) => {
|
|
60
|
+
const captured = publicationSnapshot(request);
|
|
61
|
+
let digest;
|
|
62
|
+
try {
|
|
63
|
+
if (captured.atomicity !== 'all-or-nothing')
|
|
64
|
+
throw new TypeError('Remote per-change publication is not qualified');
|
|
65
|
+
digest = await publicationDigest(captured);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
throw new PublicationNotDispatchedError(captured.operationId);
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
return publicationResult(await invoke({ kind: 'publish', value: captured }, signal), captured.operationId, selected, captured, digest);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (error instanceof PublicationNotDispatchedError)
|
|
75
|
+
throw error;
|
|
76
|
+
return { kind: 'unknown', operationId: captured.operationId, reason: 'Remote publication is unconfirmed; reconcile the original operation without automatic replay' };
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
} : {}),
|
|
80
|
+
...(options.reconciliation ? {
|
|
81
|
+
lookup: async (operationId, signal) => {
|
|
82
|
+
const captured = identifier(operationId);
|
|
83
|
+
try {
|
|
84
|
+
return lookupResult(await invoke({ kind: 'lookup', value: captured }, signal), captured, selected);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return { kind: 'unknown', operationId: captured, reason: 'Remote operation lookup is unconfirmed' };
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
} : {}),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one request-body guard of every `create*Handler` in the packages (resource, remote files, remote shell, chat
|
|
3
|
+
* transport): a JSON content type is required and the body is streamed under a byte cap, so a cross-site `text/plain`
|
|
4
|
+
* form POST and an oversized body are both refused before any parsing or full buffering. `npm run check` fails when a
|
|
5
|
+
* `create*Handler` does not import this module, so the handlers cannot drift apart.
|
|
6
|
+
*/
|
|
7
|
+
export declare const JSON_CONTENT_TYPE = "application/json";
|
|
8
|
+
/** A refused request body. `status` is the HTTP status the handler answers with. */
|
|
9
|
+
export declare class RequestGuardError extends Error {
|
|
10
|
+
readonly status: 400 | 413 | 415;
|
|
11
|
+
constructor(status: 400 | 413 | 415, message: string);
|
|
12
|
+
}
|
|
13
|
+
/** The HTTP status for a guard refusal, or 400 for any other body failure. */
|
|
14
|
+
export declare function guardStatus(error: unknown): 400 | 413 | 415;
|
|
15
|
+
export declare function hasJsonContentType(headers: Headers): boolean;
|
|
16
|
+
/** Read a stream as JSON without ever holding more than `maximum` bytes. No content-type check: see `readJsonBody`. */
|
|
17
|
+
export declare function readBoundedJson(body: ReadableStream<Uint8Array> | null, maximum: number, signal?: AbortSignal): Promise<unknown>;
|
|
18
|
+
/** Require `application/json`, refuse a declared length over the cap early, then read the body under the cap. */
|
|
19
|
+
export declare function readJsonBody(message: Request | Response, maximum: number, signal?: AbortSignal): Promise<unknown>;
|
|
20
|
+
//# sourceMappingURL=request-guard.d.ts.map
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one request-body guard of every `create*Handler` in the packages (resource, remote files, remote shell, chat
|
|
3
|
+
* transport): a JSON content type is required and the body is streamed under a byte cap, so a cross-site `text/plain`
|
|
4
|
+
* form POST and an oversized body are both refused before any parsing or full buffering. `npm run check` fails when a
|
|
5
|
+
* `create*Handler` does not import this module, so the handlers cannot drift apart.
|
|
6
|
+
*/
|
|
7
|
+
export const JSON_CONTENT_TYPE = 'application/json';
|
|
8
|
+
/** A refused request body. `status` is the HTTP status the handler answers with. */
|
|
9
|
+
export class RequestGuardError extends Error {
|
|
10
|
+
status;
|
|
11
|
+
constructor(status, message) { super(message); this.name = 'RequestGuardError'; this.status = status; }
|
|
12
|
+
}
|
|
13
|
+
/** The HTTP status for a guard refusal, or 400 for any other body failure. */
|
|
14
|
+
export function guardStatus(error) {
|
|
15
|
+
return error instanceof RequestGuardError ? error.status : 400;
|
|
16
|
+
}
|
|
17
|
+
export function hasJsonContentType(headers) {
|
|
18
|
+
return headers.get('content-type')?.split(';')[0]?.trim().toLowerCase() === JSON_CONTENT_TYPE;
|
|
19
|
+
}
|
|
20
|
+
/** Read a stream as JSON without ever holding more than `maximum` bytes. No content-type check: see `readJsonBody`. */
|
|
21
|
+
export async function readBoundedJson(body, maximum, signal) {
|
|
22
|
+
if (!Number.isSafeInteger(maximum) || maximum < 1)
|
|
23
|
+
throw new TypeError('Expected a positive byte limit');
|
|
24
|
+
if (!body)
|
|
25
|
+
throw new RequestGuardError(400, 'Missing request body');
|
|
26
|
+
const reader = body.getReader();
|
|
27
|
+
let fail;
|
|
28
|
+
const abort = new Promise((_, reject) => {
|
|
29
|
+
fail = () => reject(new Error('Request body read aborted'));
|
|
30
|
+
if (signal?.aborted)
|
|
31
|
+
fail();
|
|
32
|
+
else
|
|
33
|
+
signal?.addEventListener('abort', fail, { once: true });
|
|
34
|
+
});
|
|
35
|
+
abort.catch(() => { });
|
|
36
|
+
try {
|
|
37
|
+
let size = 0, buffer = new Uint8Array(Math.min(maximum, 16_384));
|
|
38
|
+
while (true) {
|
|
39
|
+
const item = await (signal ? Promise.race([reader.read(), abort]) : reader.read());
|
|
40
|
+
if (item.done)
|
|
41
|
+
break;
|
|
42
|
+
if (item.value.byteLength > maximum - size)
|
|
43
|
+
throw new RequestGuardError(413, 'Request body exceeds its byte limit');
|
|
44
|
+
const next = size + item.value.byteLength;
|
|
45
|
+
if (next > buffer.byteLength) {
|
|
46
|
+
const grown = new Uint8Array(Math.min(maximum, Math.max(next, buffer.byteLength * 2)));
|
|
47
|
+
grown.set(buffer.subarray(0, size));
|
|
48
|
+
buffer = grown;
|
|
49
|
+
}
|
|
50
|
+
buffer.set(item.value, size);
|
|
51
|
+
size = next;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, size)));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new RequestGuardError(400, 'Request body is not valid JSON');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
if (fail)
|
|
62
|
+
signal?.removeEventListener('abort', fail);
|
|
63
|
+
void reader.cancel().catch(() => { });
|
|
64
|
+
reader.releaseLock();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** Require `application/json`, refuse a declared length over the cap early, then read the body under the cap. */
|
|
68
|
+
export async function readJsonBody(message, maximum, signal) {
|
|
69
|
+
if (!hasJsonContentType(message.headers))
|
|
70
|
+
throw new RequestGuardError(415, 'Request content type must be application/json');
|
|
71
|
+
const declared = Number(message.headers.get('content-length'));
|
|
72
|
+
if (Number.isFinite(declared) && declared > maximum)
|
|
73
|
+
throw new RequestGuardError(413, 'Request body exceeds its byte limit');
|
|
74
|
+
return readBoundedJson(message.body, maximum, signal);
|
|
75
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ResourceClient } from './client.js';
|
|
2
|
+
import type { ResourceAccess, ResourceLocator } from './contracts.js';
|
|
3
|
+
import type { ResourceIdentity } from './remote-protocol.js';
|
|
4
|
+
import type { WorkspaceKey } from './workspace-identity.js';
|
|
5
|
+
export interface FileEntry {
|
|
6
|
+
readonly path: string;
|
|
7
|
+
readonly kind: 'file' | 'directory';
|
|
8
|
+
readonly size: number;
|
|
9
|
+
}
|
|
10
|
+
export interface FilePage {
|
|
11
|
+
readonly entries: readonly FileEntry[];
|
|
12
|
+
readonly cursor?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface FileListRequest {
|
|
15
|
+
readonly directory?: string;
|
|
16
|
+
readonly cursor?: string;
|
|
17
|
+
readonly limit?: number;
|
|
18
|
+
}
|
|
19
|
+
export interface FileSearchRequest {
|
|
20
|
+
readonly query: string;
|
|
21
|
+
readonly cursor?: string;
|
|
22
|
+
readonly limit?: number;
|
|
23
|
+
}
|
|
24
|
+
export interface SavedRevision {
|
|
25
|
+
readonly revision: string;
|
|
26
|
+
readonly savedAt: number;
|
|
27
|
+
/** Who wrote it: `publish` (a conditional write), `agent` (a Pi `write` or `edit`: observed, never receipted) or `observed` (retained as found). */
|
|
28
|
+
readonly source?: 'publish' | 'agent' | 'observed';
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* One entry of a workspace's change feed (ruling 2 of 2026-10-10): an invalidation hint, never content or a receipt. Bytes and
|
|
32
|
+
* the publication journal stay authoritative; a viewer that receives one reads again.
|
|
33
|
+
*
|
|
34
|
+
* - `change`: something at `path` changed. `revision` is the Git blob id now there, or `null` when the path is absent or is a
|
|
35
|
+
* directory (`directory: true`: something below it may have changed). `source`: `publish` (a conditional write), `agent` (a Pi
|
|
36
|
+
* `write` or `edit`), `poll` (found by hashing, for example after a `bash` command) or `watch` (reported by the file system).
|
|
37
|
+
* - `resnapshot`: events were lost (the cursor is older than the retained window, from another provider lifetime, or the watcher
|
|
38
|
+
* overflowed or restarted). Reload everything shown, then continue after this `seq`.
|
|
39
|
+
*
|
|
40
|
+
* `seq` increases strictly within one provider lifetime; `at` is milliseconds since the epoch.
|
|
41
|
+
*/
|
|
42
|
+
export type WorkspaceChangeEvent = {
|
|
43
|
+
readonly kind: 'change';
|
|
44
|
+
readonly path: string;
|
|
45
|
+
readonly revision: string | null;
|
|
46
|
+
readonly source: 'publish' | 'agent' | 'poll' | 'watch';
|
|
47
|
+
readonly seq: number;
|
|
48
|
+
readonly at: number;
|
|
49
|
+
readonly directory?: true;
|
|
50
|
+
readonly conversationId?: string;
|
|
51
|
+
} | {
|
|
52
|
+
readonly kind: 'resnapshot';
|
|
53
|
+
readonly seq: number;
|
|
54
|
+
readonly at: number;
|
|
55
|
+
};
|
|
56
|
+
export interface ChangeFeedRequest {
|
|
57
|
+
/** The last `seq` already seen: only later events are delivered (a gap is a `resnapshot`). Absent: live events only. */
|
|
58
|
+
readonly since?: number | undefined;
|
|
59
|
+
/** The provider lifetime `since` came from (`ready` event). A cursor from another lifetime is a `resnapshot`, whatever its number. */
|
|
60
|
+
readonly incarnation?: string | undefined;
|
|
61
|
+
readonly signal?: AbortSignal | undefined;
|
|
62
|
+
}
|
|
63
|
+
export interface WorkspaceCatalog {
|
|
64
|
+
readonly list: (request: FileListRequest, access: ResourceAccess) => Promise<FilePage>;
|
|
65
|
+
readonly search: (request: FileSearchRequest, access: ResourceAccess) => Promise<FilePage>;
|
|
66
|
+
readonly history: (path: string, access: ResourceAccess) => Promise<readonly SavedRevision[]>;
|
|
67
|
+
}
|
|
68
|
+
export declare class RevisionError extends Error {
|
|
69
|
+
readonly kind: 'denied' | 'unavailable' | 'invalid' | 'aborted';
|
|
70
|
+
constructor(kind: 'denied' | 'unavailable' | 'invalid' | 'aborted', message: string);
|
|
71
|
+
}
|
|
72
|
+
/** One authenticated binding for viewers, file browsing and publication. */
|
|
73
|
+
export interface RevisionProvider extends ResourceClient {
|
|
74
|
+
readonly identity: ResourceIdentity;
|
|
75
|
+
readonly workspace: WorkspaceKey;
|
|
76
|
+
readonly locate: (path: string) => ResourceLocator;
|
|
77
|
+
readonly publish: NonNullable<ResourceClient['publish']>;
|
|
78
|
+
readonly lookup: NonNullable<ResourceClient['lookup']>;
|
|
79
|
+
readonly list: (request: FileListRequest, signal?: AbortSignal) => Promise<FilePage>;
|
|
80
|
+
readonly search: (request: FileSearchRequest, signal?: AbortSignal) => Promise<FilePage>;
|
|
81
|
+
readonly history: (path: string, signal?: AbortSignal) => Promise<readonly SavedRevision[]>;
|
|
82
|
+
/**
|
|
83
|
+
* The workspace's change feed (`GET ?op=changes`, server-sent events), reconnecting after the last `seq` seen until `signal`
|
|
84
|
+
* aborts; a refused binding (another workspace, revoked access) ends it with a `RevisionError`. Optional: a hand-built provider
|
|
85
|
+
* may have none, and a viewer then refreshes on its own schedule.
|
|
86
|
+
*/
|
|
87
|
+
readonly changes?: (request?: ChangeFeedRequest) => AsyncIterable<WorkspaceChangeEvent>;
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=revision-contracts.d.ts.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ResourceAccess } from './contracts.js';
|
|
2
|
+
import type { WorkspaceResourceProvider } from './workspace.js';
|
|
3
|
+
export interface RevisionHandlerOptions {
|
|
4
|
+
/** The host's authentication and workspace selection, once per request. For the change feed the lease is held until the stream ends. */
|
|
5
|
+
readonly resolve: (request: Request) => Promise<null | {
|
|
6
|
+
readonly provider: WorkspaceResourceProvider;
|
|
7
|
+
readonly access: ResourceAccess;
|
|
8
|
+
readonly release?: () => void | Promise<void>;
|
|
9
|
+
}>;
|
|
10
|
+
/** How often the change feed sends a comment line so proxies keep it open, in milliseconds (default 15 000). */
|
|
11
|
+
readonly heartbeatMs?: number;
|
|
12
|
+
}
|
|
13
|
+
export declare function createRevisionHandler(options: RevisionHandlerOptions): (request: Request) => Promise<Response>;
|
|
14
|
+
//# sourceMappingURL=revision-handler.d.ts.map
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { createResourceHandler } from './remote-handler.js';
|
|
2
|
+
import { binding, bindingHeader, bindingValue, catalogHeader, filePage, record, savedRevisions } from './revision-protocol.js';
|
|
3
|
+
import { guardStatus, readJsonBody, RequestGuardError } from './request-guard.js';
|
|
4
|
+
import { accessSnapshot, identifier } from './publication-input.js';
|
|
5
|
+
import { RevisionError } from './revision-contracts.js';
|
|
6
|
+
const encoder = new TextEncoder();
|
|
7
|
+
/**
|
|
8
|
+
* `GET ?op=changes[&since=<seq>&incarnation=<id>]`: the workspace's change feed as server-sent events (`event: change | resnapshot`,
|
|
9
|
+
* `id:` the seq, `data:` the JSON event), after an `event: ready` naming the current seq and the provider's incarnation, with a
|
|
10
|
+
* comment every `heartbeatMs` while idle. Plain streamed responses, so it runs on Node and on Workers alike.
|
|
11
|
+
*
|
|
12
|
+
* The stream is pull-based with no queue (`highWaterMark: 0`): the provider is read only while the client is reading, so a slow
|
|
13
|
+
* client costs the provider's bounded replay buffer (and a `resnapshot` when it falls behind), never unbounded memory here. The
|
|
14
|
+
* stream ends when the person disconnects or their access is revoked, discarding anything not yet read, and the workspace lease is
|
|
15
|
+
* released then.
|
|
16
|
+
*/
|
|
17
|
+
function changeStream(provider, access, request, heartbeatMs, release) {
|
|
18
|
+
const stop = new AbortController();
|
|
19
|
+
const signal = AbortSignal.any([access.signal, stop.signal, provider.lifetime]);
|
|
20
|
+
let iterator;
|
|
21
|
+
let pending;
|
|
22
|
+
let ready = false, ended = false;
|
|
23
|
+
const finish = () => {
|
|
24
|
+
if (ended)
|
|
25
|
+
return;
|
|
26
|
+
ended = true;
|
|
27
|
+
stop.abort();
|
|
28
|
+
void iterator?.return?.().catch(() => { });
|
|
29
|
+
void release();
|
|
30
|
+
};
|
|
31
|
+
const body = new ReadableStream({
|
|
32
|
+
start: controller => {
|
|
33
|
+
const close = () => { finish(); try {
|
|
34
|
+
controller.close();
|
|
35
|
+
}
|
|
36
|
+
catch { /* already closed or cancelled */ } };
|
|
37
|
+
signal.addEventListener('abort', close, { once: true });
|
|
38
|
+
if (signal.aborted)
|
|
39
|
+
close();
|
|
40
|
+
},
|
|
41
|
+
pull: async (controller) => {
|
|
42
|
+
if (ended)
|
|
43
|
+
return;
|
|
44
|
+
const send = (text) => { try {
|
|
45
|
+
controller.enqueue(encoder.encode(text));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
finish();
|
|
49
|
+
} };
|
|
50
|
+
const end = () => { finish(); try {
|
|
51
|
+
controller.close();
|
|
52
|
+
}
|
|
53
|
+
catch { /* already closed */ } };
|
|
54
|
+
try {
|
|
55
|
+
if (!ready) {
|
|
56
|
+
ready = true;
|
|
57
|
+
// Without a cursor the feed starts at the current seq, which the client learns first and resumes from after a disconnect.
|
|
58
|
+
const start = request.since ?? provider.changeHead();
|
|
59
|
+
iterator = provider.changes({ since: start, ...(request.incarnation === undefined ? {} : { incarnation: request.incarnation }), signal }, { ...access, signal })[Symbol.asyncIterator]();
|
|
60
|
+
// An async iterator runs its body only on the first pull, so prime it here: the watcher then starts before `ready`, and a
|
|
61
|
+
// write the client makes after reading `ready` is not missed.
|
|
62
|
+
pending = iterator.next();
|
|
63
|
+
pending.catch(() => { });
|
|
64
|
+
send(`event: ready\ndata: ${JSON.stringify({ seq: start, incarnation: provider.changeIncarnation })}\n\n`);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
pending ??= iterator.next();
|
|
68
|
+
pending.catch(() => { });
|
|
69
|
+
let timer;
|
|
70
|
+
const beat = new Promise(resolve => { timer = setTimeout(() => resolve('beat'), heartbeatMs); });
|
|
71
|
+
const outcome = await Promise.race([pending, beat]).finally(() => clearTimeout(timer));
|
|
72
|
+
if (signal.aborted)
|
|
73
|
+
return end();
|
|
74
|
+
if (outcome === 'beat')
|
|
75
|
+
return send(': heartbeat\n\n');
|
|
76
|
+
pending = undefined;
|
|
77
|
+
if (outcome.done)
|
|
78
|
+
return end();
|
|
79
|
+
const event = outcome.value;
|
|
80
|
+
send(`id: ${event.seq}\nevent: ${event.kind}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
end(); /* the client reconnects from its last seq */
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
cancel: () => { finish(); },
|
|
87
|
+
}, { highWaterMark: 0 });
|
|
88
|
+
return new Response(body, { headers: { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-store', 'x-accel-buffering': 'no' } });
|
|
89
|
+
}
|
|
90
|
+
function listRequest(value) {
|
|
91
|
+
const data = record(value);
|
|
92
|
+
if ((data.directory !== undefined && typeof data.directory !== 'string') || (data.cursor !== undefined && typeof data.cursor !== 'string') || (data.limit !== undefined && typeof data.limit !== 'number'))
|
|
93
|
+
throw new RevisionError('invalid', 'Invalid listing request');
|
|
94
|
+
return { ...(typeof data.directory === 'string' ? { directory: data.directory } : {}), ...(typeof data.cursor === 'string' ? { cursor: data.cursor } : {}), ...(typeof data.limit === 'number' ? { limit: data.limit } : {}) };
|
|
95
|
+
}
|
|
96
|
+
export function createRevisionHandler(options) {
|
|
97
|
+
const heartbeatMs = options.heartbeatMs ?? 15_000;
|
|
98
|
+
if (!Number.isSafeInteger(heartbeatMs) || heartbeatMs < 1)
|
|
99
|
+
throw new RangeError('The heartbeat interval must be a positive integer');
|
|
100
|
+
return async (request) => {
|
|
101
|
+
let release;
|
|
102
|
+
const operations = new Set();
|
|
103
|
+
const hold = (pending) => {
|
|
104
|
+
operations.add(pending);
|
|
105
|
+
void pending.then(() => operations.delete(pending), () => operations.delete(pending));
|
|
106
|
+
return pending;
|
|
107
|
+
};
|
|
108
|
+
const refuse = (status) => new Response(null, { status, headers: { 'cache-control': 'no-store' } });
|
|
109
|
+
try {
|
|
110
|
+
if (request.method !== 'GET' && request.method !== 'POST')
|
|
111
|
+
return refuse(405);
|
|
112
|
+
if (request.signal.aborted)
|
|
113
|
+
return refuse(403);
|
|
114
|
+
const selected = await options.resolve(request);
|
|
115
|
+
if (!selected)
|
|
116
|
+
return refuse(403);
|
|
117
|
+
release = selected.release;
|
|
118
|
+
const captured = accessSnapshot(selected.access);
|
|
119
|
+
const access = { ...captured, signal: captured.signal ? AbortSignal.any([captured.signal, request.signal]) : request.signal };
|
|
120
|
+
if (access.signal.aborted)
|
|
121
|
+
return refuse(403);
|
|
122
|
+
const association = binding({ identity: captured, workspace: selected.provider.identity });
|
|
123
|
+
const respond = (value) => Response.json(value, { headers: { 'cache-control': 'no-store' } });
|
|
124
|
+
const op = new URL(request.url).searchParams.get('op');
|
|
125
|
+
if (request.method === 'GET' && op === null)
|
|
126
|
+
return respond(association);
|
|
127
|
+
const header = request.headers.get(bindingHeader);
|
|
128
|
+
if (!header || header.length > 16384)
|
|
129
|
+
return refuse(403);
|
|
130
|
+
let requested;
|
|
131
|
+
try {
|
|
132
|
+
requested = bindingValue(binding(JSON.parse(decodeURIComponent(header))));
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return refuse(403);
|
|
136
|
+
}
|
|
137
|
+
if (requested !== bindingValue(association))
|
|
138
|
+
return refuse(409);
|
|
139
|
+
const provider = selected.provider;
|
|
140
|
+
if (request.method === 'GET') {
|
|
141
|
+
if (op !== 'changes')
|
|
142
|
+
return refuse(400);
|
|
143
|
+
const cursor = new URL(request.url).searchParams.get('since');
|
|
144
|
+
if (cursor !== null && !/^\d{1,16}$/.test(cursor))
|
|
145
|
+
return refuse(400);
|
|
146
|
+
const since = cursor === null ? undefined : Number(cursor);
|
|
147
|
+
const incarnation = new URL(request.url).searchParams.get('incarnation') ?? undefined;
|
|
148
|
+
if (incarnation !== undefined && !/^[\w-]{1,64}$/.test(incarnation))
|
|
149
|
+
return refuse(400);
|
|
150
|
+
if (since !== undefined && !Number.isSafeInteger(since))
|
|
151
|
+
return refuse(400);
|
|
152
|
+
// The stream owns the lease from here: it is released when the stream ends, not when this function returns.
|
|
153
|
+
const lease = release;
|
|
154
|
+
release = undefined;
|
|
155
|
+
return changeStream(provider, access, { since, incarnation }, heartbeatMs, async () => { try {
|
|
156
|
+
await lease?.();
|
|
157
|
+
}
|
|
158
|
+
catch { /* the stream already ended */ } });
|
|
159
|
+
}
|
|
160
|
+
if (!request.headers.has(catalogHeader))
|
|
161
|
+
return await createResourceHandler({
|
|
162
|
+
authenticate: async () => access,
|
|
163
|
+
reader: { read: (value, context) => hold(provider.read(value, context)) },
|
|
164
|
+
publisher: { publish: (value, context) => hold(provider.publication.publish(value, context)) },
|
|
165
|
+
lookup: { lookup: (value, context) => hold(provider.reconciliation.lookup(value, context)) },
|
|
166
|
+
})(request);
|
|
167
|
+
const input = record(await readJsonBody(request, 32_768, access.signal));
|
|
168
|
+
const requestId = identifier(input.requestId);
|
|
169
|
+
let value;
|
|
170
|
+
switch (input.kind) {
|
|
171
|
+
case 'list':
|
|
172
|
+
value = filePage(await provider.catalog.list(listRequest(input.value), access));
|
|
173
|
+
break;
|
|
174
|
+
case 'search': {
|
|
175
|
+
const data = record(input.value);
|
|
176
|
+
if (typeof data.query !== 'string')
|
|
177
|
+
throw new RevisionError('invalid', 'Invalid filename query');
|
|
178
|
+
const query = { ...listRequest(data), query: data.query };
|
|
179
|
+
value = filePage(await provider.catalog.search(query, access));
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
case 'history':
|
|
183
|
+
if (typeof input.value !== 'string')
|
|
184
|
+
throw new RevisionError('invalid', 'Invalid history path');
|
|
185
|
+
value = savedRevisions(await provider.catalog.history(input.value, access));
|
|
186
|
+
break;
|
|
187
|
+
default: return refuse(400);
|
|
188
|
+
}
|
|
189
|
+
if (access.signal.aborted)
|
|
190
|
+
return refuse(403);
|
|
191
|
+
const body = JSON.stringify({ ...association, requestId, kind: input.kind, value });
|
|
192
|
+
if (new TextEncoder().encode(body).byteLength > 8_388_608)
|
|
193
|
+
return refuse(503);
|
|
194
|
+
return new Response(body, { headers: { 'content-type': 'application/json', 'cache-control': 'no-store' } });
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
return refuse(error instanceof RevisionError ? error.kind === 'invalid' ? 400 : error.kind === 'denied' || error.kind === 'aborted' ? 403 : 503 : error instanceof RequestGuardError || error instanceof TypeError ? guardStatus(error) : 503);
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
await Promise.allSettled([...operations]);
|
|
201
|
+
try {
|
|
202
|
+
await release?.();
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return refuse(503);
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
if (!request.bodyUsed)
|
|
209
|
+
void request.body?.cancel().catch(() => { });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ResourceIdentity } from './remote-protocol.js';
|
|
2
|
+
import type { WorkspaceKey } from './workspace.js';
|
|
3
|
+
import type { FilePage, SavedRevision, WorkspaceChangeEvent } from './revision-contracts.js';
|
|
4
|
+
export declare const bindingHeader = "x-boring-workspace";
|
|
5
|
+
export declare const catalogHeader = "x-boring-catalog";
|
|
6
|
+
export interface RevisionBinding {
|
|
7
|
+
readonly identity: ResourceIdentity;
|
|
8
|
+
readonly workspace: WorkspaceKey;
|
|
9
|
+
}
|
|
10
|
+
export declare function record(value: unknown): Record<string, unknown>;
|
|
11
|
+
export declare function binding(value: unknown): RevisionBinding;
|
|
12
|
+
export declare const bindingValue: (value: RevisionBinding) => string;
|
|
13
|
+
export declare function filePage(value: unknown): FilePage;
|
|
14
|
+
export declare function savedRevisions(value: unknown): readonly SavedRevision[];
|
|
15
|
+
/** One change-feed event as the server sent it, checked field by field (`data:` of `event: change | resnapshot`). */
|
|
16
|
+
export declare function changeEvent(value: unknown): WorkspaceChangeEvent;
|
|
17
|
+
/** Server-sent events from a response body: `{ event, data }` per message, comments skipped, a line longer than `limit` refused. */
|
|
18
|
+
export declare function serverEvents(body: ReadableStream<Uint8Array>, limit?: number): AsyncGenerator<{
|
|
19
|
+
readonly event: string;
|
|
20
|
+
readonly data: string;
|
|
21
|
+
}>;
|
|
22
|
+
//# sourceMappingURL=revision-protocol.d.ts.map
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { identifier, locator } from './publication-input.js';
|
|
2
|
+
import { RevisionError } from './revision-contracts.js';
|
|
3
|
+
const catalogPath = (path) => locator({ resource: { providerId: 'catalog', path }, view: { kind: 'published' } }).resource.path;
|
|
4
|
+
export const bindingHeader = 'x-boring-workspace';
|
|
5
|
+
export const catalogHeader = 'x-boring-catalog';
|
|
6
|
+
export function record(value) {
|
|
7
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
8
|
+
throw new RevisionError('invalid', 'Invalid revision response');
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
export function binding(value) {
|
|
12
|
+
const data = record(value), workspace = record(data.workspace), person = record(data.identity);
|
|
13
|
+
return { identity: { principalId: identifier(person.principalId), scopeId: identifier(person.scopeId), initiatorId: identifier(person.initiatorId) }, workspace: {
|
|
14
|
+
providerId: identifier(workspace.providerId), instanceId: identifier(workspace.instanceId),
|
|
15
|
+
incarnation: identifier(workspace.incarnation), viewId: identifier(workspace.viewId),
|
|
16
|
+
} };
|
|
17
|
+
}
|
|
18
|
+
export const bindingValue = (value) => encodeURIComponent(JSON.stringify(binding(value)));
|
|
19
|
+
export function filePage(value) {
|
|
20
|
+
const data = record(value);
|
|
21
|
+
if (!Array.isArray(data.entries) || data.entries.length > 500 || (data.cursor !== undefined && (typeof data.cursor !== 'string' || data.cursor.length > 16384)))
|
|
22
|
+
throw new RevisionError('invalid', 'Invalid file page');
|
|
23
|
+
const entries = data.entries.map((item) => {
|
|
24
|
+
const entry = record(item);
|
|
25
|
+
if ((entry.kind !== 'file' && entry.kind !== 'directory') || typeof entry.size !== 'number' || !Number.isSafeInteger(entry.size) || entry.size < 0 || typeof entry.path !== 'string')
|
|
26
|
+
throw new RevisionError('invalid', 'Invalid file entry');
|
|
27
|
+
return { path: catalogPath(entry.path), kind: entry.kind, size: entry.size };
|
|
28
|
+
});
|
|
29
|
+
if (new Set(entries.map(entry => entry.path)).size !== entries.length)
|
|
30
|
+
throw new RevisionError('invalid', 'Duplicate file entries');
|
|
31
|
+
return { entries, ...(typeof data.cursor === 'string' ? { cursor: data.cursor } : {}) };
|
|
32
|
+
}
|
|
33
|
+
export function savedRevisions(value) {
|
|
34
|
+
if (!Array.isArray(value) || value.length > 100_000)
|
|
35
|
+
throw new RevisionError('invalid', 'Invalid file history');
|
|
36
|
+
return value.map(item => {
|
|
37
|
+
const entry = record(item);
|
|
38
|
+
if (typeof entry.savedAt !== 'number' || !Number.isSafeInteger(entry.savedAt) || entry.savedAt < 0)
|
|
39
|
+
throw new RevisionError('invalid', 'Invalid save timestamp');
|
|
40
|
+
const source = entry.source === 'publish' || entry.source === 'agent' || entry.source === 'observed' ? entry.source : undefined;
|
|
41
|
+
return { revision: identifier(entry.revision), savedAt: entry.savedAt, ...(source ? { source } : {}) };
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
const sources = new Set(['publish', 'agent', 'poll', 'watch']);
|
|
45
|
+
const sequence = (value, what) => {
|
|
46
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0)
|
|
47
|
+
throw new RevisionError('invalid', `Invalid ${what}`);
|
|
48
|
+
return value;
|
|
49
|
+
};
|
|
50
|
+
/** One change-feed event as the server sent it, checked field by field (`data:` of `event: change | resnapshot`). */
|
|
51
|
+
export function changeEvent(value) {
|
|
52
|
+
const data = record(value);
|
|
53
|
+
const seq = sequence(data.seq, 'change sequence'), at = sequence(data.at, 'change time');
|
|
54
|
+
if (data.kind === 'resnapshot')
|
|
55
|
+
return { kind: 'resnapshot', seq, at };
|
|
56
|
+
if (data.kind !== 'change' || typeof data.path !== 'string' || typeof data.source !== 'string' || !sources.has(data.source))
|
|
57
|
+
throw new RevisionError('invalid', 'Invalid change event');
|
|
58
|
+
if (data.directory !== undefined && data.directory !== true)
|
|
59
|
+
throw new RevisionError('invalid', 'Invalid change event');
|
|
60
|
+
const path = data.path === '' && data.directory === true ? '' : catalogPath(data.path);
|
|
61
|
+
const revision = data.revision === null ? null : identifier(data.revision);
|
|
62
|
+
return {
|
|
63
|
+
kind: 'change', path, revision, source: data.source, seq, at,
|
|
64
|
+
...(data.directory === true ? { directory: true } : {}),
|
|
65
|
+
...(data.conversationId !== undefined ? { conversationId: identifier(data.conversationId) } : {}),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Server-sent events from a response body: `{ event, data }` per message, comments skipped, a line longer than `limit` refused. */
|
|
69
|
+
export async function* serverEvents(body, limit = 1_048_576) {
|
|
70
|
+
const reader = body.getReader(), decoder = new TextDecoder();
|
|
71
|
+
let pending = '', event = 'message', data = [];
|
|
72
|
+
try {
|
|
73
|
+
for (;;) {
|
|
74
|
+
const { done, value } = await reader.read();
|
|
75
|
+
if (done)
|
|
76
|
+
return;
|
|
77
|
+
pending += decoder.decode(value, { stream: true });
|
|
78
|
+
let newline;
|
|
79
|
+
while ((newline = pending.search(/\r?\n/)) !== -1) {
|
|
80
|
+
const line = pending.slice(0, newline);
|
|
81
|
+
pending = pending.slice(pending[newline] === '\r' ? newline + 2 : newline + 1);
|
|
82
|
+
if (line === '') {
|
|
83
|
+
if (data.length)
|
|
84
|
+
yield { event, data: data.join('\n') };
|
|
85
|
+
event = 'message';
|
|
86
|
+
data = [];
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (line.startsWith(':'))
|
|
90
|
+
continue;
|
|
91
|
+
const colon = line.indexOf(':');
|
|
92
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
93
|
+
const content = colon === -1 ? '' : line.slice(colon + 1).replace(/^ /, '');
|
|
94
|
+
if (field === 'event')
|
|
95
|
+
event = content;
|
|
96
|
+
else if (field === 'data')
|
|
97
|
+
data.push(content);
|
|
98
|
+
}
|
|
99
|
+
if (pending.length > limit)
|
|
100
|
+
throw new RevisionError('invalid', 'Change feed line exceeds the limit');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
void reader.cancel().catch(() => { });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { RevisionProvider } from './revision-contracts.js';
|
|
2
|
+
export * from './revision-contracts.js';
|
|
3
|
+
export declare function connectRevisionProvider(options: {
|
|
4
|
+
readonly endpoint: string | URL;
|
|
5
|
+
readonly fetch: (request: Request) => Promise<Response>;
|
|
6
|
+
readonly signal?: AbortSignal;
|
|
7
|
+
}): Promise<RevisionProvider>;
|
|
8
|
+
//# sourceMappingURL=revision.d.ts.map
|