@hachej/boring-files 0.0.0-stage → 0.2.0
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 +195 -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 +76 -0
- package/dist/journal.js +119 -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 +48 -0
- package/dist/revision-contracts.js +8 -0
- package/dist/revision-handler.d.ts +11 -0
- package/dist/revision-handler.js +105 -0
- package/dist/revision-protocol.d.ts +15 -0
- package/dist/revision-protocol.js +42 -0
- package/dist/revision.d.ts +8 -0
- package/dist/revision.js +57 -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 +381 -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 +55 -0
- package/dist/workspace.js +411 -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,48 @@
|
|
|
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
|
+
}
|
|
28
|
+
export interface WorkspaceCatalog {
|
|
29
|
+
readonly list: (request: FileListRequest, access: ResourceAccess) => Promise<FilePage>;
|
|
30
|
+
readonly search: (request: FileSearchRequest, access: ResourceAccess) => Promise<FilePage>;
|
|
31
|
+
readonly history: (path: string, access: ResourceAccess) => Promise<readonly SavedRevision[]>;
|
|
32
|
+
}
|
|
33
|
+
export declare class RevisionError extends Error {
|
|
34
|
+
readonly kind: 'denied' | 'unavailable' | 'invalid' | 'aborted';
|
|
35
|
+
constructor(kind: 'denied' | 'unavailable' | 'invalid' | 'aborted', message: string);
|
|
36
|
+
}
|
|
37
|
+
/** One authenticated binding for viewers, file browsing and publication. */
|
|
38
|
+
export interface RevisionProvider extends ResourceClient {
|
|
39
|
+
readonly identity: ResourceIdentity;
|
|
40
|
+
readonly workspace: WorkspaceKey;
|
|
41
|
+
readonly locate: (path: string) => ResourceLocator;
|
|
42
|
+
readonly publish: NonNullable<ResourceClient['publish']>;
|
|
43
|
+
readonly lookup: NonNullable<ResourceClient['lookup']>;
|
|
44
|
+
readonly list: (request: FileListRequest, signal?: AbortSignal) => Promise<FilePage>;
|
|
45
|
+
readonly search: (request: FileSearchRequest, signal?: AbortSignal) => Promise<FilePage>;
|
|
46
|
+
readonly history: (path: string, signal?: AbortSignal) => Promise<readonly SavedRevision[]>;
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=revision-contracts.d.ts.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ResourceAccess } from './contracts.js';
|
|
2
|
+
import type { WorkspaceResourceProvider } from './workspace.js';
|
|
3
|
+
export interface RevisionHandlerOptions {
|
|
4
|
+
readonly resolve: (request: Request) => Promise<null | {
|
|
5
|
+
readonly provider: WorkspaceResourceProvider;
|
|
6
|
+
readonly access: ResourceAccess;
|
|
7
|
+
readonly release?: () => void | Promise<void>;
|
|
8
|
+
}>;
|
|
9
|
+
}
|
|
10
|
+
export declare function createRevisionHandler(options: RevisionHandlerOptions): (request: Request) => Promise<Response>;
|
|
11
|
+
//# sourceMappingURL=revision-handler.d.ts.map
|
|
@@ -0,0 +1,105 @@
|
|
|
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
|
+
function listRequest(value) {
|
|
7
|
+
const data = record(value);
|
|
8
|
+
if ((data.directory !== undefined && typeof data.directory !== 'string') || (data.cursor !== undefined && typeof data.cursor !== 'string') || (data.limit !== undefined && typeof data.limit !== 'number'))
|
|
9
|
+
throw new RevisionError('invalid', 'Invalid listing request');
|
|
10
|
+
return { ...(typeof data.directory === 'string' ? { directory: data.directory } : {}), ...(typeof data.cursor === 'string' ? { cursor: data.cursor } : {}), ...(typeof data.limit === 'number' ? { limit: data.limit } : {}) };
|
|
11
|
+
}
|
|
12
|
+
export function createRevisionHandler(options) {
|
|
13
|
+
return async (request) => {
|
|
14
|
+
let release;
|
|
15
|
+
const operations = new Set();
|
|
16
|
+
const hold = (pending) => {
|
|
17
|
+
operations.add(pending);
|
|
18
|
+
void pending.then(() => operations.delete(pending), () => operations.delete(pending));
|
|
19
|
+
return pending;
|
|
20
|
+
};
|
|
21
|
+
const refuse = (status) => new Response(null, { status, headers: { 'cache-control': 'no-store' } });
|
|
22
|
+
try {
|
|
23
|
+
if (request.method !== 'GET' && request.method !== 'POST')
|
|
24
|
+
return refuse(405);
|
|
25
|
+
if (request.signal.aborted)
|
|
26
|
+
return refuse(403);
|
|
27
|
+
const selected = await options.resolve(request);
|
|
28
|
+
if (!selected)
|
|
29
|
+
return refuse(403);
|
|
30
|
+
release = selected.release;
|
|
31
|
+
const captured = accessSnapshot(selected.access);
|
|
32
|
+
const access = { ...captured, signal: captured.signal ? AbortSignal.any([captured.signal, request.signal]) : request.signal };
|
|
33
|
+
if (access.signal.aborted)
|
|
34
|
+
return refuse(403);
|
|
35
|
+
const association = binding({ identity: captured, workspace: selected.provider.identity });
|
|
36
|
+
const respond = (value) => Response.json(value, { headers: { 'cache-control': 'no-store' } });
|
|
37
|
+
if (request.method === 'GET')
|
|
38
|
+
return respond(association);
|
|
39
|
+
const header = request.headers.get(bindingHeader);
|
|
40
|
+
if (!header || header.length > 16384)
|
|
41
|
+
return refuse(403);
|
|
42
|
+
let requested;
|
|
43
|
+
try {
|
|
44
|
+
requested = bindingValue(binding(JSON.parse(decodeURIComponent(header))));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return refuse(403);
|
|
48
|
+
}
|
|
49
|
+
if (requested !== bindingValue(association))
|
|
50
|
+
return refuse(409);
|
|
51
|
+
const provider = selected.provider;
|
|
52
|
+
if (!request.headers.has(catalogHeader))
|
|
53
|
+
return await createResourceHandler({
|
|
54
|
+
authenticate: async () => access,
|
|
55
|
+
reader: { read: (value, context) => hold(provider.read(value, context)) },
|
|
56
|
+
publisher: { publish: (value, context) => hold(provider.publication.publish(value, context)) },
|
|
57
|
+
lookup: { lookup: (value, context) => hold(provider.reconciliation.lookup(value, context)) },
|
|
58
|
+
})(request);
|
|
59
|
+
const input = record(await readJsonBody(request, 32_768, access.signal));
|
|
60
|
+
const requestId = identifier(input.requestId);
|
|
61
|
+
let value;
|
|
62
|
+
switch (input.kind) {
|
|
63
|
+
case 'list':
|
|
64
|
+
value = filePage(await provider.catalog.list(listRequest(input.value), access));
|
|
65
|
+
break;
|
|
66
|
+
case 'search': {
|
|
67
|
+
const data = record(input.value);
|
|
68
|
+
if (typeof data.query !== 'string')
|
|
69
|
+
throw new RevisionError('invalid', 'Invalid filename query');
|
|
70
|
+
const query = { ...listRequest(data), query: data.query };
|
|
71
|
+
value = filePage(await provider.catalog.search(query, access));
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
case 'history':
|
|
75
|
+
if (typeof input.value !== 'string')
|
|
76
|
+
throw new RevisionError('invalid', 'Invalid history path');
|
|
77
|
+
value = savedRevisions(await provider.catalog.history(input.value, access));
|
|
78
|
+
break;
|
|
79
|
+
default: return refuse(400);
|
|
80
|
+
}
|
|
81
|
+
if (access.signal.aborted)
|
|
82
|
+
return refuse(403);
|
|
83
|
+
const body = JSON.stringify({ ...association, requestId, kind: input.kind, value });
|
|
84
|
+
if (new TextEncoder().encode(body).byteLength > 8_388_608)
|
|
85
|
+
return refuse(503);
|
|
86
|
+
return new Response(body, { headers: { 'content-type': 'application/json', 'cache-control': 'no-store' } });
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
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);
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
await Promise.allSettled([...operations]);
|
|
93
|
+
try {
|
|
94
|
+
await release?.();
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return refuse(503);
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
if (!request.bodyUsed)
|
|
101
|
+
void request.body?.cancel().catch(() => { });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ResourceIdentity } from './remote-protocol.js';
|
|
2
|
+
import type { WorkspaceKey } from './workspace.js';
|
|
3
|
+
import type { FilePage, SavedRevision } 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
|
+
//# sourceMappingURL=revision-protocol.d.ts.map
|
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
return { revision: identifier(entry.revision), savedAt: entry.savedAt };
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -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
|
package/dist/revision.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createResourceClient } from './remote.js';
|
|
2
|
+
import { binding, bindingHeader, bindingValue, catalogHeader, filePage, record, savedRevisions } from './revision-protocol.js';
|
|
3
|
+
import { readJsonBody } from './request-guard.js';
|
|
4
|
+
import { RevisionError } from './revision-contracts.js';
|
|
5
|
+
import { observe } from './remote-protocol.js';
|
|
6
|
+
import { randomUUID } from './platform.js';
|
|
7
|
+
import { locator } from './publication-input.js';
|
|
8
|
+
export * from './revision-contracts.js';
|
|
9
|
+
export async function connectRevisionProvider(options) {
|
|
10
|
+
const endpoint = new URL(options.endpoint), fetch = options.fetch;
|
|
11
|
+
if (!['http:', 'https:'].includes(endpoint.protocol) || endpoint.username || endpoint.password || endpoint.hash)
|
|
12
|
+
throw new TypeError('Expected an HTTP endpoint without credentials or fragment');
|
|
13
|
+
async function send(request) {
|
|
14
|
+
try {
|
|
15
|
+
const pending = fetch(request);
|
|
16
|
+
void pending.then(response => { if (request.signal.aborted)
|
|
17
|
+
void response.body?.cancel().catch(() => { }); }, () => { });
|
|
18
|
+
const response = await observe(pending, request.signal);
|
|
19
|
+
if (request.signal.aborted) {
|
|
20
|
+
void response.body?.cancel().catch(() => { });
|
|
21
|
+
throw new RevisionError('aborted', 'File request was cancelled');
|
|
22
|
+
}
|
|
23
|
+
if (response.redirected || !response.ok) {
|
|
24
|
+
void response.body?.cancel().catch(() => { });
|
|
25
|
+
throw new RevisionError(response.status === 403 || response.status === 409 ? 'denied' : response.status === 400 ? 'invalid' : 'unavailable', 'The selected workspace is unavailable or no longer authorized');
|
|
26
|
+
}
|
|
27
|
+
return await readJsonBody(response, 8_388_608, request.signal);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (request.signal.aborted)
|
|
31
|
+
throw new RevisionError('aborted', 'File request was cancelled');
|
|
32
|
+
if (error instanceof RevisionError)
|
|
33
|
+
throw error;
|
|
34
|
+
throw new RevisionError('unavailable', 'The file request could not be completed');
|
|
35
|
+
}
|
|
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,
|
|
40
|
+
fetch: request => { const headers = new Headers(request.headers); headers.set(bindingHeader, bound); return fetch(new Request(request, { headers })); } });
|
|
41
|
+
async function invoke(kind, value, signal) {
|
|
42
|
+
const requestId = randomUUID();
|
|
43
|
+
const result = record(await send(new Request(endpoint, { method: 'POST', redirect: 'error', headers: { 'content-type': 'application/json', [bindingHeader]: bound, [catalogHeader]: '1' }, body: JSON.stringify({ requestId, kind, value }), ...(signal ? { signal } : {}) })));
|
|
44
|
+
if (bindingValue(binding(result)) !== bound || result.kind !== kind || result.requestId !== requestId)
|
|
45
|
+
throw new RevisionError('denied', 'The response belongs to another workspace');
|
|
46
|
+
return result.value;
|
|
47
|
+
}
|
|
48
|
+
if (!client.publish || !client.lookup)
|
|
49
|
+
throw new Error('Publishing client lacks reconciliation');
|
|
50
|
+
return {
|
|
51
|
+
...client, publish: client.publish, lookup: client.lookup, identity: Object.freeze(selected.identity), workspace: Object.freeze(selected.workspace),
|
|
52
|
+
locate: path => locator({ resource: { providerId: selected.workspace.providerId, path }, view: { kind: 'published' } }),
|
|
53
|
+
list: async (request, signal) => filePage(await invoke('list', request, signal)),
|
|
54
|
+
search: async (request, signal) => filePage(await invoke('search', request, signal)),
|
|
55
|
+
history: async (path, signal) => savedRevisions(await invoke('history', path, signal)),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { SqliteConnection } from './sqlite.js';
|
|
2
|
+
/**
|
|
3
|
+
* Internal (not a package export): what the workspace provider needs from the SQLite file system to commit a multi-file batch in
|
|
4
|
+
* one transaction. Registered by `openSqliteFileSystem` against the file system object it returns, so the provider keeps taking a
|
|
5
|
+
* plain Pi `FileSystem`. Both functions are synchronous and run inside `connection.transaction`.
|
|
6
|
+
*/
|
|
7
|
+
export interface SqliteBatch {
|
|
8
|
+
readonly connection: SqliteConnection;
|
|
9
|
+
/** The bytes of the regular file at an absolute path, `null` when nothing is there, `undefined` when it is not a file. */
|
|
10
|
+
readonly bytes: (path: string) => Uint8Array | null | undefined;
|
|
11
|
+
/** Write a regular file at an absolute path, creating its missing parent directories. Throws a Pi `FileError` on failure. */
|
|
12
|
+
readonly write: (path: string, bytes: Uint8Array) => void;
|
|
13
|
+
}
|
|
14
|
+
export declare const registerSqliteBatch: (fs: object, batch: SqliteBatch) => void;
|
|
15
|
+
export declare const sqliteBatchOf: (fs: object) => SqliteBatch | undefined;
|
|
16
|
+
/** The journal's connection, so the provider can commit the bytes and the receipt in one transaction when they share a database. */
|
|
17
|
+
export declare const registerJournalConnection: (journal: object, connection: SqliteConnection) => void;
|
|
18
|
+
export declare const journalConnectionOf: (journal: object) => SqliteConnection | undefined;
|
|
19
|
+
//# sourceMappingURL=sqlite-batch.d.ts.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
const batches = new WeakMap();
|
|
2
|
+
const journals = new WeakMap();
|
|
3
|
+
export const registerSqliteBatch = (fs, batch) => { batches.set(fs, batch); };
|
|
4
|
+
export const sqliteBatchOf = (fs) => batches.get(fs);
|
|
5
|
+
/** The journal's connection, so the provider can commit the bytes and the receipt in one transaction when they share a database. */
|
|
6
|
+
export const registerJournalConnection = (journal, connection) => { journals.set(journal, connection); };
|
|
7
|
+
export const journalConnectionOf = (journal) => journals.get(journal);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { SqliteConnection } from './sqlite.js';
|
|
2
|
+
type DurableValue = ArrayBuffer | string | number | null;
|
|
3
|
+
/**
|
|
4
|
+
* The part of a Cloudflare Durable Object's `ctx.storage` this adapter uses, declared structurally so the package needs no
|
|
5
|
+
* Cloudflare types: `sql.exec(query, ...bindings)` returns a cursor and `transactionSync(work)` runs `work` as one SQLite transaction.
|
|
6
|
+
*/
|
|
7
|
+
export interface DurableObjectSqlStorage {
|
|
8
|
+
readonly sql: {
|
|
9
|
+
readonly exec: (query: string, ...bindings: DurableValue[]) => {
|
|
10
|
+
readonly toArray: () => Record<string, unknown>[];
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
readonly transactionSync: <Value>(work: () => Value) => Value;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A `SqliteConnection` over a SQLite-backed Durable Object's storage, for `openSqliteFileSystem({ connection })` and `createWorkspaceJournal(connection)`. Statements run on
|
|
17
|
+
* the object's own synchronous SQL API. A `write` transaction is `transactionSync`: SQLite commits every statement of the callback
|
|
18
|
+
* together, or rolls all of them back when it throws, and nothing else in the object runs in between because the callback is
|
|
19
|
+
* synchronous. A `read` runs the callback directly, which is consistent for the same reason. The object owns the database:
|
|
20
|
+
* the connection has no `close`.
|
|
21
|
+
*/
|
|
22
|
+
export declare function durableObjectSqliteConnection(storage: DurableObjectSqlStorage): SqliteConnection;
|
|
23
|
+
export {};
|
|
24
|
+
//# sourceMappingURL=sqlite-durable-object.d.ts.map
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
function binding(value) {
|
|
2
|
+
if (typeof value === 'bigint') {
|
|
3
|
+
if (value > BigInt(Number.MAX_SAFE_INTEGER) || value < BigInt(Number.MIN_SAFE_INTEGER))
|
|
4
|
+
throw new RangeError('SQLite integer is outside the safe range');
|
|
5
|
+
return Number(value);
|
|
6
|
+
}
|
|
7
|
+
return value instanceof Uint8Array ? value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength) : value;
|
|
8
|
+
}
|
|
9
|
+
/** Blobs come back as ArrayBuffers; the provider reads Uint8Arrays. */
|
|
10
|
+
function row(raw) {
|
|
11
|
+
for (const key of Object.keys(raw)) {
|
|
12
|
+
const value = raw[key];
|
|
13
|
+
if (value instanceof ArrayBuffer)
|
|
14
|
+
raw[key] = new Uint8Array(value);
|
|
15
|
+
}
|
|
16
|
+
return raw;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* A `SqliteConnection` over a SQLite-backed Durable Object's storage, for `openSqliteFileSystem({ connection })` and `createWorkspaceJournal(connection)`. Statements run on
|
|
20
|
+
* the object's own synchronous SQL API. A `write` transaction is `transactionSync`: SQLite commits every statement of the callback
|
|
21
|
+
* together, or rolls all of them back when it throws, and nothing else in the object runs in between because the callback is
|
|
22
|
+
* synchronous. A `read` runs the callback directly, which is consistent for the same reason. The object owns the database:
|
|
23
|
+
* the connection has no `close`.
|
|
24
|
+
*/
|
|
25
|
+
export function durableObjectSqliteConnection(storage) {
|
|
26
|
+
const exec = (sql, params) => storage.sql.exec(sql, ...params.map(binding));
|
|
27
|
+
let depth = 0;
|
|
28
|
+
return {
|
|
29
|
+
exec: sql => { exec(sql, []).toArray(); },
|
|
30
|
+
run: (sql, ...params) => { exec(sql, params).toArray(); },
|
|
31
|
+
get: (sql, ...params) => {
|
|
32
|
+
const [first] = exec(sql, params).toArray();
|
|
33
|
+
return first === undefined ? undefined : row(first);
|
|
34
|
+
},
|
|
35
|
+
all: (sql, ...params) => exec(sql, params).toArray().map(item => row(item)),
|
|
36
|
+
// A transaction opened inside another one joins it.
|
|
37
|
+
transaction: (mode, work) => {
|
|
38
|
+
if (mode === 'read' || depth > 0)
|
|
39
|
+
return work();
|
|
40
|
+
depth++;
|
|
41
|
+
try {
|
|
42
|
+
return storage.transactionSync(work);
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
depth--;
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { FileSystem } from '@earendil-works/pi-durable/env';
|
|
2
|
+
import type { SqliteConnection } from './sqlite.js';
|
|
3
|
+
export interface SqliteFileSystemOptions {
|
|
4
|
+
/** The database. The host owns it: `cleanup` leaves it open. */
|
|
5
|
+
readonly connection: SqliteConnection;
|
|
6
|
+
/** Which workspace's rows: one database can hold several workspaces. */
|
|
7
|
+
readonly workspace: string;
|
|
8
|
+
/** The absolute working directory, created when missing (for example `/workspace`). Relative paths resolve against `cwd`. */
|
|
9
|
+
readonly cwd: string;
|
|
10
|
+
}
|
|
11
|
+
/** A Pi `FileSystem` plus the incarnation of its rows: new when the workspace was first created in this database. */
|
|
12
|
+
export interface SqliteFileSystem extends FileSystem {
|
|
13
|
+
readonly incarnation: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function openSqliteFileSystem(options: SqliteFileSystemOptions): SqliteFileSystem;
|
|
16
|
+
//# sourceMappingURL=sqlite-filesystem.d.ts.map
|