@volter/world-core 3.0.155 → 3.0.157
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/attach.cjs +2 -0
- package/capability-decision.cjs +6 -4
- package/dist/attach.cjs +2 -0
- package/dist/capability-decision.cjs +6 -4
- package/dist/generated/pack-facts.json +204 -4
- package/dist/inject.cjs +150 -86
- package/dist/network-policy.cjs +11 -1
- package/dist/network-policy.d.cts +1 -0
- package/dist/pack-facts.d.cts +3 -0
- package/dist/required-credential-transport.cjs +29 -0
- package/dist/required-credential-transport.d.cts +3 -0
- package/dist/src/actions.d.ts +15 -0
- package/dist/src/actions.js +82 -21
- package/dist/src/ancestry.d.ts +5 -4
- package/dist/src/ancestry.js +53 -28
- package/dist/src/blob-store.d.ts +16 -0
- package/dist/src/blob-store.js +76 -1
- package/dist/src/changeset.js +1 -1
- package/dist/src/clickhouse/compression.d.ts +28 -0
- package/dist/src/clickhouse/compression.js +281 -0
- package/dist/src/clickhouse/index.d.ts +1 -0
- package/dist/src/clickhouse/index.js +1 -0
- package/dist/src/derived-core.d.ts +84 -7
- package/dist/src/derived-core.js +336 -61
- package/dist/src/derived-real.js +97 -15
- package/dist/src/derived.d.ts +5 -0
- package/dist/src/derived.js +3 -0
- package/dist/src/executor.d.ts +7 -0
- package/dist/src/executor.js +15 -1
- package/dist/src/fork.js +1 -1
- package/dist/src/git/lfs.d.ts +13 -3
- package/dist/src/git/lfs.js +41 -9
- package/dist/src/grpc-wire.js +2 -1
- package/dist/src/h2.js +63 -10
- package/dist/src/head.d.ts +5 -0
- package/dist/src/head.js +15 -0
- package/dist/src/history.d.ts +1 -1
- package/dist/src/history.js +7 -7
- package/dist/src/index.d.ts +5 -1
- package/dist/src/index.js +6 -1
- package/dist/src/live/ingest.js +9 -13
- package/dist/src/live/rtmp.js +7 -5
- package/dist/src/live/segmenter.js +7 -1
- package/dist/src/log.d.ts +23 -3
- package/dist/src/log.js +160 -37
- package/dist/src/machines.js +5 -1
- package/dist/src/managed-go-programs.d.ts +55 -0
- package/dist/src/managed-go-programs.js +133 -0
- package/dist/src/managed-original-inputs.d.ts +101 -0
- package/dist/src/managed-original-inputs.js +125 -0
- package/dist/src/mongodb/client.d.ts +8 -0
- package/dist/src/mongodb/client.js +27 -0
- package/dist/src/mongodb/index.d.ts +2 -0
- package/dist/src/mongodb/index.js +5 -0
- package/dist/src/mongodb/stream.d.ts +97 -0
- package/dist/src/mongodb/stream.js +206 -0
- package/dist/src/observe.js +1 -1
- package/dist/src/pack-fetch.d.ts +6 -1
- package/dist/src/pack-fetch.js +69 -14
- package/dist/src/pack-paths.d.ts +2 -1
- package/dist/src/pack-paths.js +6 -1
- package/dist/src/packRegistry.d.ts +10 -14
- package/dist/src/packRegistry.js +2 -13
- package/dist/src/protocol.d.ts +13 -0
- package/dist/src/protocol.js +13 -0
- package/dist/src/redis/engine.d.ts +10 -4
- package/dist/src/redis/engine.js +103 -32
- package/dist/src/redis/member-storage.js +20 -8
- package/dist/src/redis/service-times.d.ts +25 -0
- package/dist/src/redis/service-times.js +87 -0
- package/dist/src/redis/stream.d.ts +2 -1
- package/dist/src/redis/stream.js +109 -20
- package/dist/src/resource-blob.d.ts +4 -0
- package/dist/src/resource-blob.js +8 -1
- package/dist/src/runtime-state.d.ts +11 -0
- package/dist/src/runtime-state.js +13 -0
- package/dist/src/runtime.d.ts +7 -5
- package/dist/src/runtime.js +7 -4
- package/dist/src/serve-http.d.ts +2 -0
- package/dist/src/serve-http.js +73 -6
- package/dist/src/serve.d.ts +10 -0
- package/dist/src/serve.js +43 -3
- package/dist/src/sockets.d.ts +4 -1
- package/dist/src/sockets.js +59 -6
- package/dist/src/storage.d.ts +2 -1
- package/dist/src/storage.js +6 -3
- package/dist/src/vendor-call.js +58 -0
- package/dist/src/world-boot.d.ts +1 -0
- package/dist/src/world-boot.js +6 -0
- package/dist/src/world-clock.js +6 -2
- package/dist/src/world-store.d.ts +122 -7
- package/dist/src/world-store.js +959 -103
- package/dist/src/xml.d.ts +30 -0
- package/dist/src/xml.js +74 -0
- package/generated/pack-facts.json +204 -4
- package/inject.cjs +150 -86
- package/network-policy.cjs +11 -1
- package/network-policy.d.cts +1 -0
- package/pack-facts.d.cts +3 -0
- package/package.json +27 -1
- package/required-credential-transport.cjs +29 -0
- package/required-credential-transport.d.cts +3 -0
- package/src/actions.ts +78 -21
- package/src/ancestry.ts +46 -24
- package/src/blob-store.ts +59 -1
- package/src/changeset.ts +1 -1
- package/src/clickhouse/compression.ts +172 -0
- package/src/clickhouse/index.ts +2 -0
- package/src/derived-core.ts +356 -61
- package/src/derived-real.ts +80 -14
- package/src/derived.ts +9 -1
- package/src/executor.ts +17 -3
- package/src/fork.ts +1 -1
- package/src/git/lfs.ts +40 -9
- package/src/grpc-wire.ts +2 -1
- package/src/h2.ts +46 -5
- package/src/head.ts +15 -0
- package/src/history.ts +7 -7
- package/src/index.ts +7 -1
- package/src/live/ingest.ts +7 -3
- package/src/live/rtmp.ts +7 -5
- package/src/live/segmenter.ts +7 -1
- package/src/log.ts +147 -32
- package/src/machines.ts +5 -1
- package/src/managed-go-programs.ts +146 -0
- package/src/managed-original-inputs.ts +163 -0
- package/src/mongodb/client.ts +27 -0
- package/src/mongodb/index.ts +5 -0
- package/src/mongodb/stream.ts +223 -0
- package/src/observe.ts +1 -1
- package/src/pack-fetch.ts +66 -16
- package/src/pack-paths.ts +5 -1
- package/src/packRegistry.ts +3 -15
- package/src/protocol.ts +13 -0
- package/src/redis/engine.ts +100 -57
- package/src/redis/member-storage.ts +17 -9
- package/src/redis/service-times.ts +81 -0
- package/src/redis/stream.ts +66 -14
- package/src/resource-blob.ts +9 -1
- package/src/runtime-state.ts +14 -0
- package/src/runtime.ts +10 -3
- package/src/serve-http.ts +65 -6
- package/src/serve.ts +45 -3
- package/src/sockets.ts +39 -8
- package/src/storage.ts +5 -3
- package/src/vendor-call.ts +54 -0
- package/src/world-boot.ts +6 -0
- package/src/world-clock.ts +6 -2
- package/src/world-store.ts +768 -91
- package/src/xml.ts +71 -0
package/src/derived-real.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { parseExactJson, exactInteger, compareExactNumbers } from './exact-json.
|
|
|
4
4
|
// They run over the kernel executor (`RemoteExecute`), which applies the sealed credential; the credential never
|
|
5
5
|
// reaches here, only its keyed fingerprint (`PerformContext.credential`, for the idempotency key).
|
|
6
6
|
import type { TwinAction } from './actions.ts';
|
|
7
|
-
import { aliasesFrom, parentEntries, readParentTreeMap, readTree } from './log.ts';
|
|
7
|
+
import { aliasesFrom, parentEntries, readParentTreeMap, readTree, wholeLog } from './log.ts';
|
|
8
8
|
import { storedType, type DerivedManifest, type IngestDecl, type ResourceDecl, type VendorManifest } from './derived-core.ts';
|
|
9
9
|
import type { DerivedOperation, DerivedSurface } from './derived.ts';
|
|
10
10
|
import { verifyEvent, type EventScheme } from './events.ts';
|
|
@@ -181,10 +181,20 @@ const fixedHeaders = (op: DerivedOperation): Record<string, string> => Object.fr
|
|
|
181
181
|
|
|
182
182
|
/** The body as the operation's encoding sends it. */
|
|
183
183
|
function encode(op: DerivedOperation, body: unknown): { body?: string; type?: string } {
|
|
184
|
-
|
|
184
|
+
// a body the request carried where the spec declares none (Tinybird's Events API, NDJSON its client sends as text):
|
|
185
|
+
// the body as recorded, as the vendor read it
|
|
186
|
+
if (body === undefined) return {};
|
|
185
187
|
if (op.bodyEncoding === 'form') {
|
|
188
|
+
// a form's nested values in the bracket form a form-encoded API reads (`scope[type]=account`, `items[0][price]=…`),
|
|
189
|
+
// the form the kernel's own parseBracketForm reads back; never a value's JSON text
|
|
186
190
|
const form = new URLSearchParams();
|
|
187
|
-
|
|
191
|
+
const put = (key: string, v: unknown): void => {
|
|
192
|
+
if (v === undefined || v === null) return;
|
|
193
|
+
if (Array.isArray(v)) { v.forEach((item, i) => put(`${key}[${i}]`, item)); return; }
|
|
194
|
+
if (typeof v === 'object') { for (const [k, x] of Object.entries(v as Record<string, unknown>)) put(`${key}[${k}]`, x); return; }
|
|
195
|
+
form.append(key, String(v));
|
|
196
|
+
};
|
|
197
|
+
for (const [k, v] of Object.entries(object(body) ?? {})) put(k, v);
|
|
188
198
|
return { body: form.toString(), type: 'application/x-www-form-urlencoded' };
|
|
189
199
|
}
|
|
190
200
|
return { body: JSON.stringify(body), type: 'application/json' };
|
|
@@ -249,8 +259,14 @@ async function graphqlAnswers(m: DerivedManifest, surface: DerivedSurface, input
|
|
|
249
259
|
/** The account a root is, before a perform sends a path naming the World's ("The account a root is"): the World's
|
|
250
260
|
* account its own doors made, unadopted, adopts the one account the root's credential lists. Read once per deploy (an
|
|
251
261
|
* adopted account is not asked again); a list the vendor refuses, or one naming several accounts, adopts none. */
|
|
262
|
+
/** The accounts a root's list already named as the World's own (the World's id is the vendor's), by service and root:
|
|
263
|
+
* nothing to adopt, so not asked again. */
|
|
264
|
+
const accountsMatched = new Map<string, Set<string>>();
|
|
265
|
+
|
|
252
266
|
async function adoptAccount(units: Unit[], execute: RemoteExecute, ctx: PerformContext): Promise<void> {
|
|
253
267
|
if (ctx.service === undefined) return;
|
|
268
|
+
const matched = accountsMatched.get(`${ctx.service}\u0000${ctx.root ?? ''}`) ?? new Set<string>();
|
|
269
|
+
accountsMatched.set(`${ctx.service}\u0000${ctx.root ?? ''}`, matched);
|
|
254
270
|
for (const u of units) {
|
|
255
271
|
const m = u.manifest;
|
|
256
272
|
const decl = m.account ? m.resources[m.account] : undefined;
|
|
@@ -259,11 +275,12 @@ async function adoptAccount(units: Unit[], execute: RemoteExecute, ctx: PerformC
|
|
|
259
275
|
const local = (readTree(ctx.service, ctx.root) as Row[]).filter((r) => r.type === type && r.deleted !== true && ctx.resolve(type, String(r.id)) === String(r.id));
|
|
260
276
|
const scope = decl.refresh;
|
|
261
277
|
const op = scope && 'list' in scope ? u.surface.operations.find((o) => o.id === scope.list) : undefined;
|
|
262
|
-
if (!local.length || !op) continue;
|
|
278
|
+
if (!local.length || !op || local.every((l) => matched.has(`${type}:${String(l.id)}`))) continue;
|
|
263
279
|
const answer = await execute({ method: op.method.toUpperCase(), path: sentTo(m, marked(op, `${op.basePath ?? u.surface.basePath ?? ''}${op.path}`), {}), ...(u.lane ? { lane: u.lane } : {}), headers: fixedHeaders(op) });
|
|
264
280
|
if (answer.status >= 400) continue;
|
|
265
281
|
const items = itemsOf(op, parse(answer));
|
|
266
282
|
const id = items.length === 1 ? storedIdOf(decl, items[0]!) : undefined;
|
|
283
|
+
if (id !== undefined && local.some((l) => String(l.id) === id)) matched.add(`${type}:${id}`);
|
|
267
284
|
if (id === undefined || local.some((l) => String(l.id) === id)) continue;
|
|
268
285
|
observeResources(ctx.service, [{ type, id, fields: items[0] as ObservedResource['fields'], adopts: String(local[0]!.id) }], ctx.root !== undefined ? { root: ctx.root } : {});
|
|
269
286
|
}
|
|
@@ -469,8 +486,25 @@ function scopeIdOf(scope: object, fields: Record<string, unknown>): string | und
|
|
|
469
486
|
return value === undefined || value === null || value === '' ? undefined : String(value);
|
|
470
487
|
}
|
|
471
488
|
|
|
489
|
+
/** A refresh larger than one window of the vendor's rate budget paces itself: a call the budget refuses before making
|
|
490
|
+
* it (RateBudgetError) waits the time the refusal names, at most two minutes a wait, and is made then. The budget is
|
|
491
|
+
* never exceeded and no vendor answer is retried; without it a refresh needing more than one window never finishes
|
|
492
|
+
* (Slack's, after a deploy spent the window: every rerun refused again from the start). */
|
|
493
|
+
function paced(execute: RemoteExecute): RemoteExecute {
|
|
494
|
+
return async (request) => {
|
|
495
|
+
for (let waits = 0; ; waits++) {
|
|
496
|
+
try { return await execute(request); } catch (error) {
|
|
497
|
+
const wait = (error as { name?: string; retryAfterMs?: number }).name === 'RateBudgetError' ? (error as { retryAfterMs: number }).retryAfterMs : undefined;
|
|
498
|
+
if (wait === undefined || wait > 120_000 || waits >= 60) throw error;
|
|
499
|
+
await new Promise((done) => setTimeout(done, wait + 250));
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
472
505
|
function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapters['refresh']> {
|
|
473
|
-
return async (
|
|
506
|
+
return async (unpaced, opts) => {
|
|
507
|
+
const execute = paced(unpaced);
|
|
474
508
|
const observed: ObservedResource[] = [];
|
|
475
509
|
const complete: Completed[] = [];
|
|
476
510
|
// what the root's credential may not read (a token scoped to some of the account): left as the World holds it, said
|
|
@@ -478,6 +512,16 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
|
|
|
478
512
|
let answered = 0;
|
|
479
513
|
for (const { manifest: m, surface, lane } of units) {
|
|
480
514
|
const onLane = lane ? { lane } : {};
|
|
515
|
+
const tenantBinding: Record<string, unknown> = {};
|
|
516
|
+
if (m.refreshIdentity) {
|
|
517
|
+
const identity = surface.operations.find(operation => operation.id === m.refreshIdentity!.operation);
|
|
518
|
+
if (!m.tenant || !identity || identity.method !== 'get' || identity.path.includes('{')) throw new Error(`${m.vendor}: refresh identity needs a parameterless GET and declared tenant field`);
|
|
519
|
+
const answer = await execute({ method: 'GET', path: `${identity.basePath ?? surface.basePath ?? ''}${identity.path}`, ...onLane, headers: fixedHeaders(identity) });
|
|
520
|
+
if (answer.status >= 400 || refusedIn(m, parse(answer))) throw new Error(`${m.vendor}: root account identity refused; nothing folded`);
|
|
521
|
+
const tenant = at(parse(answer), m.refreshIdentity.field);
|
|
522
|
+
if (typeof tenant !== 'string' || !tenant) throw new Error(`${m.vendor}: root account identity is absent; nothing folded`);
|
|
523
|
+
tenantBinding[m.tenant.field] = tenant;
|
|
524
|
+
}
|
|
481
525
|
// parents before children, however deep
|
|
482
526
|
const scoped = Object.entries(m.resources).filter(([, d]) => d.refresh && !('none' in d.refresh));
|
|
483
527
|
const depth = (r: string, seen = new Set<string>()): number => { const p = m.resources[r]?.parent?.resource; return p && !seen.has(p) ? 1 + depth(p, seen.add(r)) : 0; };
|
|
@@ -485,6 +529,7 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
|
|
|
485
529
|
for (const [resource, decl] of order) {
|
|
486
530
|
const scope = decl.refresh!;
|
|
487
531
|
if ('none' in scope) continue;
|
|
532
|
+
if (m.refreshIdentity && (!('list' in scope) || 'graphql' in scope)) throw new Error(`${m.vendor}: account-bound refresh requires REST list scopes`);
|
|
488
533
|
if ('graphql' in scope && scope.graphql) {
|
|
489
534
|
await refreshGraphql(m, resource, decl, scope, execute, opts.scope, observed, complete);
|
|
490
535
|
answered += 1;
|
|
@@ -501,6 +546,16 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
|
|
|
501
546
|
if ('get' in scope && scope.known) {
|
|
502
547
|
const aliases = aliasesFrom(parentEntries(service, opts.root));
|
|
503
548
|
const held = [...readParentTreeMap(service, opts.root)].filter(([, r]) => r.type === type && r.fields.deleted !== true);
|
|
549
|
+
// and each a door made (a person's act on the vendor's site: a group they add the bot to), which the vendor holds
|
|
550
|
+
// as the World does though no deploy landed it
|
|
551
|
+
const rooted = new Set(held.map(([k]) => k));
|
|
552
|
+
const doorMade = new Set(wholeLog(service, opts.root).filter((e) => e.subject.type === type && (e.input as Row | undefined)?.door !== undefined).map((e) => String(e.subject.id)));
|
|
553
|
+
for (const r of readTree(service, opts.root) as Row[]) {
|
|
554
|
+
const key = `${type}:${String(r.id)}`;
|
|
555
|
+
if (r.type !== type || r.deleted === true || !doorMade.has(String(r.id)) || held.some(([k]) => k === key)) continue;
|
|
556
|
+
const { type: _t, id, ...fields } = r;
|
|
557
|
+
held.push([key, { type, id: String(id), fields } as (typeof held)[number][1]]);
|
|
558
|
+
}
|
|
504
559
|
const read: string[] = [];
|
|
505
560
|
// a 403 leaves the type as the World holds it, never complete
|
|
506
561
|
let refused = false;
|
|
@@ -523,12 +578,15 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
|
|
|
523
578
|
const target = marked(op, path);
|
|
524
579
|
const sent = asked.length ? `${target}${target.includes('?') ? '&' : '?'}${new URLSearchParams(asked)}` : target;
|
|
525
580
|
const answer = await execute({ method: op.method.toUpperCase(), path: sentTo(m, sent, subject), ...onLane, headers: fixedHeaders(op) });
|
|
581
|
+
// a door's subject the vendor does not show the root (another bot's private chat) stays as the World holds it
|
|
582
|
+
if (!rooted.has(key) && answer.status >= 400 && answer.status !== 429) continue;
|
|
526
583
|
if (answer.status === 401) throw new Error(`401: the vendor refused the root's credential (${op.method.toUpperCase()} ${path}); nothing folded`);
|
|
527
584
|
if (answer.status === 403) { unread.push({ type, path, status: 403 }); refused = true; break; }
|
|
528
585
|
if (answer.status === 429) throw new Error(`429: rate limited${answer.headers['retry-after'] ? `, retry after ${answer.headers['retry-after']}s` : ''}`);
|
|
529
586
|
read.push(String(subject.id));
|
|
530
587
|
answered += 1;
|
|
531
|
-
|
|
588
|
+
// the vendor's answer for a subject it no longer has: a 404, or the scope's `gone` (Telegram's 400 `chat not found`)
|
|
589
|
+
if (answer.status === 404 || (scope.gone && answer.status === scope.gone.status && refusalWords(m, parse(answer)).includes(scope.gone.says))) continue;
|
|
532
590
|
if (answer.status >= 400 || refusedIn(m, parse(answer))) throw new Error(`${m.vendor}: ${resource} refresh ${op.id} (${op.method.toUpperCase()} ${op.path}): ${answer.status}: ${refusalWords(m, parse(answer))}`);
|
|
533
591
|
const full = object(op.answers?.key ? at(parse(answer), op.answers.key) : parse(answer)) ?? {};
|
|
534
592
|
const local = (m as { worldFields?: Record<string, Record<string, unknown>> }).worldFields?.[resource] ?? {};
|
|
@@ -577,9 +635,12 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
|
|
|
577
635
|
: decl.parent?.params?.length && under ? Object.fromEntries(Object.entries(under.params).filter(([n]) => !op.path.includes(`{${n}}`) && (op.query ?? []).some((x) => x.name === n))) : {};
|
|
578
636
|
// a list asked by POST carries its parent in its body too, unless the operation reads it from its query
|
|
579
637
|
const parentInQuery = !rpc || (op.query ?? []).some((x) => x.name === decl.parent?.param);
|
|
580
|
-
|
|
638
|
+
// a parameter the operation declares in its query, and not its body, goes in the query of a list asked by POST
|
|
639
|
+
// too (TikTok's video list: `fields` in the query, its cursor in the body)
|
|
640
|
+
const inQuery = (n: string): boolean => (op.query ?? []).some((x) => x.name === n) && !(op.body ?? []).some((b) => b.name === n);
|
|
641
|
+
const q = new URLSearchParams(rpc ? { ...Object.fromEntries(Object.entries(page).filter(([k]) => inQuery(k))), ...(parentInQuery ? byQuery : {}) } : { ...page, ...byQuery });
|
|
581
642
|
// Fixed parameters and cursors take the operation's body types, not their manifest string encoding.
|
|
582
|
-
const typedPage = Object.fromEntries(Object.entries(page).map(([k, v]) => {
|
|
643
|
+
const typedPage = Object.fromEntries(Object.entries(page).filter(([k]) => !rpc || !inQuery(k)).map(([k, v]) => {
|
|
583
644
|
const type = (op.body ?? []).find((b) => b.name === k)?.type;
|
|
584
645
|
const value = type === 'integer' ? exactInteger(v) : type === 'number' ? Number(v)
|
|
585
646
|
: type === 'boolean' && (v === 'true' || v === 'false') ? v === 'true' : v;
|
|
@@ -631,9 +692,12 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
|
|
|
631
692
|
if ('list' in scope && scope.detail) {
|
|
632
693
|
const detail = surface.operations.find((o) => o.id === scope.detail);
|
|
633
694
|
if (!detail) throw new Error(`${m.vendor}: ${resource}'s refresh detail names ${scope.detail}, no operation of the surface`);
|
|
634
|
-
const id = mapId ?? storedIdOf(decl, item);
|
|
695
|
+
const id = mapId ?? storedIdOf(decl, { ...under?.params, ...item });
|
|
696
|
+
// the item's own id names it in the detail's path where its stored id is a composite key (a Cloudflare
|
|
697
|
+
// script stored `<account>/<name>` is read at `/scripts/{script_name}` by its name)
|
|
698
|
+
const own = item[decl.idAs ?? 'id'];
|
|
635
699
|
const path = `${detail.basePath ?? surface.basePath ?? ''}${detail.path}`.replace(/\{([^}]+)\}/g, (_, n: string) => {
|
|
636
|
-
const value = under?.params[n] ?? item[n] ?? id;
|
|
700
|
+
const value = under?.params[n] ?? item[n] ?? (decl.key && (typeof own === 'string' || typeof own === 'number') ? own : id);
|
|
637
701
|
if (value === undefined) throw new Error(`${m.vendor}: ${resource}'s refresh detail has no value for ${n}`);
|
|
638
702
|
return encodeURIComponent(String(value));
|
|
639
703
|
});
|
|
@@ -655,8 +719,10 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
|
|
|
655
719
|
// twin_local_ports) is the answering World's, never the vendor's, and is not folded in
|
|
656
720
|
const local = (m as { worldFields?: Record<string, Record<string, unknown>> }).worldFields?.[resource] ?? {};
|
|
657
721
|
const vendorShape = Object.fromEntries(Object.entries(full).filter(([k]) => !(k in local)));
|
|
658
|
-
const fields = { ...vendorShape, ...kept, ...(decl.parent && parent !== undefined ? { [decl.parent.field]: parent } : {}) };
|
|
659
|
-
|
|
722
|
+
const fields = { ...vendorShape, ...(!decl.shared ? tenantBinding : {}), ...kept, ...(decl.parent && parent !== undefined ? { [decl.parent.field]: parent } : {}) };
|
|
723
|
+
// a key part the item does not carry is the path parameter its parent was listed under (Cloudflare's secret,
|
|
724
|
+
// `{account_id}/{script_name}::{name}`, listed as its name and type alone)
|
|
725
|
+
const id = mapId ?? scopeIdOf(scope, fields) ?? storedIdOf(decl, { ...under?.params, ...fields });
|
|
660
726
|
if (id === undefined) continue;
|
|
661
727
|
observed.push({ type, id, fields: fields as ObservedResource['fields'] });
|
|
662
728
|
}
|
|
@@ -717,7 +783,7 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
|
|
|
717
783
|
// a type under a parent is whole only for the parents observed: what it held under another parent stands. A root
|
|
718
784
|
// scoped to one resource (`RootConfig.scope`, GitHub's `repos/o/r`) lists only what is under it, so a type with no
|
|
719
785
|
// parent is never the account's whole list there
|
|
720
|
-
if (whole && !decl.parent && !opts.scope) complete.push(type);
|
|
786
|
+
if (whole && !decl.parent && !opts.scope) complete.push(m.refreshIdentity && m.tenant && !decl.shared ? { type, field: m.tenant.field, within: [String(tenantBinding[m.tenant.field])] } : type);
|
|
721
787
|
else if (whole && parents.length && decl.parent) complete.push({ type, field: decl.parent.field, within: parents.map((p) => p!.value) });
|
|
722
788
|
}
|
|
723
789
|
}
|
|
@@ -814,7 +880,7 @@ async function refreshGraphql(m: DerivedManifest, resource: string, decl: Resour
|
|
|
814
880
|
const items = listed(value).map(object).filter((x): x is Row => x !== undefined);
|
|
815
881
|
for (const item of items) {
|
|
816
882
|
const fields = { ...item, ...(decl.parent && under ? { [decl.parent.field]: under.value } : {}) };
|
|
817
|
-
const id = storedIdOf(decl, fields);
|
|
883
|
+
const id = storedIdOf(decl, { ...under?.params, ...fields });
|
|
818
884
|
if (id !== undefined) observed.push({ type, id, fields: fields as ObservedResource['fields'] });
|
|
819
885
|
}
|
|
820
886
|
taken += items.length;
|
package/src/derived.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { parseExactJson } from './exact-json.ts';
|
|
|
9
9
|
import type { WebSocketUpgrade } from './serve-http.ts';
|
|
10
10
|
import type { TwinStream } from './twin-fetch.ts';
|
|
11
11
|
import { runWithRequestTrace } from './trace-context.ts';
|
|
12
|
+
import { redisServiceTimes } from './redis/service-times.ts';
|
|
12
13
|
|
|
13
14
|
/** The slice of a generated surface the dispatch reads. */
|
|
14
15
|
export type DerivedOperation = {
|
|
@@ -29,6 +30,8 @@ export type DerivedOperation = {
|
|
|
29
30
|
emptySuccess?: boolean;
|
|
30
31
|
/** a form-encoded operation's number and boolean fields, by bracket path (`items.[].quantity`, `metadata.*`) */
|
|
31
32
|
scalars?: Readonly<Record<string, string | undefined>>;
|
|
33
|
+
/** a form-encoded operation's string fields that take only listed values, by the same bracket path */
|
|
34
|
+
enums?: Readonly<Record<string, ReadonlyArray<string>>>;
|
|
32
35
|
/** what the success answer is: a resource, a list of them, and the envelope key holding it */
|
|
33
36
|
answers?: { resource: string; list: boolean; key?: string };
|
|
34
37
|
/** the path its own server puts before it, where the operation names a server other than the surface's */
|
|
@@ -47,7 +50,10 @@ export type DerivedOperation = {
|
|
|
47
50
|
export type DerivedSurface = { graphql?: { sdl: string }; format?: string; commands?: ReadonlyArray<{ id: string }>; version: string; basePath?: string; spanning?: ReadonlyArray<string>; operations: ReadonlyArray<DerivedOperation> };
|
|
48
51
|
|
|
49
52
|
/** What a semantics handler receives: the request, the operation it matched, and its path parameters. */
|
|
50
|
-
export type DerivedCall = { request: Request; operation: DerivedOperation; params: Record<string, string
|
|
53
|
+
export type DerivedCall = { request: Request; operation: DerivedOperation; params: Record<string, string>;
|
|
54
|
+
/** a World screen's call, which no perform sends again: a body in a media type the core does not read (anything
|
|
55
|
+
* but JSON, a form, multipart or text) is the handler's alone, left unread and unrecorded, so an upload streams */
|
|
56
|
+
streamed?: true };
|
|
51
57
|
|
|
52
58
|
/**
|
|
53
59
|
* A request's body as text, read once per request: the dispatch reads it and tags the request with it, and every clone
|
|
@@ -285,7 +291,9 @@ export function createDerivedFetch(options: DerivedFetchOptions): DerivedFetch {
|
|
|
285
291
|
if (!matched) return fallback(request, undefined, 'no operation at this path');
|
|
286
292
|
for (const observer of dispatchObservers ?? []) observer({ surface: options.surface, operation: matched.operation, owner: handlers[matched.operation.id] ? 'handler' : options.core?.owns(matched.operation) ? 'core' : 'gap' });
|
|
287
293
|
// the body read once, as text, for everything the dispatch does with it (bodyTextOf)
|
|
294
|
+
redisServiceTimes.lap('6a around entered, guards and scenario passed, operation matched again');
|
|
288
295
|
if (request.method !== 'GET' && request.method !== 'HEAD' && request.body && !(request.headers.get('content-type') ?? '').includes('multipart')) tagBody(request, await request.clone().text());
|
|
296
|
+
redisServiceTimes.lap('6b the dispatch read the body as text');
|
|
289
297
|
// the path prefix's named groups (pack-fetch.ts, `pathPrefix`) are the call's parameters too
|
|
290
298
|
const prefixed = request.headers.get(PREFIX_PARAMS_HEADER);
|
|
291
299
|
const call: DerivedCall = { request, operation: matched.operation, params: prefixed ? { ...(parseExactJson(prefixed) as Record<string, string>), ...matched.params } : matched.params };
|
package/src/executor.ts
CHANGED
|
@@ -78,6 +78,10 @@ export type TwinAuthStrategy =
|
|
|
78
78
|
* it replaces. The kernel keeps the new value for its next exchange and hands the updated credential to the
|
|
79
79
|
* root's custody to seal (`onRotate`), so the root outlives the first refresh token. */
|
|
80
80
|
rotate?: { answer: string; credential: string };
|
|
81
|
+
/** The answer's field giving the token's lifetime in seconds (a dotted path; OAuth's `expires_in`): a token held past
|
|
82
|
+
* it is exchanged again before the next call. For a vendor that answers an expired token with no `retryOn` status
|
|
83
|
+
* (TikTok's API for Business answers HTTP 200 with a code), and so a long-lived process never sends a dead token. */
|
|
84
|
+
expiresIn?: string;
|
|
81
85
|
};
|
|
82
86
|
|
|
83
87
|
async function oauth1SignedHeader(credential: CredentialPayload, method: string, target: URL, headers: Headers, body?: string | Uint8Array): Promise<string> {
|
|
@@ -248,7 +252,9 @@ async function awsSigV4Headers(input: {
|
|
|
248
252
|
* refused, as before. */
|
|
249
253
|
/** Where a credential is kept (the head's and refresh's root: head.ts rootCustody): `key` names the credential stably
|
|
250
254
|
* across rotations and anew when another is sealed in its place (the token cache is keyed by it, not by the secret's value), `open` reads it as sealed now, `seal` keeps a rotated one. */
|
|
251
|
-
|
|
255
|
+
/** `unsealed`: where a rotation the seal failed to keep is recorded durably (the field and the cause, never a value), since
|
|
256
|
+
* the root cannot renew once the process holding the new secret ends; a later successful seal clears it. */
|
|
257
|
+
export type CredentialCustody = { key: string; open: () => Promise<CredentialPayload>; seal: (credential: CredentialPayload) => Promise<void> | void; unsealed?: (field: string, cause: string) => void };
|
|
252
258
|
/** `vendor`: the vendor whose rate budget every call is charged to (D8): the call is priced and reserved before it is
|
|
253
259
|
* made, refused when the budget is spent, and settled with the vendor's answer (its back-off honoured). */
|
|
254
260
|
/** `now`: the instant a signature is made at (the wall clock unless given: an acceptance whose vendor is a fixture World
|
|
@@ -418,7 +424,7 @@ function direct(origin: URL, credential: CredentialPayload, auth?: Exclude<TwinA
|
|
|
418
424
|
/** Tokens the exchange strategy holds, per root and credential, for the life of the process: an executor
|
|
419
425
|
* is built per deploy and per refresh, so a token kept only inside one would cost a fresh exchange on every
|
|
420
426
|
* write (Bluesky allows 30 sessions in five minutes). In memory only; never written anywhere. */
|
|
421
|
-
const exchangedTokens = new Map<string, { token: Promise<string
|
|
427
|
+
const exchangedTokens = new Map<string, { token: Promise<string>; expiresAt?: number }>();
|
|
422
428
|
/** A rotated credential a seal failed to keep, per custody key: the process spends it (never the spent one the file
|
|
423
429
|
* still holds) until a seal succeeds or the file moves past the value it replaced. In memory only. */
|
|
424
430
|
const unsealedRotations = new Map<string, { replaced: string; credential: CredentialPayload }>();
|
|
@@ -442,6 +448,7 @@ function exchangeExecute(origin: URL, sealed: CredentialPayload, auth: Extract<T
|
|
|
442
448
|
return held;
|
|
443
449
|
};
|
|
444
450
|
const key = custody ? `${origin.href}\n${custody.key}` : `${origin.href}\n${JSON.stringify(sealed)}`;
|
|
451
|
+
let expiresAt: number | undefined;
|
|
445
452
|
const exchange = async (): Promise<string> => {
|
|
446
453
|
// a rotating secret is read as kept NOW (another executor or process may have rotated it since this one was
|
|
447
454
|
// built), or as this process holds it when its seal failed and the file still holds the spent value. Two
|
|
@@ -473,6 +480,11 @@ function exchangeExecute(origin: URL, sealed: CredentialPayload, auth: Extract<T
|
|
|
473
480
|
const code = parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>).error : undefined;
|
|
474
481
|
throw new Error(`remote execute: the token exchange answered no \`${auth.token}\`${typeof code === 'string' && code !== '' ? ` (${code.slice(0, 120)})` : ''}`);
|
|
475
482
|
}
|
|
483
|
+
if (auth.expiresIn) {
|
|
484
|
+
const seconds = Number(auth.expiresIn.split('.').reduce<unknown>((at, k) => (at && typeof at === 'object' ? (at as Record<string, unknown>)[k] : undefined), parsed));
|
|
485
|
+
// a minute short of the stated lifetime, so a call in flight never carries a token that dies on the way
|
|
486
|
+
expiresAt = Number.isFinite(seconds) && seconds > 0 ? Date.now() + Math.max(0, seconds - 60) * 1000 : undefined;
|
|
487
|
+
}
|
|
476
488
|
if (auth.rotate) {
|
|
477
489
|
const next = auth.rotate.answer.split('.').reduce<unknown>((at, k) => (at && typeof at === 'object' ? (at as Record<string, unknown>)[k] : undefined), parsed);
|
|
478
490
|
if (typeof next === 'string' && next !== '' && next !== credential[auth.rotate.credential]) {
|
|
@@ -483,6 +495,7 @@ function exchangeExecute(origin: URL, sealed: CredentialPayload, auth: Extract<T
|
|
|
483
495
|
if (custody) {
|
|
484
496
|
try { await custody.seal(credential); unsealedRotations.delete(key); } catch (error) {
|
|
485
497
|
unsealedRotations.set(key, { replaced, credential });
|
|
498
|
+
try { custody.unsealed?.(auth.rotate.credential, error instanceof Error ? error.message : String(error)); } catch { /* the stderr line below still says it */ }
|
|
486
499
|
console.error(`remote execute: the vendor rotated \`${auth.rotate.credential}\` and sealing the new one failed (${error instanceof Error ? error.message : String(error)}); this process holds it until a seal succeeds`);
|
|
487
500
|
}
|
|
488
501
|
}
|
|
@@ -492,12 +505,13 @@ function exchangeExecute(origin: URL, sealed: CredentialPayload, auth: Extract<T
|
|
|
492
505
|
};
|
|
493
506
|
const current = (): { token: Promise<string> } => {
|
|
494
507
|
let held = exchangedTokens.get(key);
|
|
508
|
+
if (held?.expiresAt !== undefined && Date.now() >= held.expiresAt) { exchangedTokens.delete(key); held = undefined; }
|
|
495
509
|
if (!held) {
|
|
496
510
|
const token = exchange();
|
|
497
511
|
held = { token };
|
|
498
512
|
exchangedTokens.set(key, held);
|
|
499
513
|
const entry = held;
|
|
500
|
-
token.
|
|
514
|
+
token.then(() => { entry.expiresAt = expiresAt; }, () => { if (exchangedTokens.get(key) === entry) exchangedTokens.delete(key); });
|
|
501
515
|
}
|
|
502
516
|
return held;
|
|
503
517
|
};
|
package/src/fork.ts
CHANGED
|
@@ -76,7 +76,7 @@ export function forkTwin(opts: {
|
|
|
76
76
|
};
|
|
77
77
|
getActiveWorldStore().write(forkMetaPath(service, toRoot), `${JSON.stringify(meta, null, 2)}\n`);
|
|
78
78
|
return meta;
|
|
79
|
-
});
|
|
79
|
+
}, { operation: 'pointer publication' });
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
export function readForkMeta(service: string, root: string): ForkMeta {
|
package/src/git/lfs.ts
CHANGED
|
@@ -9,7 +9,14 @@ import { exactInteger } from '../exact-json.ts';
|
|
|
9
9
|
export const LFS_TYPE = 'application/vnd.git-lfs+json';
|
|
10
10
|
export type LfsObject = { oid: string; size: number | string };
|
|
11
11
|
/** Where a pack keeps its objects: by oid. */
|
|
12
|
-
|
|
12
|
+
/** Where a server keeps its LFS objects: an object's size when it holds one (the batch API answers from it, never
|
|
13
|
+
* loading the object), and the store an upload's verified bytes go to. */
|
|
14
|
+
export type LfsStore = {
|
|
15
|
+
size(oid: string): Promise<number | null>; put(oid: string, bytes: Uint8Array): Promise<void>;
|
|
16
|
+
/** an upload's body written as it arrives and kept only when `accept` holds for its size and SHA-256 (`ctx.blobs.putStream`):
|
|
17
|
+
* a store with it never holds an object whole */
|
|
18
|
+
putStream?(oid: string, chunks: AsyncIterable<Uint8Array>, accept: (written: { size: number; sha256: string }) => boolean): Promise<boolean>;
|
|
19
|
+
};
|
|
13
20
|
|
|
14
21
|
const OID = /^[0-9a-f]{64}$/;
|
|
15
22
|
const lfsJson = (status: number, body: unknown): Response => Response.json(body, { status, headers: { 'content-type': LFS_TYPE } });
|
|
@@ -48,21 +55,45 @@ export async function lfsBatch(body: unknown, opts: { store: LfsStore; href: (op
|
|
|
48
55
|
const o = { oid: String(raw?.oid ?? ''), size: /^-?\d+$/.test(String(raw?.size)) ? exactInteger(String(raw?.size)) : Number(raw?.size) };
|
|
49
56
|
// source: https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md "The object is invalid (for example, an invalid oid or size)."
|
|
50
57
|
if (!OID.test(o.oid) || !Number.isSafeInteger(o.size) || Number(o.size) < 0) { objects.push({ ...o, error: { code: 422, message: 'Invalid object' } }); continue; }
|
|
51
|
-
const held = await opts.store.
|
|
58
|
+
const held = await opts.store.size(o.oid);
|
|
52
59
|
if (operation === 'upload') {
|
|
53
|
-
objects.push(held ? { ...o, authenticated: true } : { ...o, authenticated: true, actions: { upload: { href: opts.href('upload', o), ...(opts.header ? { header: opts.header } : {}), expires_in: 3600 } } });
|
|
60
|
+
objects.push(held !== null ? { ...o, authenticated: true } : { ...o, authenticated: true, actions: { upload: { href: opts.href('upload', o), ...(opts.header ? { header: opts.header } : {}), expires_in: 3600 } } });
|
|
54
61
|
} else {
|
|
55
62
|
// source: https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md "The object does not exist on the server."
|
|
56
|
-
objects.push(held ? { oid: o.oid, size: held
|
|
63
|
+
objects.push(held !== null ? { oid: o.oid, size: held, authenticated: true, actions: { download: { href: opts.href('download', o), ...(opts.header ? { header: opts.header } : {}), expires_in: 3600 } } } : { ...o, error: { code: 404, message: 'Object does not exist' } });
|
|
57
64
|
}
|
|
58
65
|
}
|
|
59
66
|
return lfsJson(200, { transfer: 'basic', objects, hash_algo: 'sha256' });
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
/** A basic transfer's upload (`PUT` of the object's bytes): kept when they hash to the oid and have the declared size
|
|
63
|
-
* (the size is the request's Content-Length when the caller has no other), else refused and nothing kept.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
70
|
+
* (the size is the request's Content-Length when the caller has no other), else refused and nothing kept. A body
|
|
71
|
+
* stream goes to the store's `putStream` as it arrives, read no further than the declared size; bytes, or a store
|
|
72
|
+
* without one, are checked whole. */
|
|
73
|
+
export async function lfsUpload(body: Uint8Array | ReadableStream<Uint8Array> | null, o: LfsObject, store: LfsStore): Promise<Response> {
|
|
74
|
+
const refused = (): Response => lfsJson(422, { message: `the object's bytes do not match ${o.oid} (${o.size} bytes)` });
|
|
75
|
+
if (body instanceof Uint8Array || body === null || !store.putStream) {
|
|
76
|
+
const bytes = body instanceof Uint8Array ? body : new Uint8Array(body === null ? new ArrayBuffer(0) : await new Response(body).arrayBuffer());
|
|
77
|
+
if (bytes.length !== o.size || (await lfsOid(bytes)) !== o.oid) return refused();
|
|
78
|
+
await store.put(o.oid, bytes);
|
|
79
|
+
return new Response(null, { status: 200 });
|
|
80
|
+
}
|
|
81
|
+
const declared = Number(o.size);
|
|
82
|
+
let over = false;
|
|
83
|
+
async function* upTo(stream: ReadableStream<Uint8Array>): AsyncGenerator<Uint8Array> {
|
|
84
|
+
const reader = stream.getReader();
|
|
85
|
+
let read = 0;
|
|
86
|
+
try {
|
|
87
|
+
for (;;) {
|
|
88
|
+
const { done, value } = await reader.read();
|
|
89
|
+
if (done) return;
|
|
90
|
+
read += value.byteLength;
|
|
91
|
+
// a body longer than declared is refused without reading the rest of it
|
|
92
|
+
if (read > declared) { over = true; await reader.cancel(); return; }
|
|
93
|
+
yield value;
|
|
94
|
+
}
|
|
95
|
+
} finally { reader.releaseLock(); }
|
|
96
|
+
}
|
|
97
|
+
const kept = await store.putStream(o.oid, upTo(body), (w) => !over && w.size === declared && w.sha256 === o.oid);
|
|
98
|
+
return kept ? new Response(null, { status: 200 }) : refused();
|
|
68
99
|
}
|
package/src/grpc-wire.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { parseExactJson } from './exact-json.ts';
|
|
|
7
7
|
// and what they answer is the pack's. Importing it does nothing.
|
|
8
8
|
import { acceptH2Connection, type H2Handler } from './h2.ts';
|
|
9
9
|
import { nodeBuiltin, WORLD_BOOT_PATH } from './serve-http.ts';
|
|
10
|
+
import { worldBootAnswer } from './world-boot.ts';
|
|
10
11
|
import { decodeMessage, encodeMessage, type ProtoSchema } from './protobuf.ts';
|
|
11
12
|
|
|
12
13
|
/** gRPC's status codes (https://grpc.github.io/grpc/core/md_doc_statuscodes.html). */
|
|
@@ -151,7 +152,7 @@ export function grpcOverFetch(fetch: (request: Request) => Promise<Response>): G
|
|
|
151
152
|
export async function serveGrpc(options: { port?: number; fetch: (request: Request) => Promise<Response>; schema: ProtoSchema }): Promise<{ port: number; stop: () => void }> {
|
|
152
153
|
const net = nodeBuiltin<typeof import('node:net')>('node:net');
|
|
153
154
|
const worldBoot = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env?.VOLTER_WORLD_BOOT_ID;
|
|
154
|
-
const fetch = worldBoot ? (request: Request) => (new URL(request.url).pathname === WORLD_BOOT_PATH ? Promise.resolve(
|
|
155
|
+
const fetch = worldBoot ? (request: Request) => (new URL(request.url).pathname === WORLD_BOOT_PATH ? Promise.resolve(worldBootAnswer(worldBoot)) : options.fetch(request)) : options.fetch;
|
|
155
156
|
const handler = grpcStreamHandler(options.schema, grpcOverFetch(fetch));
|
|
156
157
|
const server = net.createServer((socket) => { acceptH2Connection(socket, handler, fetch); });
|
|
157
158
|
await new Promise<void>((resolve, reject) => { server.once('error', reject); server.listen(options.port ?? 0, '127.0.0.1', () => resolve()); });
|
package/src/h2.ts
CHANGED
|
@@ -25,6 +25,36 @@ const MAX_CONCURRENT_STREAMS = 1000;
|
|
|
25
25
|
/** a request body larger than this resets its stream: a memory bound, not Temporal's message limit (the
|
|
26
26
|
* SDKs' gRPC default is 4 MiB; this server does not enforce that one) */
|
|
27
27
|
const MAX_REQUEST_BYTES = 64 * 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
/** A chunked body (RFC 9112 §7.1) from `from`: its bytes once the last chunk and the trailer section have arrived,
|
|
30
|
+
* 'incomplete' before, 'bad' for a malformed size line or one past MAX_REQUEST_BYTES. Extensions and trailers are
|
|
31
|
+
* read past and not kept. */
|
|
32
|
+
function chunkedBody(buffer: Buffer, from: number): Uint8Array | 'incomplete' | 'bad' {
|
|
33
|
+
const parts: Buffer[] = [];
|
|
34
|
+
let at = from, total = 0;
|
|
35
|
+
for (;;) {
|
|
36
|
+
const line = buffer.indexOf('\r\n', at);
|
|
37
|
+
if (line < 0) return buffer.length - at > 4096 ? 'bad' : 'incomplete';
|
|
38
|
+
const size = /^([0-9A-Fa-f]{1,8})[ \t]*(?:;.*)?$/su.exec(buffer.subarray(at, line).toString('latin1'));
|
|
39
|
+
if (!size) return 'bad';
|
|
40
|
+
const length = parseInt(size[1]!, 16);
|
|
41
|
+
at = line + 2;
|
|
42
|
+
if (length === 0) break;
|
|
43
|
+
total += length;
|
|
44
|
+
if (total > MAX_REQUEST_BYTES) return 'bad';
|
|
45
|
+
if (buffer.length < at + length + 2) return 'incomplete';
|
|
46
|
+
if (buffer[at + length] !== 0x0d || buffer[at + length + 1] !== 0x0a) return 'bad';
|
|
47
|
+
parts.push(buffer.subarray(at, at + length));
|
|
48
|
+
at += length + 2;
|
|
49
|
+
}
|
|
50
|
+
// The trailer section: field lines up to an empty line.
|
|
51
|
+
for (;;) {
|
|
52
|
+
const line = buffer.indexOf('\r\n', at);
|
|
53
|
+
if (line < 0) return buffer.length - at > 64 * 1024 ? 'bad' : 'incomplete';
|
|
54
|
+
if (line === at) return Buffer.concat(parts);
|
|
55
|
+
at = line + 2;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
28
58
|
/** a header block (HEADERS + CONTINUATION) larger than this closes the connection */
|
|
29
59
|
const MAX_HEADER_BLOCK = 256 * 1024;
|
|
30
60
|
|
|
@@ -293,11 +323,22 @@ class H2Connection {
|
|
|
293
323
|
this.respondHttp1(new Response('bad header', { status: 400 }));
|
|
294
324
|
return;
|
|
295
325
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
326
|
+
// A request with a Transfer-Encoding has a body framed by it, and its Content-Length is not read (RFC 9112 §6.3):
|
|
327
|
+
// undici sends a streamed body (a World's fetch forwarding a request's own body, duplex 'half') chunked.
|
|
328
|
+
const coding = headers.get('transfer-encoding');
|
|
329
|
+
let body: Uint8Array;
|
|
330
|
+
if (coding !== null) {
|
|
331
|
+
if (coding.split(',').map((each) => each.trim().toLowerCase()).at(-1) !== 'chunked') { this.respondHttp1(new Response('a transfer coding other than chunked is not read', { status: 400 })); return; }
|
|
332
|
+
const read = chunkedBody(this.buffer, end + 4);
|
|
333
|
+
if (read === 'bad') { this.respondHttp1(new Response('bad chunked body', { status: 400 })); return; }
|
|
334
|
+
if (read === 'incomplete') return;
|
|
335
|
+
body = read;
|
|
336
|
+
} else {
|
|
337
|
+
const length = Number(headers.get('content-length') ?? 0);
|
|
338
|
+
if (!Number.isFinite(length) || length < 0 || length > MAX_REQUEST_BYTES) { this.respondHttp1(new Response('bad content-length', { status: 400 })); return; }
|
|
339
|
+
if (this.buffer.length < end + 4 + length) return;
|
|
340
|
+
body = this.buffer.subarray(end + 4, end + 4 + length);
|
|
341
|
+
}
|
|
301
342
|
this.buffer = Buffer.alloc(0);
|
|
302
343
|
this.socket.removeAllListeners('data');
|
|
303
344
|
let request: Request;
|
package/src/head.ts
CHANGED
|
@@ -156,7 +156,10 @@ export async function resealRootCredential(root: BoundRoot, payload: CredentialP
|
|
|
156
156
|
// read and write are synchronous: nothing comes between this check and the write
|
|
157
157
|
if (store.read(root.credential) !== held) throw new Error(`the ${vendor} credential was set again while it rotated: the rotation is dropped`);
|
|
158
158
|
store.write(root.credential, `${JSON.stringify({ ...sealed, ...(before ? { fingerprint: before.fingerprint } : {}), rotatedAt: now }, null, 2)}\n`, { secret: true });
|
|
159
|
+
store.remove(unsealedMarkerOf(root));
|
|
159
160
|
}
|
|
161
|
+
/** Beside a root's credential, the record of a rotation its seal failed to keep (`rootCustody`'s `unsealed`). */
|
|
162
|
+
export function unsealedMarkerOf(root: BoundRoot): string { return root.credential.replace(/\.json$/, '.unsealed.json'); }
|
|
160
163
|
/** The root's custody for an exchange that rotates: the credential as sealed now, and the seal of a rotated one, which
|
|
161
164
|
* lands only over the record that was opened. */
|
|
162
165
|
export function rootCustody(root: BoundRoot, vendor: string = vendorOf(root)): CredentialCustody {
|
|
@@ -175,6 +178,9 @@ export function rootCustody(root: BoundRoot, vendor: string = vendorOf(root)): C
|
|
|
175
178
|
return opened.payload;
|
|
176
179
|
},
|
|
177
180
|
seal: (credential) => resealRootCredential(root, credential, vendor, source),
|
|
181
|
+
// the vendor already spent the sealed secret: until a seal lands, the root renews only inside the process holding the
|
|
182
|
+
// new one, so the World keeps a record a person or `doctor` can read after that process ends
|
|
183
|
+
unsealed: (field, cause) => store.write(unsealedMarkerOf(root), `${JSON.stringify({ vendor, field, at: new Date().toISOString(), cause: cause.slice(0, 300) }, null, 2)}\n`),
|
|
178
184
|
};
|
|
179
185
|
}
|
|
180
186
|
/** The vendor a root belongs to: its credential is sealed under the vendor's name. */
|
|
@@ -461,6 +467,15 @@ async function performNow(opts: Parameters<typeof performEntries>[0]): Promise<D
|
|
|
461
467
|
* `auto` is performed now. Returns the vendor's id when it minted one, so the answer carries it.
|
|
462
468
|
* Raises `RefusedWriteError` / `VendorWriteError` for the pack to answer in the vendor's shape.
|
|
463
469
|
*/
|
|
470
|
+
/** Whether `performAtHead` would perform an entry now (a twin whose root says `auto`, outside a pack fetch's deferred
|
|
471
|
+
* group): a caller appending several writes at once appends them one at a time then, each performed before the next. */
|
|
472
|
+
export function performsAtHeadNow(service: string, root?: string): boolean {
|
|
473
|
+
const request = currentCorrelationId();
|
|
474
|
+
if (request !== undefined && deferred.has(request)) return false;
|
|
475
|
+
const head = headOf(service, root);
|
|
476
|
+
return head.kind === 'real' && head.deploy === 'auto';
|
|
477
|
+
}
|
|
478
|
+
|
|
464
479
|
export async function performAtHead(service: string, action: Pick<TwinAction, 'id'>, root?: string): Promise<{ performed: boolean; externalId?: string; data?: unknown }> {
|
|
465
480
|
// a pack fetch performs its request's entries as one group after the handler (performRequestGroup)
|
|
466
481
|
const request = currentCorrelationId();
|
package/src/history.ts
CHANGED
|
@@ -91,14 +91,14 @@ export function saveHistoryView(directory: string, view: HistoryView): string {
|
|
|
91
91
|
getActiveWorldStore().writeAtomic(file, JSON.stringify({ ...view, dependencies }));
|
|
92
92
|
for (const dependency of dependencies) commitParentPin(dependency.directory, file);
|
|
93
93
|
return id;
|
|
94
|
-
});
|
|
94
|
+
}, { operation: 'view pin publication' });
|
|
95
95
|
}
|
|
96
96
|
/** What an inherited history's content stands on, as file facts: every segment file the view names
|
|
97
97
|
* (and the view document itself) and each named directory's ancestry record (its generation). A read cache keyed by this sees an
|
|
98
98
|
* ancestor's retained history truncated, replaced or its directory re-created, which the full read
|
|
99
99
|
* refuses (`historyEntries`), instead of serving a tree folded from the history it replaced. */
|
|
100
|
-
export function inheritedHistoryKey(service: string, root?: string): string {
|
|
101
|
-
const parent = readBranchMeta(service, root)?.parent;
|
|
100
|
+
export function inheritedHistoryKey(service: string, root?: string, meta?: string | null): string {
|
|
101
|
+
const parent = readBranchMeta(service, root, meta)?.parent;
|
|
102
102
|
if (!parent?.view) return '';
|
|
103
103
|
const store = getActiveWorldStore();
|
|
104
104
|
const fact = (path: string): string => { const st = store.stat(path); return st ? `${st.size}:${st.mtimeMs}` : '-'; };
|
|
@@ -136,7 +136,7 @@ export function captureHistory(service: string, root?: string): { view: string;
|
|
|
136
136
|
if (entries.length) descriptor.segments.push({ directory, generation, file, start: 0, count: entries.length, digest: historyDigest(entries) });
|
|
137
137
|
}
|
|
138
138
|
return { view: saveHistoryView(directory, descriptor), position: historyLength(descriptor.layout), descriptor };
|
|
139
|
-
});
|
|
139
|
+
}, { operation: 'capture' });
|
|
140
140
|
}
|
|
141
141
|
export function originHead(service: string, root?: string): { view: string; remoteView: string } | null {
|
|
142
142
|
const raw = getActiveWorldStore().read(join(worldPaths(service, root).dir, 'origin-head.json'));
|
|
@@ -162,7 +162,7 @@ export function publishOriginHistory(service: string, root: string, remoteView:
|
|
|
162
162
|
const view = saveHistoryView(directory, { version: 1, owner: { directory, generation }, layout, segments });
|
|
163
163
|
store.writeAtomic(join(directory, 'origin-head.json'), JSON.stringify({ view, remoteView }));
|
|
164
164
|
return { view, appended };
|
|
165
|
-
});
|
|
165
|
+
}, { operation: 'origin fetch' });
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
/** A changeset binds the complete inherited view, including this root's observations. */
|
|
@@ -171,7 +171,7 @@ export function captureParentHistory(service: string, root?: string): HistoryRef
|
|
|
171
171
|
const head = captureHistory(service, root);
|
|
172
172
|
const position = head.position - head.descriptor.layout.branch;
|
|
173
173
|
return { view: saveHistoryView(worldPaths(service, root).dir, historyPrefix(head.descriptor, position)), position };
|
|
174
|
-
});
|
|
174
|
+
}, { operation: 'capture' });
|
|
175
175
|
}
|
|
176
176
|
/** Compare evaluated views, so removals, reordered rows and layout changes count as drift. */
|
|
177
177
|
export function historyChanges(service: string, before: HistoryView, after: HistoryView): Entry[] {
|
|
@@ -222,7 +222,7 @@ export function historyAtInstant(service: string, instant: string, root?: string
|
|
|
222
222
|
}
|
|
223
223
|
const view = saveHistoryView(directory, { version: 1, owner: source.owner, layout, segments, ...(source.origin ? { origin: { ...source.origin, ...(keep.slice(0, source.origin.position).some(v => !v) ? { incomplete: true } : {}) } } : {}) });
|
|
224
224
|
return { view, position: historyLength(layout) };
|
|
225
|
-
});
|
|
225
|
+
}, { operation: 'capture' });
|
|
226
226
|
}
|
|
227
227
|
|
|
228
228
|
/** Replace only the tracked origin layer; inherited local layers keep their exact projection order. */
|
package/src/index.ts
CHANGED
|
@@ -53,7 +53,7 @@ export { VendorUnreachableError } from './vendor-call.ts';
|
|
|
53
53
|
export { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER } from './request-scope.ts';
|
|
54
54
|
|
|
55
55
|
// ── the serve factory ──────────────────────────────────────────────────────────────────────────
|
|
56
|
-
export { serveHttp, serveStream, StreamListenerUnavailable, type HttpHandler, type HttpServer, type ServeHttpOptions } from './serve-http.ts';
|
|
56
|
+
export { serveHttp, serveStream, nodeBuiltin, StreamListenerUnavailable, type HttpHandler, type HttpServer, type ServeHttpOptions } from './serve-http.ts';
|
|
57
57
|
export type { TwinStream, TwinStreamConnection, TwinStreamSink } from './twin-fetch.ts';
|
|
58
58
|
export { privateTransportKey } from './private-transport.ts';
|
|
59
59
|
// where a page links a hostname its twin answers, inside the World that shows it
|
|
@@ -79,6 +79,8 @@ export type { Batch as EngineBatch, ManagedDatabase, Statement as EngineStatemen
|
|
|
79
79
|
export type { Mail, SmtpRoute } from './smtp.ts';
|
|
80
80
|
// S3's wire (contract "S3's wire is a kernel library too") and the git plane (contract "The git plane is a kernel library")
|
|
81
81
|
export * as s3 from './s3/wire.ts';
|
|
82
|
+
// Generic strict XML grammar; vendor interpretation remains in its consumer.
|
|
83
|
+
export * as xml from './xml.ts';
|
|
82
84
|
export * as clickhouse from './clickhouse/index.ts';
|
|
83
85
|
export { sampleFromSchema } from './schema-sample.ts';
|
|
84
86
|
export * as openaiWire from './openai-wire.ts';
|
|
@@ -91,6 +93,7 @@ export { presignSigV4, signSigV4, verifySigV4, type SigV4Verdict } from './sigv4
|
|
|
91
93
|
export * as redis from './redis/index.ts';
|
|
92
94
|
export * as mysql from './mysql/index.ts';
|
|
93
95
|
export * as live from './live/index.ts';
|
|
96
|
+
export * as mongodb from './mongodb/index.ts';
|
|
94
97
|
export * as git from './git/index.ts';
|
|
95
98
|
|
|
96
99
|
// ── the scenario grammar (architecture, "Behaviour") ───────────────────────────────────────────
|
|
@@ -103,3 +106,6 @@ export { browserSessionToken, startBrowserSession, endBrowserSession } from './b
|
|
|
103
106
|
|
|
104
107
|
export { readHostPort } from './host-port.ts';
|
|
105
108
|
export { assertBrowserName, browserOrigin, validateBrowserState, validateBrowserStorageState, validateBrowserContext, browserAcceptLanguage, cookieMatchesHost, type BrowserCookie, type BrowserState, type BrowserStorageState, type BrowserContext, type BrowserIndexedDB } from './browser-state.ts';
|
|
109
|
+
|
|
110
|
+
export * as managedOriginalInputs from './managed-original-inputs.ts';
|
|
111
|
+
export * as managedGoPrograms from './managed-go-programs.ts';
|