@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/platform.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment-sensitive browser capabilities, in one place. Browsers expose `crypto.randomUUID`, `crypto.subtle`,
|
|
3
|
+
* `navigator.clipboard` and `navigator.share` only in secure contexts (HTTPS, localhost); a page served over plain HTTP on a
|
|
4
|
+
* private network gets none of them. Every browser-reachable package and the studio import these instead of the raw APIs,
|
|
5
|
+
* and `npm run check` (scripts/check-platform-apis.mjs) fails when browser-reachable source uses a raw one.
|
|
6
|
+
*/
|
|
7
|
+
/** A version 4 UUID; falls back to `crypto.getRandomValues`, which every context has. */
|
|
8
|
+
export function randomUUID() {
|
|
9
|
+
const crypto = globalThis.crypto;
|
|
10
|
+
if (typeof crypto.randomUUID === 'function')
|
|
11
|
+
return crypto.randomUUID();
|
|
12
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
13
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
14
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
15
|
+
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
|
|
16
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Puts `text` on the clipboard: the async Clipboard API where present, otherwise a hidden-textarea `execCommand('copy')`
|
|
20
|
+
* (works over plain HTTP). Resolves `false` when neither worked, so the caller can show the text for a manual copy.
|
|
21
|
+
*/
|
|
22
|
+
export async function copyToClipboard(text) {
|
|
23
|
+
try {
|
|
24
|
+
const clipboard = globalThis.navigator?.clipboard;
|
|
25
|
+
if (clipboard) {
|
|
26
|
+
await clipboard.writeText(text);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch { /* denied or unfocused: try the selection route */ }
|
|
31
|
+
const doc = globalThis.document;
|
|
32
|
+
if (!doc?.body || typeof doc.execCommand !== 'function')
|
|
33
|
+
return false;
|
|
34
|
+
const area = doc.createElement('textarea');
|
|
35
|
+
area.value = text;
|
|
36
|
+
area.setAttribute('readonly', '');
|
|
37
|
+
area.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;pointer-events:none';
|
|
38
|
+
const focus = doc.activeElement instanceof HTMLElement ? doc.activeElement : undefined;
|
|
39
|
+
doc.body.append(area);
|
|
40
|
+
try {
|
|
41
|
+
area.select();
|
|
42
|
+
area.setSelectionRange(0, text.length);
|
|
43
|
+
return doc.execCommand('copy');
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
area.remove();
|
|
50
|
+
focus?.focus({ preventScroll: true });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** SHA-256 of `bytes`; without `crypto.subtle` it uses the portable standard (FIPS 180-4) implementation below, with identical output. */
|
|
54
|
+
export async function sha256(bytes) {
|
|
55
|
+
const subtle = globalThis.crypto?.subtle;
|
|
56
|
+
if (subtle)
|
|
57
|
+
return new Uint8Array(await subtle.digest('SHA-256', bytes));
|
|
58
|
+
return sha256Portable(bytes);
|
|
59
|
+
}
|
|
60
|
+
/** SHA-1 of `bytes` (a content identifier, not a security boundary): `crypto.subtle` where present, otherwise the portable implementation below. */
|
|
61
|
+
export async function sha1(bytes) {
|
|
62
|
+
const subtle = globalThis.crypto?.subtle;
|
|
63
|
+
if (subtle)
|
|
64
|
+
return new Uint8Array(await subtle.digest('SHA-1', bytes));
|
|
65
|
+
return sha1Portable(bytes);
|
|
66
|
+
}
|
|
67
|
+
/** Exported for the equivalence check only; use `sha1`. */
|
|
68
|
+
export function sha1Portable(bytes) {
|
|
69
|
+
const length = bytes.length, blocks = Math.ceil((length + 9) / 64);
|
|
70
|
+
const padded = new Uint8Array(blocks * 64);
|
|
71
|
+
padded.set(bytes);
|
|
72
|
+
padded[length] = 0x80;
|
|
73
|
+
const view = new DataView(padded.buffer);
|
|
74
|
+
view.setUint32(padded.length - 8, Math.floor(length / 0x20000000));
|
|
75
|
+
view.setUint32(padded.length - 4, (length * 8) >>> 0);
|
|
76
|
+
const h = Uint32Array.from([0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, 0xc3d2e1f0]);
|
|
77
|
+
const w = new Uint32Array(80);
|
|
78
|
+
const rotl = (x, n) => (x << n) | (x >>> (32 - n));
|
|
79
|
+
for (let offset = 0; offset < padded.length; offset += 64) {
|
|
80
|
+
for (let i = 0; i < 16; i++)
|
|
81
|
+
w[i] = view.getUint32(offset + i * 4);
|
|
82
|
+
for (let i = 16; i < 80; i++)
|
|
83
|
+
w[i] = rotl(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1);
|
|
84
|
+
let [a, b, c, d, e] = [h[0], h[1], h[2], h[3], h[4]];
|
|
85
|
+
for (let i = 0; i < 80; i++) {
|
|
86
|
+
const f = i < 20 ? (b & c) | (~b & d) : i < 40 ? b ^ c ^ d : i < 60 ? (b & c) | (b & d) | (c & d) : b ^ c ^ d;
|
|
87
|
+
const k = i < 20 ? 0x5a827999 : i < 40 ? 0x6ed9eba1 : i < 60 ? 0x8f1bbcdc : 0xca62c1d6;
|
|
88
|
+
const t = (rotl(a, 5) + f + e + k + w[i]) >>> 0;
|
|
89
|
+
e = d;
|
|
90
|
+
d = c;
|
|
91
|
+
c = rotl(b, 30) >>> 0;
|
|
92
|
+
b = a;
|
|
93
|
+
a = t;
|
|
94
|
+
}
|
|
95
|
+
h[0] = (h[0] + a) >>> 0;
|
|
96
|
+
h[1] = (h[1] + b) >>> 0;
|
|
97
|
+
h[2] = (h[2] + c) >>> 0;
|
|
98
|
+
h[3] = (h[3] + d) >>> 0;
|
|
99
|
+
h[4] = (h[4] + e) >>> 0;
|
|
100
|
+
}
|
|
101
|
+
const out = new Uint8Array(20), outView = new DataView(out.buffer);
|
|
102
|
+
h.forEach((word, i) => outView.setUint32(i * 4, word));
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
const K = Uint32Array.from([
|
|
106
|
+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
|
|
107
|
+
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
|
108
|
+
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
|
|
109
|
+
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
|
110
|
+
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
|
|
111
|
+
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
|
112
|
+
]);
|
|
113
|
+
/** Exported for the equivalence check only; use `sha256`. */
|
|
114
|
+
export function sha256Portable(bytes) {
|
|
115
|
+
const length = bytes.length, blocks = Math.ceil((length + 9) / 64);
|
|
116
|
+
const padded = new Uint8Array(blocks * 64);
|
|
117
|
+
padded.set(bytes);
|
|
118
|
+
padded[length] = 0x80;
|
|
119
|
+
const view = new DataView(padded.buffer);
|
|
120
|
+
view.setUint32(padded.length - 8, Math.floor(length / 0x20000000));
|
|
121
|
+
view.setUint32(padded.length - 4, (length * 8) >>> 0);
|
|
122
|
+
const h = Uint32Array.from([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
|
|
123
|
+
const w = new Uint32Array(64);
|
|
124
|
+
const rotr = (x, n) => (x >>> n) | (x << (32 - n));
|
|
125
|
+
for (let offset = 0; offset < padded.length; offset += 64) {
|
|
126
|
+
for (let i = 0; i < 16; i++)
|
|
127
|
+
w[i] = view.getUint32(offset + i * 4);
|
|
128
|
+
for (let i = 16; i < 64; i++) {
|
|
129
|
+
const a = w[i - 15], b = w[i - 2];
|
|
130
|
+
w[i] = (w[i - 16] + (rotr(a, 7) ^ rotr(a, 18) ^ (a >>> 3)) + w[i - 7] + (rotr(b, 17) ^ rotr(b, 19) ^ (b >>> 10))) >>> 0;
|
|
131
|
+
}
|
|
132
|
+
let [a, b, c, d, e, f, g, k] = [h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7]];
|
|
133
|
+
for (let i = 0; i < 64; i++) {
|
|
134
|
+
const t1 = (k + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + w[i]) >>> 0;
|
|
135
|
+
const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
|
|
136
|
+
k = g;
|
|
137
|
+
g = f;
|
|
138
|
+
f = e;
|
|
139
|
+
e = (d + t1) >>> 0;
|
|
140
|
+
d = c;
|
|
141
|
+
c = b;
|
|
142
|
+
b = a;
|
|
143
|
+
a = (t1 + t2) >>> 0;
|
|
144
|
+
}
|
|
145
|
+
h[0] = (h[0] + a) >>> 0;
|
|
146
|
+
h[1] = (h[1] + b) >>> 0;
|
|
147
|
+
h[2] = (h[2] + c) >>> 0;
|
|
148
|
+
h[3] = (h[3] + d) >>> 0;
|
|
149
|
+
h[4] = (h[4] + e) >>> 0;
|
|
150
|
+
h[5] = (h[5] + f) >>> 0;
|
|
151
|
+
h[6] = (h[6] + g) >>> 0;
|
|
152
|
+
h[7] = (h[7] + k) >>> 0;
|
|
153
|
+
}
|
|
154
|
+
const out = new Uint8Array(32), outView = new DataView(out.buffer);
|
|
155
|
+
h.forEach((word, i) => outView.setUint32(i * 4, word));
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PublicationRequest, PublicationResult, ResourceAccess, ResourceLocator, ResourceRef } from './contracts.js';
|
|
2
|
+
/** This invocation did not dispatch. Earlier attempts with the same ID may still have effects. */
|
|
3
|
+
export declare class PublicationNotDispatchedError extends Error {
|
|
4
|
+
readonly operationId: string;
|
|
5
|
+
constructor(operationId: string);
|
|
6
|
+
}
|
|
7
|
+
export declare function identifier(value: unknown): string;
|
|
8
|
+
export declare function locator(value: unknown): ResourceLocator;
|
|
9
|
+
export declare function reference(value: unknown): ResourceRef;
|
|
10
|
+
export declare function accessSnapshot(value: ResourceAccess): ResourceAccess;
|
|
11
|
+
export declare function publicationSnapshot(value: unknown): PublicationRequest;
|
|
12
|
+
export declare function publicationDigest(input: PublicationRequest): Promise<string>;
|
|
13
|
+
/** Parse transport/provider output before treating it as acknowledgement. */
|
|
14
|
+
export declare function parsePublicationResult(value: unknown): PublicationResult;
|
|
15
|
+
//# sourceMappingURL=publication-input.d.ts.map
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { sha256 } from './platform.js';
|
|
2
|
+
/** This invocation did not dispatch. Earlier attempts with the same ID may still have effects. */
|
|
3
|
+
export class PublicationNotDispatchedError extends Error {
|
|
4
|
+
operationId;
|
|
5
|
+
constructor(operationId) {
|
|
6
|
+
super('Publication request could not be prepared and was not dispatched');
|
|
7
|
+
this.name = 'PublicationNotDispatchedError';
|
|
8
|
+
this.operationId = identifier(operationId);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function object(value) {
|
|
12
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
13
|
+
throw new TypeError('Expected an object');
|
|
14
|
+
return Object.fromEntries(Object.entries(value));
|
|
15
|
+
}
|
|
16
|
+
export function identifier(value) {
|
|
17
|
+
if (typeof value !== 'string' || !value.length || value.length > 1024 || /[\u0000-\u001f]/u.test(value) || /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(value)) {
|
|
18
|
+
throw new TypeError('Expected a nonempty well-formed identifier without control characters');
|
|
19
|
+
}
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
/** Human-readable refusal text from a provider: normalized and bounded rather than rejected, so a long or multi-line reason cannot downgrade the outcome. */
|
|
23
|
+
function reason(value) {
|
|
24
|
+
if (typeof value !== 'string')
|
|
25
|
+
throw new TypeError('Expected a refusal reason');
|
|
26
|
+
const text = value.replace(/[\u0000-\u001f]+/g, ' ').trim().slice(0, 1024).replace(/[\uD800-\uDBFF]$/, '');
|
|
27
|
+
return identifier(text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '\uFFFD') || 'No reason given');
|
|
28
|
+
}
|
|
29
|
+
export function locator(value) {
|
|
30
|
+
const input = object(value);
|
|
31
|
+
const resource = object(input.resource);
|
|
32
|
+
const path = identifier(resource.path);
|
|
33
|
+
if (path.includes('\\') || path.includes('%') || path.split('/').some(part => !part || part === '.' || part === '..')) {
|
|
34
|
+
throw new TypeError('Expected a relative canonical resource path');
|
|
35
|
+
}
|
|
36
|
+
const view = object(input.view);
|
|
37
|
+
if (view.kind !== 'published' && view.kind !== 'working')
|
|
38
|
+
throw new TypeError('Invalid resource view');
|
|
39
|
+
return {
|
|
40
|
+
resource: { providerId: identifier(resource.providerId), path },
|
|
41
|
+
view: view.kind === 'published' ? { kind: 'published' } : { kind: 'working', viewId: identifier(view.viewId) },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export function reference(value) {
|
|
45
|
+
return { ...locator(value), revision: identifier(object(value).revision) };
|
|
46
|
+
}
|
|
47
|
+
export function accessSnapshot(value) {
|
|
48
|
+
return {
|
|
49
|
+
principalId: identifier(value.principalId), scopeId: identifier(value.scopeId), initiatorId: identifier(value.initiatorId),
|
|
50
|
+
...(value.authorizationRef === undefined ? {} : { authorizationRef: identifier(value.authorizationRef) }),
|
|
51
|
+
...(value.signal === undefined ? {} : { signal: value.signal }),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function expectation(value) {
|
|
55
|
+
const input = object(value);
|
|
56
|
+
if (input.kind === 'absent')
|
|
57
|
+
return { kind: 'absent', target: locator(input.target) };
|
|
58
|
+
if (input.kind === 'revision')
|
|
59
|
+
return { kind: 'revision', target: reference(input.target) };
|
|
60
|
+
throw new TypeError('Invalid resource expectation');
|
|
61
|
+
}
|
|
62
|
+
function change(value) {
|
|
63
|
+
const input = object(value);
|
|
64
|
+
if (input.kind === 'delete')
|
|
65
|
+
return { kind: 'delete', target: reference(input.target) };
|
|
66
|
+
if (input.kind !== 'create' && input.kind !== 'replace')
|
|
67
|
+
throw new TypeError('Invalid resource change');
|
|
68
|
+
if (!(input.bytes instanceof Uint8Array))
|
|
69
|
+
throw new TypeError('Resource bytes must be Uint8Array');
|
|
70
|
+
const bytes = Uint8Array.from(input.bytes);
|
|
71
|
+
const mediaType = identifier(input.mediaType);
|
|
72
|
+
if (input.kind === 'replace')
|
|
73
|
+
return { kind: 'replace', target: reference(input.target), bytes, mediaType };
|
|
74
|
+
if (object(input.expected).kind !== 'absent')
|
|
75
|
+
throw new TypeError('Create requires explicit absence');
|
|
76
|
+
return { kind: 'create', target: locator(input.target), expected: { kind: 'absent' }, bytes, mediaType };
|
|
77
|
+
}
|
|
78
|
+
export function publicationSnapshot(value) {
|
|
79
|
+
const input = object(value);
|
|
80
|
+
if (!Array.isArray(input.changes) || !input.changes.length)
|
|
81
|
+
throw new TypeError('Publication requires changes');
|
|
82
|
+
if (input.atomicity !== 'all-or-nothing' && input.atomicity !== 'per-change')
|
|
83
|
+
throw new TypeError('Invalid atomicity');
|
|
84
|
+
const [first, ...rest] = input.changes;
|
|
85
|
+
const changes = [change(first), ...rest.map(change)];
|
|
86
|
+
const targets = changes.map(item => JSON.stringify(item.target.resource) + JSON.stringify(item.target.view));
|
|
87
|
+
if (new Set(targets).size !== targets.length)
|
|
88
|
+
throw new TypeError('A publication cannot change the same target twice');
|
|
89
|
+
if (input.preconditions !== undefined && !Array.isArray(input.preconditions))
|
|
90
|
+
throw new TypeError('Invalid preconditions');
|
|
91
|
+
return {
|
|
92
|
+
operationId: identifier(input.operationId), changes, atomicity: input.atomicity,
|
|
93
|
+
...(input.preconditions === undefined ? {} : { preconditions: input.preconditions.map(expectation) }),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
export async function publicationDigest(input) {
|
|
97
|
+
const request = publicationSnapshot(input);
|
|
98
|
+
const canonical = JSON.stringify({
|
|
99
|
+
format: 'boring-publication-v1',
|
|
100
|
+
atomicity: request.atomicity,
|
|
101
|
+
changes: request.changes.map(item => item.kind === 'delete' ? item : { ...item, bytes: Array.from(item.bytes) }),
|
|
102
|
+
preconditions: request.preconditions ?? [],
|
|
103
|
+
});
|
|
104
|
+
const digest = await sha256(new TextEncoder().encode(canonical));
|
|
105
|
+
return 'boring-publication-v1:sha256:' + Array.from(digest, byte => byte.toString(16).padStart(2, '0')).join('');
|
|
106
|
+
}
|
|
107
|
+
function committedChange(value) {
|
|
108
|
+
const input = object(value);
|
|
109
|
+
if (input.kind === 'create' && input.before === null)
|
|
110
|
+
return { kind: 'create', before: null, after: reference(input.after) };
|
|
111
|
+
if (input.kind === 'delete' && input.after === null)
|
|
112
|
+
return { kind: 'delete', before: reference(input.before), after: null };
|
|
113
|
+
if (input.kind === 'replace') {
|
|
114
|
+
const before = reference(input.before), after = reference(input.after);
|
|
115
|
+
if (JSON.stringify(locator(before)) !== JSON.stringify(locator(after)))
|
|
116
|
+
throw new TypeError('Invalid replacement receipt');
|
|
117
|
+
return { kind: 'replace', before, after };
|
|
118
|
+
}
|
|
119
|
+
throw new TypeError('Invalid committed change');
|
|
120
|
+
}
|
|
121
|
+
function publicationReceipt(value) {
|
|
122
|
+
const input = object(value);
|
|
123
|
+
if (!Array.isArray(input.changes) || !input.changes.length)
|
|
124
|
+
throw new TypeError('Receipt requires changes');
|
|
125
|
+
const [first, ...rest] = input.changes;
|
|
126
|
+
return {
|
|
127
|
+
operationId: identifier(input.operationId), argumentDigest: identifier(input.argumentDigest),
|
|
128
|
+
principalId: identifier(input.principalId), scopeId: identifier(input.scopeId), initiatorId: identifier(input.initiatorId),
|
|
129
|
+
evidenceRef: identifier(input.evidenceRef), changes: [committedChange(first), ...rest.map(committedChange)],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function publicationOutcome(value) {
|
|
133
|
+
const input = object(value);
|
|
134
|
+
switch (input.kind) {
|
|
135
|
+
case 'committed': return { kind: 'committed', receipt: publicationReceipt(input.receipt) };
|
|
136
|
+
case 'denied':
|
|
137
|
+
case 'unavailable': return { kind: input.kind, reason: reason(input.reason) };
|
|
138
|
+
case 'unknown': return { kind: 'unknown', operationId: identifier(input.operationId), reason: reason(input.reason) };
|
|
139
|
+
case 'conflict': {
|
|
140
|
+
if (!Array.isArray(input.current))
|
|
141
|
+
throw new TypeError('Conflict requires current references');
|
|
142
|
+
return { kind: 'conflict', current: input.current.map(reference), reason: reason(input.reason) };
|
|
143
|
+
}
|
|
144
|
+
default: throw new TypeError('Invalid publication outcome');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** Parse transport/provider output before treating it as acknowledgement. */
|
|
148
|
+
export function parsePublicationResult(value) {
|
|
149
|
+
const input = object(value);
|
|
150
|
+
if (input.kind !== 'partial')
|
|
151
|
+
return publicationOutcome(input);
|
|
152
|
+
if (!Array.isArray(input.items) || !input.items.length)
|
|
153
|
+
throw new TypeError('Partial publication requires items');
|
|
154
|
+
const items = input.items.map(value => {
|
|
155
|
+
const item = object(value);
|
|
156
|
+
if (typeof item.changeIndex !== 'number' || !Number.isSafeInteger(item.changeIndex) || item.changeIndex < 0)
|
|
157
|
+
throw new TypeError('Invalid change index');
|
|
158
|
+
return { changeIndex: item.changeIndex, target: locator(item.target), outcome: publicationOutcome(item.outcome) };
|
|
159
|
+
});
|
|
160
|
+
const [first, ...rest] = items;
|
|
161
|
+
if (!first || new Set(items.map(item => item.changeIndex)).size !== items.length)
|
|
162
|
+
throw new TypeError('Invalid partial publication coverage');
|
|
163
|
+
return { kind: 'partial', operationId: identifier(input.operationId), items: [first, ...rest] };
|
|
164
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { PublicationLookup, ResourceAccess, ResourcePublisher, ResourceReader } from './contracts.js';
|
|
2
|
+
export interface ResourceHandlerOptions {
|
|
3
|
+
readonly authenticate: (request: Request) => Promise<ResourceAccess | null>;
|
|
4
|
+
readonly reader: ResourceReader;
|
|
5
|
+
readonly publisher?: ResourcePublisher;
|
|
6
|
+
readonly lookup?: PublicationLookup;
|
|
7
|
+
readonly maxRequestBytes?: number;
|
|
8
|
+
readonly maxResponseBytes?: number;
|
|
9
|
+
}
|
|
10
|
+
export declare function createResourceHandler(options: ResourceHandlerOptions): (request: Request) => Promise<Response>;
|
|
11
|
+
//# sourceMappingURL=remote-handler.d.ts.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { guardStatus, readJsonBody } from './request-guard.js';
|
|
2
|
+
import { accessSnapshot, publicationDigest } from './publication-input.js';
|
|
3
|
+
import { contentType, decodeCall, encode, envelope, identity, limit, lookupResult, observe, publicationResult, readResult, sameIdentity, schema, version } from './remote-protocol.js';
|
|
4
|
+
export function createResourceHandler(options) {
|
|
5
|
+
const { authenticate, reader, publisher, lookup } = options;
|
|
6
|
+
const requestLimit = limit(options.maxRequestBytes ?? 4_194_304), responseLimit = limit(options.maxResponseBytes ?? 8_388_608);
|
|
7
|
+
return async (request) => {
|
|
8
|
+
const refuse = (status) => new Response(null, { status, headers: { 'cache-control': 'no-store' } });
|
|
9
|
+
try {
|
|
10
|
+
if (request.method !== 'POST')
|
|
11
|
+
return refuse(405);
|
|
12
|
+
const authenticated = await observe(authenticate(request), request.signal);
|
|
13
|
+
if (!authenticated)
|
|
14
|
+
return refuse(403);
|
|
15
|
+
const captured = accessSnapshot(authenticated);
|
|
16
|
+
const signal = captured.signal ? AbortSignal.any([captured.signal, request.signal]) : request.signal;
|
|
17
|
+
const access = { ...captured, signal };
|
|
18
|
+
if (signal.aborted)
|
|
19
|
+
return refuse(403);
|
|
20
|
+
const input = envelope(await readJsonBody(request, requestLimit, signal));
|
|
21
|
+
if (!sameIdentity(input.identity, captured))
|
|
22
|
+
return refuse(403);
|
|
23
|
+
const call = decodeCall(input);
|
|
24
|
+
let value;
|
|
25
|
+
switch (call.kind) {
|
|
26
|
+
case 'read': {
|
|
27
|
+
if (signal.aborted)
|
|
28
|
+
return refuse(403);
|
|
29
|
+
value = readResult(await observe(reader.read(call.value, access), signal), call.value);
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
case 'publish': {
|
|
33
|
+
if (!publisher)
|
|
34
|
+
return refuse(405);
|
|
35
|
+
const digest = await observe(publicationDigest(call.value), signal);
|
|
36
|
+
if (signal.aborted)
|
|
37
|
+
return refuse(403);
|
|
38
|
+
value = publicationResult(await observe(publisher.publish(call.value, access), signal), call.value.operationId, captured, call.value, digest);
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
case 'lookup': {
|
|
42
|
+
if (!lookup)
|
|
43
|
+
return refuse(405);
|
|
44
|
+
if (signal.aborted)
|
|
45
|
+
return refuse(403);
|
|
46
|
+
value = lookupResult(await observe(lookup.lookup(call.value, access), signal), call.value, captured);
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (signal.aborted)
|
|
51
|
+
return refuse(403);
|
|
52
|
+
const body = encode({ schema, version, requestId: input.requestId, identity: identity(captured), kind: call.kind, value }, responseLimit);
|
|
53
|
+
return new Response(body, { status: 200, headers: { 'content-type': contentType, 'cache-control': 'no-store' } });
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
return refuse(guardStatus(error));
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
if (!request.bodyUsed)
|
|
60
|
+
void request.body?.cancel().catch(() => { });
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { PublicationLookupResult, PublicationRequest, PublicationResult, ReadResult, ResourceAccess, ResourceRead } from './contracts.js';
|
|
2
|
+
export type ResourceIdentity = Pick<ResourceAccess, 'principalId' | 'scopeId' | 'initiatorId'>;
|
|
3
|
+
export type ResourceCall = {
|
|
4
|
+
readonly kind: 'read';
|
|
5
|
+
readonly value: ResourceRead;
|
|
6
|
+
} | {
|
|
7
|
+
readonly kind: 'publish';
|
|
8
|
+
readonly value: PublicationRequest;
|
|
9
|
+
} | {
|
|
10
|
+
readonly kind: 'lookup';
|
|
11
|
+
readonly value: string;
|
|
12
|
+
};
|
|
13
|
+
export declare const schema = "boring-resource";
|
|
14
|
+
export declare const version = 2;
|
|
15
|
+
export declare const contentType = "application/json";
|
|
16
|
+
export declare function object(value: unknown): Record<string, unknown>;
|
|
17
|
+
export declare function identity(value: ResourceIdentity): ResourceIdentity;
|
|
18
|
+
export declare function sameIdentity(left: ResourceIdentity, right: ResourceIdentity): boolean;
|
|
19
|
+
export declare function envelope(value: unknown): {
|
|
20
|
+
requestId: string;
|
|
21
|
+
identity: ResourceIdentity;
|
|
22
|
+
kind: ResourceCall['kind'];
|
|
23
|
+
value: unknown;
|
|
24
|
+
};
|
|
25
|
+
export declare function readSnapshot(value: unknown): ResourceRead;
|
|
26
|
+
export declare function decodeCall(input: ReturnType<typeof envelope>): ResourceCall;
|
|
27
|
+
export declare function encode(value: unknown, limit: number): string;
|
|
28
|
+
export declare function limit(value: number): number;
|
|
29
|
+
export declare function observe<Value>(pending: Promise<Value>, signal?: AbortSignal): Promise<Value>;
|
|
30
|
+
export { readJsonBody as jsonBody } from './request-guard.js';
|
|
31
|
+
export declare function readResult(value: unknown, request: ResourceRead, wire?: boolean): ReadResult;
|
|
32
|
+
export declare function publicationResult(value: unknown, operationId: string, selected: ResourceIdentity, request?: PublicationRequest, digest?: string): PublicationResult;
|
|
33
|
+
export declare function lookupResult(value: unknown, operationId: string, selected: ResourceIdentity): PublicationLookupResult;
|
|
34
|
+
//# sourceMappingURL=remote-protocol.d.ts.map
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { accessSnapshot, identifier, locator, parsePublicationResult, publicationSnapshot, reference } from './publication-input.js';
|
|
2
|
+
export const schema = 'boring-resource';
|
|
3
|
+
export const version = 2;
|
|
4
|
+
export const contentType = 'application/json';
|
|
5
|
+
export function object(value) {
|
|
6
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
7
|
+
throw new TypeError('Expected an object');
|
|
8
|
+
return Object.fromEntries(Object.entries(value));
|
|
9
|
+
}
|
|
10
|
+
export function identity(value) {
|
|
11
|
+
const { principalId, scopeId, initiatorId } = accessSnapshot(value);
|
|
12
|
+
return { principalId, scopeId, initiatorId };
|
|
13
|
+
}
|
|
14
|
+
export function sameIdentity(left, right) {
|
|
15
|
+
return left.principalId === right.principalId && left.scopeId === right.scopeId && left.initiatorId === right.initiatorId;
|
|
16
|
+
}
|
|
17
|
+
function wireIdentity(value) {
|
|
18
|
+
const input = object(value);
|
|
19
|
+
return { principalId: identifier(input.principalId), scopeId: identifier(input.scopeId), initiatorId: identifier(input.initiatorId) };
|
|
20
|
+
}
|
|
21
|
+
export function envelope(value) {
|
|
22
|
+
const input = object(value);
|
|
23
|
+
if (input.schema !== schema || input.version !== version)
|
|
24
|
+
throw new TypeError('Unsupported resource protocol');
|
|
25
|
+
if (input.kind !== 'read' && input.kind !== 'publish' && input.kind !== 'lookup')
|
|
26
|
+
throw new TypeError('Unsupported resource method');
|
|
27
|
+
return { requestId: identifier(input.requestId), identity: wireIdentity(input.identity), kind: input.kind, value: input.value };
|
|
28
|
+
}
|
|
29
|
+
export function readSnapshot(value) {
|
|
30
|
+
const input = object(value), revision = object(input.revision);
|
|
31
|
+
if (revision.kind !== 'latest' && revision.kind !== 'exact')
|
|
32
|
+
throw new TypeError('Invalid revision selector');
|
|
33
|
+
return { target: locator(input.target), revision: revision.kind === 'latest' ? { kind: 'latest' } : { kind: 'exact', value: identifier(revision.value) } };
|
|
34
|
+
}
|
|
35
|
+
/** Bytes travel as canonical base64 text; JSON number arrays multiplied the size by three to four. */
|
|
36
|
+
function encodeBytes(value) {
|
|
37
|
+
let text = '';
|
|
38
|
+
for (let index = 0; index < value.length; index += 0x8000)
|
|
39
|
+
text += String.fromCharCode(...value.subarray(index, index + 0x8000));
|
|
40
|
+
return btoa(text);
|
|
41
|
+
}
|
|
42
|
+
function decodeBytes(value) {
|
|
43
|
+
let text;
|
|
44
|
+
try {
|
|
45
|
+
text = atob(value);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
throw new TypeError('Invalid resource bytes');
|
|
49
|
+
}
|
|
50
|
+
const result = new Uint8Array(text.length);
|
|
51
|
+
for (let index = 0; index < text.length; index++)
|
|
52
|
+
result[index] = text.charCodeAt(index);
|
|
53
|
+
if (encodeBytes(result) !== value)
|
|
54
|
+
throw new TypeError('Invalid resource bytes');
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
function bytes(value) {
|
|
58
|
+
if (typeof value !== 'string')
|
|
59
|
+
throw new TypeError('Invalid resource bytes');
|
|
60
|
+
return decodeBytes(value);
|
|
61
|
+
}
|
|
62
|
+
export function decodeCall(input) {
|
|
63
|
+
if (input.kind === 'read')
|
|
64
|
+
return { kind: 'read', value: readSnapshot(input.value) };
|
|
65
|
+
if (input.kind === 'lookup')
|
|
66
|
+
return { kind: 'lookup', value: identifier(input.value) };
|
|
67
|
+
const value = object(input.value);
|
|
68
|
+
if (!Array.isArray(value.changes))
|
|
69
|
+
throw new TypeError('Missing changes');
|
|
70
|
+
const changes = value.changes.map(item => {
|
|
71
|
+
const change = object(item);
|
|
72
|
+
return change.kind === 'delete' ? change : { ...change, bytes: bytes(change.bytes) };
|
|
73
|
+
});
|
|
74
|
+
const request = publicationSnapshot({ ...value, changes });
|
|
75
|
+
if (request.atomicity !== 'all-or-nothing')
|
|
76
|
+
throw new TypeError('Remote per-change publication is not qualified');
|
|
77
|
+
return { kind: 'publish', value: request };
|
|
78
|
+
}
|
|
79
|
+
export function encode(value, limit) {
|
|
80
|
+
const json = JSON.stringify(value, (_key, item) => item instanceof Uint8Array ? encodeBytes(item) : item);
|
|
81
|
+
if (new TextEncoder().encode(json).byteLength > limit)
|
|
82
|
+
throw new RangeError('Resource message exceeds byte limit');
|
|
83
|
+
return json;
|
|
84
|
+
}
|
|
85
|
+
export function limit(value) {
|
|
86
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > 67_108_864)
|
|
87
|
+
throw new TypeError('Expected a byte limit between 1 and 67108864');
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
export function observe(pending, signal) {
|
|
91
|
+
if (!signal)
|
|
92
|
+
return pending;
|
|
93
|
+
return new Promise((resolve, reject) => {
|
|
94
|
+
const abort = () => { signal.removeEventListener('abort', abort); reject(new Error('Resource observation aborted')); };
|
|
95
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
96
|
+
if (signal.aborted)
|
|
97
|
+
abort();
|
|
98
|
+
pending.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
export { readJsonBody as jsonBody } from './request-guard.js';
|
|
102
|
+
function sameTarget(left, right) {
|
|
103
|
+
return JSON.stringify(locator(left)) === JSON.stringify(locator(right));
|
|
104
|
+
}
|
|
105
|
+
export function readResult(value, request, wire = false) {
|
|
106
|
+
const input = object(value);
|
|
107
|
+
if (input.kind === 'missing')
|
|
108
|
+
return { kind: 'missing' };
|
|
109
|
+
if (input.kind === 'denied' || input.kind === 'unavailable')
|
|
110
|
+
return { kind: input.kind, reason: identifier(input.reason) };
|
|
111
|
+
if (input.kind !== 'available')
|
|
112
|
+
throw new TypeError('Invalid read result');
|
|
113
|
+
const snapshot = object(input.snapshot), ref = reference(snapshot.ref);
|
|
114
|
+
if (!sameTarget(ref, request.target) || (request.revision.kind === 'exact' && ref.revision !== request.revision.value))
|
|
115
|
+
throw new TypeError('Read reference mismatch');
|
|
116
|
+
const content = wire ? bytes(snapshot.bytes) : snapshot.bytes;
|
|
117
|
+
if (!(content instanceof Uint8Array))
|
|
118
|
+
throw new TypeError('Invalid snapshot bytes');
|
|
119
|
+
return { kind: 'available', snapshot: { ref, bytes: Uint8Array.from(content), mediaType: identifier(snapshot.mediaType) } };
|
|
120
|
+
}
|
|
121
|
+
function outcomeAgreement(result, operationId, selected, changes, digest) {
|
|
122
|
+
if (result.kind === 'unknown' && result.operationId !== operationId)
|
|
123
|
+
throw new TypeError('Unknown operation mismatch');
|
|
124
|
+
if (result.kind !== 'committed')
|
|
125
|
+
return;
|
|
126
|
+
const receipt = result.receipt;
|
|
127
|
+
if (receipt.operationId !== operationId || !sameIdentity(receipt, selected) || (digest !== undefined && receipt.argumentDigest !== digest))
|
|
128
|
+
throw new TypeError('Receipt identity mismatch');
|
|
129
|
+
const targets = receipt.changes.map(change => JSON.stringify(locator(change.after ?? change.before)));
|
|
130
|
+
if (new Set(targets).size !== targets.length)
|
|
131
|
+
throw new TypeError('Duplicate receipt target');
|
|
132
|
+
if (!changes)
|
|
133
|
+
return;
|
|
134
|
+
if (receipt.changes.length !== changes.length)
|
|
135
|
+
throw new TypeError('Receipt change count mismatch');
|
|
136
|
+
const committedByTarget = new Map(receipt.changes.map(change => [JSON.stringify(locator(change.after ?? change.before)), change]));
|
|
137
|
+
for (const change of changes) {
|
|
138
|
+
const committed = committedByTarget.get(JSON.stringify(locator(change.target)));
|
|
139
|
+
if (!committed || committed.kind !== change.kind)
|
|
140
|
+
throw new TypeError('Receipt change mismatch');
|
|
141
|
+
if (change.kind !== 'create' && JSON.stringify(committed.before) !== JSON.stringify(change.target))
|
|
142
|
+
throw new TypeError('Receipt base mismatch');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
export function publicationResult(value, operationId, selected, request, digest) {
|
|
146
|
+
const result = parsePublicationResult(value);
|
|
147
|
+
if (result.kind === 'partial')
|
|
148
|
+
throw new TypeError('Remote partial receipts are not qualified');
|
|
149
|
+
outcomeAgreement(result, operationId, selected, request?.changes, digest);
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
export function lookupResult(value, operationId, selected) {
|
|
153
|
+
return object(value).kind === 'not-found' ? { kind: 'not-found' } : publicationResult(value, operationId, selected);
|
|
154
|
+
}
|
package/dist/remote.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ResourceClient } from './client.js';
|
|
2
|
+
import type { ResourceIdentity } from './remote-protocol.js';
|
|
3
|
+
export { createResourceHandler } from './remote-handler.js';
|
|
4
|
+
export type { ResourceHandlerOptions } from './remote-handler.js';
|
|
5
|
+
export type { ResourceIdentity } from './remote-protocol.js';
|
|
6
|
+
interface ResourceClientBase {
|
|
7
|
+
readonly identity: ResourceIdentity;
|
|
8
|
+
readonly endpoint: string | URL;
|
|
9
|
+
readonly fetch: (request: Request) => Promise<Response>;
|
|
10
|
+
readonly maxRequestBytes?: number;
|
|
11
|
+
readonly maxResponseBytes?: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* A client that can publish must also be able to reconcile (`lookup`): after one lost acknowledgement the editor's save
|
|
15
|
+
* is `unknown`, and without a lookup nothing can release it, so the editor would stay unsavable until a reload dropped
|
|
16
|
+
* the unsaved text. The type requires both, and the constructor throws if a plain-JS caller sets only `publication`.
|
|
17
|
+
*/
|
|
18
|
+
export type ResourceClientOptions = ResourceClientBase & ({
|
|
19
|
+
readonly publication: true;
|
|
20
|
+
readonly reconciliation: true;
|
|
21
|
+
} | {
|
|
22
|
+
readonly publication?: false;
|
|
23
|
+
readonly reconciliation?: boolean;
|
|
24
|
+
});
|
|
25
|
+
export declare function createResourceClient(options: ResourceClientOptions): ResourceClient;
|
|
26
|
+
//# sourceMappingURL=remote.d.ts.map
|