@loomwork/ui-hydration 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +398 -2
- package/dist/cache.d.ts +71 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +157 -0
- package/dist/cache.js.map +1 -0
- package/dist/chatSource.d.ts +105 -0
- package/dist/chatSource.d.ts.map +1 -0
- package/dist/chatSource.js +259 -0
- package/dist/chatSource.js.map +1 -0
- package/dist/client.d.ts +17 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +36 -0
- package/dist/client.js.map +1 -0
- package/dist/contentRefs.d.ts +78 -0
- package/dist/contentRefs.d.ts.map +1 -0
- package/dist/contentRefs.js +129 -0
- package/dist/contentRefs.js.map +1 -0
- package/dist/controller.d.ts +228 -0
- package/dist/controller.d.ts.map +1 -0
- package/dist/controller.js +454 -0
- package/dist/controller.js.map +1 -0
- package/dist/dataProvider.d.ts +53 -0
- package/dist/dataProvider.d.ts.map +1 -0
- package/dist/dataProvider.js +77 -0
- package/dist/dataProvider.js.map +1 -0
- package/dist/esm/cache.js +151 -0
- package/dist/esm/cache.js.map +1 -0
- package/dist/esm/chatSource.js +254 -0
- package/dist/esm/chatSource.js.map +1 -0
- package/dist/esm/client.js +15 -0
- package/dist/esm/client.js.map +1 -0
- package/dist/esm/contentRefs.js +119 -0
- package/dist/esm/contentRefs.js.map +1 -0
- package/dist/esm/controller.js +449 -0
- package/dist/esm/controller.js.map +1 -0
- package/dist/esm/dataProvider.js +72 -0
- package/dist/esm/dataProvider.js.map +1 -0
- package/dist/esm/index.js +15 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/invoker.js +2 -0
- package/dist/esm/invoker.js.map +1 -0
- package/dist/esm/invokers.js +310 -0
- package/dist/esm/invokers.js.map +1 -0
- package/dist/esm/loomEffectProvider.js +96 -0
- package/dist/esm/loomEffectProvider.js.map +1 -0
- package/dist/esm/params.js +207 -0
- package/dist/esm/params.js.map +1 -0
- package/dist/esm/path.js +24 -0
- package/dist/esm/path.js.map +1 -0
- package/dist/esm/plan.js +190 -0
- package/dist/esm/plan.js.map +1 -0
- package/dist/esm/projection.js +230 -0
- package/dist/esm/projection.js.map +1 -0
- package/dist/esm/provider.js +416 -0
- package/dist/esm/provider.js.map +1 -0
- package/dist/esm/repair.js +53 -0
- package/dist/esm/repair.js.map +1 -0
- package/dist/esm/reshape.js +156 -0
- package/dist/esm/reshape.js.map +1 -0
- package/dist/esm/restricted.js +54 -0
- package/dist/esm/restricted.js.map +1 -0
- package/dist/esm/scheduler.js +247 -0
- package/dist/esm/scheduler.js.map +1 -0
- package/dist/esm/select.js +18 -0
- package/dist/esm/select.js.map +1 -0
- package/dist/esm/shape.js +34 -0
- package/dist/esm/shape.js.map +1 -0
- package/dist/esm/split/client.js +309 -0
- package/dist/esm/split/client.js.map +1 -0
- package/dist/esm/split/index.js +3 -0
- package/dist/esm/split/index.js.map +1 -0
- package/dist/esm/split/server.js +488 -0
- package/dist/esm/split/server.js.map +1 -0
- package/dist/esm/split/shared.js +13 -0
- package/dist/esm/split/shared.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +63 -0
- package/dist/index.js.map +1 -0
- package/dist/invoker.d.ts +10 -0
- package/dist/invoker.d.ts.map +1 -0
- package/dist/invoker.js +3 -0
- package/dist/invoker.js.map +1 -0
- package/dist/invokers.d.ts +36 -0
- package/dist/invokers.d.ts.map +1 -0
- package/dist/invokers.js +315 -0
- package/dist/invokers.js.map +1 -0
- package/dist/loomEffectProvider.d.ts +67 -0
- package/dist/loomEffectProvider.d.ts.map +1 -0
- package/dist/loomEffectProvider.js +101 -0
- package/dist/loomEffectProvider.js.map +1 -0
- package/dist/params.d.ts +103 -0
- package/dist/params.d.ts.map +1 -0
- package/dist/params.js +221 -0
- package/dist/params.js.map +1 -0
- package/dist/path.d.ts +12 -0
- package/dist/path.d.ts.map +1 -0
- package/dist/path.js +28 -0
- package/dist/path.js.map +1 -0
- package/dist/plan.d.ts +56 -0
- package/dist/plan.d.ts.map +1 -0
- package/dist/plan.js +199 -0
- package/dist/plan.js.map +1 -0
- package/dist/projection.d.ts +122 -0
- package/dist/projection.d.ts.map +1 -0
- package/dist/projection.js +238 -0
- package/dist/projection.js.map +1 -0
- package/dist/provider.d.ts +152 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +421 -0
- package/dist/provider.js.map +1 -0
- package/dist/repair.d.ts +18 -0
- package/dist/repair.d.ts.map +1 -0
- package/dist/repair.js +57 -0
- package/dist/repair.js.map +1 -0
- package/dist/reshape.d.ts +32 -0
- package/dist/reshape.d.ts.map +1 -0
- package/dist/reshape.js +164 -0
- package/dist/reshape.js.map +1 -0
- package/dist/restricted.d.ts +21 -0
- package/dist/restricted.d.ts.map +1 -0
- package/dist/restricted.js +61 -0
- package/dist/restricted.js.map +1 -0
- package/dist/scheduler.d.ts +82 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +254 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/select.d.ts +34 -0
- package/dist/select.d.ts.map +1 -0
- package/dist/select.js +21 -0
- package/dist/select.js.map +1 -0
- package/dist/shape.d.ts +3 -0
- package/dist/shape.d.ts.map +1 -0
- package/dist/shape.js +37 -0
- package/dist/shape.js.map +1 -0
- package/dist/split/client.d.ts +132 -0
- package/dist/split/client.d.ts.map +1 -0
- package/dist/split/client.js +313 -0
- package/dist/split/client.js.map +1 -0
- package/dist/split/index.d.ts +5 -0
- package/dist/split/index.d.ts.map +1 -0
- package/dist/split/index.js +14 -0
- package/dist/split/index.js.map +1 -0
- package/dist/split/server.d.ts +160 -0
- package/dist/split/server.d.ts.map +1 -0
- package/dist/split/server.js +496 -0
- package/dist/split/server.js.map +1 -0
- package/dist/split/shared.d.ts +6 -0
- package/dist/split/shared.d.ts.map +1 -0
- package/dist/split/shared.js +19 -0
- package/dist/split/shared.js.map +1 -0
- package/package.json +77 -3
- package/src/cache.ts +211 -0
- package/src/chatSource.ts +374 -0
- package/src/client.ts +46 -0
- package/src/contentRefs.ts +195 -0
- package/src/controller.ts +708 -0
- package/src/dataProvider.ts +141 -0
- package/src/index.ts +86 -0
- package/src/invoker.ts +14 -0
- package/src/invokers.ts +348 -0
- package/src/loomEffectProvider.ts +136 -0
- package/src/params.ts +314 -0
- package/src/path.ts +26 -0
- package/src/plan.ts +197 -0
- package/src/projection.ts +297 -0
- package/src/provider.ts +657 -0
- package/src/repair.ts +70 -0
- package/src/reshape.ts +193 -0
- package/src/restricted.ts +69 -0
- package/src/scheduler.ts +284 -0
- package/src/select.ts +59 -0
- package/src/shape.ts +39 -0
- package/src/split/client.ts +449 -0
- package/src/split/index.ts +18 -0
- package/src/split/server.ts +746 -0
- package/src/split/shared.ts +18 -0
package/src/shape.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { jsonSchemaHash } from "@loomwork/core";
|
|
2
|
+
|
|
3
|
+
// The output-shape fingerprint, client-safe. This MIRRORS `shapeHash` in `@loomwork/plugin-sandbox`
|
|
4
|
+
// (ADR 0064) because that package's entry pulls QuickJS; `shape.test.ts` asserts the two agree
|
|
5
|
+
// on a corpus, so a drift in either fails the build. Same skeleton: primitives -> typeof, objects
|
|
6
|
+
// -> sorted key/shape maps, arrays -> the deduplicated, canonically sorted element shapes.
|
|
7
|
+
|
|
8
|
+
type Shape = string | Shape[] | { [key: string]: Shape };
|
|
9
|
+
const MAX_SHAPE_DEPTH = 1024;
|
|
10
|
+
|
|
11
|
+
function shapeAt(value: unknown, depth: number): Shape {
|
|
12
|
+
if (value === null) return "null";
|
|
13
|
+
const t = typeof value;
|
|
14
|
+
if (t === "string" || t === "number" || t === "boolean") return t;
|
|
15
|
+
if (depth >= MAX_SHAPE_DEPTH) {
|
|
16
|
+
throw new Error(`shapeOf: depth limit exceeded (${MAX_SHAPE_DEPTH})`);
|
|
17
|
+
}
|
|
18
|
+
if (Array.isArray(value)) {
|
|
19
|
+
const shapes = new Map<string, Shape>();
|
|
20
|
+
for (const v of value) {
|
|
21
|
+
const s = shapeAt(v, depth + 1);
|
|
22
|
+
shapes.set(JSON.stringify(s), s);
|
|
23
|
+
}
|
|
24
|
+
return [...shapes.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([, s]) => s);
|
|
25
|
+
}
|
|
26
|
+
if (t === "object") {
|
|
27
|
+
const out: { [key: string]: Shape } = {};
|
|
28
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
29
|
+
if (v !== undefined) out[k] = shapeAt(v, depth + 1);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
return t;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The output-shape fingerprint. The planner pins `ProviderSpec.outputShapeHash` with THIS function. */
|
|
37
|
+
export function outputShapeHashOf(value: unknown): string {
|
|
38
|
+
return jsonSchemaHash(shapeAt(value, 0));
|
|
39
|
+
}
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import type { Audience, Finding, Priority } from "@loomwork/ui-core";
|
|
2
|
+
import { checkKindValue, hasKindContract, isBroadcast } from "@loomwork/ui-core/browser";
|
|
3
|
+
import { type LoadParams, checkParams, paramsKey, pickParams } from "../params";
|
|
4
|
+
import {
|
|
5
|
+
HydrationAudienceMismatchError,
|
|
6
|
+
type HydrationStore,
|
|
7
|
+
isReservedHandle,
|
|
8
|
+
} from "../projection";
|
|
9
|
+
import {
|
|
10
|
+
type HandleLoadResult,
|
|
11
|
+
HandleScheduler,
|
|
12
|
+
KEY_SEP,
|
|
13
|
+
type ScheduledJob,
|
|
14
|
+
type TimerFn,
|
|
15
|
+
defaultTimer,
|
|
16
|
+
disposedFinding,
|
|
17
|
+
} from "../scheduler";
|
|
18
|
+
import { errorMessage, refuse, reservedFinding } from "./shared";
|
|
19
|
+
|
|
20
|
+
// ── Split hydration: providers stay on the server ─────────────────────────────
|
|
21
|
+
// Two sides of a trust boundary. The SERVER holds the experience with its provider specs (tool
|
|
22
|
+
// names, arguments, reshaping scripts). The CLIENT holds the compiled Blueprint plus a HANDLE
|
|
23
|
+
// INVENTORY: per bound handle, its priority (the Blueprint carries none), the NAMES of the params
|
|
24
|
+
// a load may override, and an opaque revision that moves when the provider behind the handle
|
|
25
|
+
// changes — never the provider itself, nor an unkeyed function of it: the server derives each
|
|
26
|
+
// revision through its own `revisionOf` (a keyed hash), so a client cannot confirm a guessed
|
|
27
|
+
// provider offline. `SplitHydrationController` schedules from the inventory
|
|
28
|
+
// exactly as the in-process controller schedules from providers (the same `HandleScheduler`) and
|
|
29
|
+
// loads through a `HandleLoader` — the app's transport. Loads the scheduler starts together (a
|
|
30
|
+
// group: the P0s of one bind, a P1 batch) go out as ONE `loadMany`.
|
|
31
|
+
//
|
|
32
|
+
// The server answers with `serveHandles` (a batch) or `serveHandle` (one): `executeProvider` plus
|
|
33
|
+
// the same publish mapping (`outcomeResult`), so split and in-process hydration cannot disagree.
|
|
34
|
+
// A batch is the server's PASS: one clock read for every relative-time token in it, one step memo
|
|
35
|
+
// (so N handles sharing an upstream step make one call) and one call cap. Every request is
|
|
36
|
+
// UNTRUSTED input: it is validated, only a handle a node actually binds is served, and a param
|
|
37
|
+
// never reaches an argument the provider did not declare overridable.
|
|
38
|
+
//
|
|
39
|
+
// This file is the CLIENT half: browser-safe by construction (no provider execution, no sandbox,
|
|
40
|
+
// no MCP runtime, no node builtin). `@loomwork/ui-hydration/client` exports it; the fitness test
|
|
41
|
+
// `clientEntry.test.ts` walks the built graph to keep it that way.
|
|
42
|
+
|
|
43
|
+
export interface HandleRef {
|
|
44
|
+
readonly handle: string;
|
|
45
|
+
readonly priority: Priority;
|
|
46
|
+
/** Argument names a load may override (the sorted union of the provider's `overridable`). */
|
|
47
|
+
readonly params: readonly string[];
|
|
48
|
+
/**
|
|
49
|
+
* Opaque: changes when the provider behind the handle changes. Never the provider, nor an
|
|
50
|
+
* unkeyed function of it — `InventoryOptions.revisionOf` derives it on the server.
|
|
51
|
+
*/
|
|
52
|
+
readonly revision: string;
|
|
53
|
+
/** Set when the handle cannot load (no provider): published as an error, never requested. */
|
|
54
|
+
readonly finding?: Finding;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface HandleInventory {
|
|
58
|
+
readonly experienceId: string;
|
|
59
|
+
readonly versionHash: string;
|
|
60
|
+
readonly handles: readonly HandleRef[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface HandleLoadRequest {
|
|
64
|
+
readonly experienceId: string;
|
|
65
|
+
readonly versionHash: string;
|
|
66
|
+
readonly handle: string;
|
|
67
|
+
/** Only the params this handle declares (the controller filters by `HandleRef.params`). */
|
|
68
|
+
readonly params: LoadParams;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The app's transport. Each method answers or rejects; a rejection errors the handles it carried. */
|
|
72
|
+
export interface HandleLoader {
|
|
73
|
+
load(request: HandleLoadRequest, signal: AbortSignal): Promise<HandleLoadResult>;
|
|
74
|
+
/**
|
|
75
|
+
* Several requests in ONE round trip, answered in request order — the server's `serveHandles`,
|
|
76
|
+
* so they share a pass and handles sharing an upstream step share the call. Required: that
|
|
77
|
+
* sharing is not optional. `signal` aborts once every request in the group has been abandoned.
|
|
78
|
+
*/
|
|
79
|
+
loadMany(
|
|
80
|
+
requests: readonly HandleLoadRequest[],
|
|
81
|
+
signal: AbortSignal
|
|
82
|
+
): Promise<readonly HandleLoadResult[]>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface SplitControllerOptions {
|
|
86
|
+
readonly loader: HandleLoader;
|
|
87
|
+
readonly store: HydrationStore;
|
|
88
|
+
/**
|
|
89
|
+
* The audience this controller draws for. When given it must agree with the store's
|
|
90
|
+
* `scope.audience` both ways, or the constructor throws `HydrationAudienceMismatchError` (as
|
|
91
|
+
* `HydrationController` always does). Omitted = the viewer audience: a broadcast store then throws.
|
|
92
|
+
*/
|
|
93
|
+
readonly audience?: Audience;
|
|
94
|
+
readonly setTimer?: TimerFn;
|
|
95
|
+
readonly batchWindowMs?: number;
|
|
96
|
+
/** Params every load starts from (e.g. `DEFAULT_TIME_RANGE`); `setParams` layers over them. */
|
|
97
|
+
readonly defaultParams?: LoadParams;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface RefJob extends ScheduledJob {
|
|
101
|
+
readonly params: LoadParams;
|
|
102
|
+
readonly experienceId: string;
|
|
103
|
+
readonly versionHash: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** One queued load in a group, waiting for the group's flush. */
|
|
107
|
+
interface Queued {
|
|
108
|
+
readonly job: RefJob;
|
|
109
|
+
readonly signal: AbortSignal;
|
|
110
|
+
readonly resolve: (r: HandleLoadResult) => void;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Loads the scheduler started together: flushed as one `loadMany` a microtask later. */
|
|
114
|
+
interface Group {
|
|
115
|
+
queue: Queued[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const isFinding = (f: unknown): f is Finding =>
|
|
119
|
+
f !== null &&
|
|
120
|
+
typeof f === "object" &&
|
|
121
|
+
typeof (f as Finding).code === "string" &&
|
|
122
|
+
typeof (f as Finding).message === "string" &&
|
|
123
|
+
((f as Finding).severity === "error" || (f as Finding).severity === "warn");
|
|
124
|
+
|
|
125
|
+
const malformedResult = (handle: string): HandleLoadResult =>
|
|
126
|
+
refuse(
|
|
127
|
+
"load-malformed",
|
|
128
|
+
`the loader answered "${handle}" with something that is not a load result`
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const failedResult = (handle: string, e: unknown): HandleLoadResult =>
|
|
132
|
+
refuse("load-failed", `loading "${handle}" failed: ${errorMessage(e)}`);
|
|
133
|
+
|
|
134
|
+
/** A loader's answer crossed a boundary: believe only what has the shape of a load result. */
|
|
135
|
+
function asLoadResult(x: unknown, handle: string): HandleLoadResult {
|
|
136
|
+
if (x === null || typeof x !== "object") return malformedResult(handle);
|
|
137
|
+
const r = x as { state?: unknown; value?: unknown; finding?: unknown };
|
|
138
|
+
const finding = isFinding(r.finding) ? r.finding : undefined;
|
|
139
|
+
if (r.state === "ready")
|
|
140
|
+
return finding === undefined
|
|
141
|
+
? { state: "ready", value: r.value }
|
|
142
|
+
: { state: "ready", value: r.value, finding };
|
|
143
|
+
// `empty` (loom ADR 0100) carries its finding and never a value: a value beside it is dropped.
|
|
144
|
+
if ((r.state === "stale" || r.state === "error" || r.state === "empty") && finding !== undefined)
|
|
145
|
+
return { state: r.state, finding };
|
|
146
|
+
return malformedResult(handle);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const PRIORITIES: ReadonlySet<unknown> = new Set(["P0", "P1", "P2"]);
|
|
150
|
+
|
|
151
|
+
/** The inventory crossed a boundary too: believe only what has the shape of one. */
|
|
152
|
+
function isInventory(x: unknown): x is HandleInventory {
|
|
153
|
+
if (x === null || typeof x !== "object") return false;
|
|
154
|
+
const inv = x as { experienceId?: unknown; versionHash?: unknown; handles?: unknown };
|
|
155
|
+
return (
|
|
156
|
+
typeof inv.experienceId === "string" &&
|
|
157
|
+
typeof inv.versionHash === "string" &&
|
|
158
|
+
Array.isArray(inv.handles) &&
|
|
159
|
+
inv.handles.every((r: unknown) => {
|
|
160
|
+
if (r === null || typeof r !== "object") return false;
|
|
161
|
+
const ref = r as Partial<Record<keyof HandleRef, unknown>>;
|
|
162
|
+
return (
|
|
163
|
+
typeof ref.handle === "string" &&
|
|
164
|
+
PRIORITIES.has(ref.priority) &&
|
|
165
|
+
typeof ref.revision === "string" &&
|
|
166
|
+
Array.isArray(ref.params) &&
|
|
167
|
+
ref.params.every((p: unknown) => typeof p === "string") &&
|
|
168
|
+
(ref.finding === undefined || isFinding(ref.finding))
|
|
169
|
+
);
|
|
170
|
+
})
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Client-side hydration across a trust boundary. Same scheduling as `HydrationController` (P0 now,
|
|
176
|
+
* P1 batched, P2 on `markVisible`, once per question, supersede, dispose) over a `HandleInventory`,
|
|
177
|
+
* loading through the injected `HandleLoader` — a group the scheduler starts together as ONE
|
|
178
|
+
* `loadMany` — and publishing to a `HydrationStore`. A question is the handle's opaque revision
|
|
179
|
+
* plus its params (defaults < global < handle-scoped, filtered to the names the inventory lists
|
|
180
|
+
* for it). No cache, no clock and no drift handling here: all three are the server's.
|
|
181
|
+
*/
|
|
182
|
+
export class SplitHydrationController {
|
|
183
|
+
private readonly sched: HandleScheduler<RefJob, Group, HandleLoadResult>;
|
|
184
|
+
private current: HandleInventory | undefined;
|
|
185
|
+
private globalParams: LoadParams = {};
|
|
186
|
+
private readonly scopedParams = new Map<string, LoadParams>();
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Throws `TypeError` for a loader missing `load` or `loadMany`, `RangeError` for
|
|
190
|
+
* unrepresentable `defaultParams`.
|
|
191
|
+
*/
|
|
192
|
+
constructor(private readonly opts: SplitControllerOptions) {
|
|
193
|
+
const broadcast = isBroadcast(opts.audience);
|
|
194
|
+
if (broadcast !== isBroadcast(opts.store.scope.audience))
|
|
195
|
+
throw new HydrationAudienceMismatchError(broadcast);
|
|
196
|
+
const loader: Partial<HandleLoader> | null | undefined = opts.loader;
|
|
197
|
+
if (typeof loader?.load !== "function" || typeof loader.loadMany !== "function")
|
|
198
|
+
throw new TypeError("SplitHydrationController: the loader must implement load and loadMany");
|
|
199
|
+
if (opts.defaultParams !== undefined) {
|
|
200
|
+
const bad = checkParams(opts.defaultParams);
|
|
201
|
+
if (bad) throw new RangeError(bad.message);
|
|
202
|
+
}
|
|
203
|
+
this.sched = new HandleScheduler<RefJob, Group, HandleLoadResult>(
|
|
204
|
+
opts.store,
|
|
205
|
+
{
|
|
206
|
+
newGroup: () => ({ queue: [] }),
|
|
207
|
+
run: (job, signal, group) => this.enqueue(job, signal, group),
|
|
208
|
+
},
|
|
209
|
+
opts.setTimer ?? defaultTimer,
|
|
210
|
+
opts.batchWindowMs ?? 50
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Publish a value for a handle that no loader serves (a live transcript this page folds), through
|
|
216
|
+
* the inventory's gate: only a handle the server LISTED is accepted (a restricted or unknown one is
|
|
217
|
+
* absent from it, so it is refused), and the value must pass the contract of `kind`, which the caller
|
|
218
|
+
* names (this side holds no catalogue). Refused = a finding and nothing written. NOTE: the inventory
|
|
219
|
+
* does not say what KIND a listed handle feeds, so this cannot verify that `kind` is the handle's
|
|
220
|
+
* widget: that is the page author's code naming its own handle, the viewer gate (listed or not) is
|
|
221
|
+
* the part that is enforced here.
|
|
222
|
+
*/
|
|
223
|
+
publishValue(
|
|
224
|
+
handle: string,
|
|
225
|
+
value: unknown,
|
|
226
|
+
options: { readonly state?: "ready" | "pending"; readonly kind: string }
|
|
227
|
+
): { ok: true } | { ok: false; finding: Finding } {
|
|
228
|
+
const listed =
|
|
229
|
+
!this.sched.isDisposed &&
|
|
230
|
+
!isReservedHandle(handle) &&
|
|
231
|
+
this.current?.handles.some((r) => r.handle === handle && r.finding === undefined) === true;
|
|
232
|
+
if (!listed)
|
|
233
|
+
return {
|
|
234
|
+
ok: false,
|
|
235
|
+
finding: {
|
|
236
|
+
code: "publish-unbound",
|
|
237
|
+
severity: "error",
|
|
238
|
+
message: "the handle is not in the current inventory; the value was refused",
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
if (!hasKindContract(options.kind))
|
|
242
|
+
return {
|
|
243
|
+
ok: false,
|
|
244
|
+
finding: {
|
|
245
|
+
code: "publish-uncontracted",
|
|
246
|
+
severity: "error",
|
|
247
|
+
message: "the named kind has no value contract; refused",
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
const check = checkKindValue(options.kind, value);
|
|
251
|
+
if (!check.ok) return { ok: false, finding: check.finding };
|
|
252
|
+
this.opts.store.setValue(handle, value, { state: options.state ?? "ready" });
|
|
253
|
+
return { ok: true };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Bind an inventory; a second `load` IS a recompose. */
|
|
257
|
+
load(inventory: HandleInventory): { findings: Finding[] } {
|
|
258
|
+
if (this.sched.isDisposed) return { findings: [disposedFinding("load")] };
|
|
259
|
+
if (!isInventory(inventory)) return { findings: [invalidInventory()] };
|
|
260
|
+
if (this.current !== undefined) return this.recompose(inventory);
|
|
261
|
+
return this.bind(inventory);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** A new inventory replaces the current one: drop unlisted handles, keep the rest, load new ones. */
|
|
265
|
+
recompose(next: HandleInventory): { findings: Finding[] } {
|
|
266
|
+
if (this.sched.isDisposed) return { findings: [disposedFinding("recompose")] };
|
|
267
|
+
// Before anything is dropped: an inventory we refuse must leave the current one as it was.
|
|
268
|
+
if (!isInventory(next)) return { findings: [invalidInventory()] };
|
|
269
|
+
this.sched.retain(new Set(next.handles.map((r) => r.handle)));
|
|
270
|
+
return this.bind(next);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** The surface reports a lazy (P2) widget entered the viewport. */
|
|
274
|
+
markVisible(handle: string): void {
|
|
275
|
+
this.sched.markVisible(handle);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Replace the global params (no `scope.handle`) or one handle's scoped params, then re-ask
|
|
280
|
+
* every bound handle whose question changed — only those, so a handle that ended on `error`
|
|
281
|
+
* with an unchanged question is NOT retried here (`invalidate` retries it). `{}` clears.
|
|
282
|
+
* Refused, with nothing changed, for params `checkParams` refuses or a reserved handle.
|
|
283
|
+
*/
|
|
284
|
+
setParams(params: LoadParams, scope: { readonly handle?: string } = {}): { findings: Finding[] } {
|
|
285
|
+
if (this.sched.isDisposed) return { findings: [disposedFinding("setParams")] };
|
|
286
|
+
const bad = checkParams(params);
|
|
287
|
+
if (bad) return { findings: [bad] };
|
|
288
|
+
if (scope.handle !== undefined && isReservedHandle(scope.handle))
|
|
289
|
+
return { findings: [reservedFinding(scope.handle)] };
|
|
290
|
+
const before =
|
|
291
|
+
this.current === undefined
|
|
292
|
+
? new Map<string, string>()
|
|
293
|
+
: new Map(this.jobs(this.current).jobs.map((j) => [j.handle, j.fingerprint]));
|
|
294
|
+
// A copy (own enumerable keys only): a caller mutating its object later changes nothing.
|
|
295
|
+
const copy: LoadParams = Object.fromEntries(Object.entries(params));
|
|
296
|
+
if (scope.handle === undefined) this.globalParams = copy;
|
|
297
|
+
else this.scopedParams.set(scope.handle, copy);
|
|
298
|
+
if (this.current !== undefined)
|
|
299
|
+
this.sched.schedule(
|
|
300
|
+
this.jobs(this.current).jobs.filter((j) => before.get(j.handle) !== j.fingerprint)
|
|
301
|
+
);
|
|
302
|
+
return { findings: [] };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Ask again (one handle or all): supersedes in-flight loads; a lazy handle stays lazy. */
|
|
306
|
+
invalidate(handle?: string): { findings: Finding[] } {
|
|
307
|
+
if (this.sched.isDisposed) return { findings: [disposedFinding("invalidate")] };
|
|
308
|
+
if (this.current === undefined) return { findings: [] };
|
|
309
|
+
const { jobs } = this.jobs(this.current);
|
|
310
|
+
this.sched.reload(handle === undefined ? jobs : jobs.filter((j) => j.handle === handle));
|
|
311
|
+
return { findings: [] };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
inFlight(): string[] {
|
|
315
|
+
return this.sched.inFlight();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Terminal: every in-flight load is aborted and reported, and nothing schedules again. */
|
|
319
|
+
dispose(): void {
|
|
320
|
+
this.sched.dispose();
|
|
321
|
+
this.current = undefined;
|
|
322
|
+
this.globalParams = {};
|
|
323
|
+
this.scopedParams.clear();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private bind(inventory: HandleInventory): { findings: Finding[] } {
|
|
327
|
+
this.current = inventory;
|
|
328
|
+
const listed = new Set(inventory.handles.map((r) => r.handle));
|
|
329
|
+
for (const handle of [...this.scopedParams.keys()])
|
|
330
|
+
if (!listed.has(handle)) this.scopedParams.delete(handle);
|
|
331
|
+
const { jobs, findings, missing } = this.jobs(inventory);
|
|
332
|
+
for (const m of missing)
|
|
333
|
+
this.opts.store.setStatus(m.handle, { state: "error", finding: m.finding });
|
|
334
|
+
this.sched.schedule(jobs);
|
|
335
|
+
return { findings };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Pure: the jobs `inventory` asks for right now, and the handles that cannot load (with why). */
|
|
339
|
+
private jobs(inventory: HandleInventory): {
|
|
340
|
+
jobs: RefJob[];
|
|
341
|
+
findings: Finding[];
|
|
342
|
+
missing: Array<{ handle: string; finding: Finding }>;
|
|
343
|
+
} {
|
|
344
|
+
const jobs: RefJob[] = [];
|
|
345
|
+
const findings: Finding[] = [];
|
|
346
|
+
const missing: Array<{ handle: string; finding: Finding }> = [];
|
|
347
|
+
for (const ref of inventory.handles) {
|
|
348
|
+
// The inventory crossed a boundary: a reserved handle is refused here too.
|
|
349
|
+
if (isReservedHandle(ref.handle)) {
|
|
350
|
+
findings.push(reservedFinding(ref.handle));
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (ref.finding !== undefined) {
|
|
354
|
+
findings.push(ref.finding);
|
|
355
|
+
missing.push({ handle: ref.handle, finding: ref.finding });
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
const params = pickParams(
|
|
359
|
+
{ ...this.opts.defaultParams, ...this.globalParams, ...this.scopedParams.get(ref.handle) },
|
|
360
|
+
ref.params
|
|
361
|
+
);
|
|
362
|
+
jobs.push({
|
|
363
|
+
handle: ref.handle,
|
|
364
|
+
priority: ref.priority,
|
|
365
|
+
params,
|
|
366
|
+
experienceId: inventory.experienceId,
|
|
367
|
+
versionHash: inventory.versionHash,
|
|
368
|
+
fingerprint: `${ref.revision}${KEY_SEP}${paramsKey(params)}`,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
return { jobs, findings, missing };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* The scheduler starts a group's jobs in one synchronous turn; each joins the group's queue and
|
|
376
|
+
* the first schedules the flush, so the whole group leaves as one request.
|
|
377
|
+
*/
|
|
378
|
+
private enqueue(job: RefJob, signal: AbortSignal, group: Group): Promise<HandleLoadResult> {
|
|
379
|
+
return new Promise<HandleLoadResult>((resolve) => {
|
|
380
|
+
group.queue.push({ job, signal, resolve });
|
|
381
|
+
if (group.queue.length === 1) queueMicrotask(() => this.flush(group));
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private flush(group: Group): void {
|
|
386
|
+
const queued = group.queue;
|
|
387
|
+
group.queue = [];
|
|
388
|
+
// Abandoned before it left (superseded, dropped, disposed): its result would be dropped anyway.
|
|
389
|
+
const live: Queued[] = [];
|
|
390
|
+
for (const q of queued) {
|
|
391
|
+
if (q.signal.aborted)
|
|
392
|
+
q.resolve(refuse("aborted", `the load of "${q.job.handle}" was abandoned`));
|
|
393
|
+
else live.push(q);
|
|
394
|
+
}
|
|
395
|
+
if (live.length > 1) {
|
|
396
|
+
this.loadGroup(live).then((results) => {
|
|
397
|
+
live.forEach((q, i) => q.resolve(results[i] ?? malformedResult(q.job.handle)));
|
|
398
|
+
});
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
for (const q of live) this.loadOne(q).then(q.resolve);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private async loadOne(q: Queued): Promise<HandleLoadResult> {
|
|
405
|
+
try {
|
|
406
|
+
return asLoadResult(await this.opts.loader.load(request(q.job), q.signal), q.job.handle);
|
|
407
|
+
} catch (e) {
|
|
408
|
+
return failedResult(q.job.handle, e);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** One `loadMany` for the group; its signal aborts once every member has been abandoned. */
|
|
413
|
+
private async loadGroup(live: readonly Queued[]): Promise<HandleLoadResult[]> {
|
|
414
|
+
const ac = new AbortController();
|
|
415
|
+
let remaining = live.length;
|
|
416
|
+
const onAbort = (): void => {
|
|
417
|
+
remaining -= 1;
|
|
418
|
+
if (remaining === 0) ac.abort();
|
|
419
|
+
};
|
|
420
|
+
for (const q of live) q.signal.addEventListener("abort", onAbort, { once: true });
|
|
421
|
+
try {
|
|
422
|
+
const answer: unknown = await this.opts.loader.loadMany(
|
|
423
|
+
live.map((q) => request(q.job)),
|
|
424
|
+
ac.signal
|
|
425
|
+
);
|
|
426
|
+
if (!Array.isArray(answer) || answer.length !== live.length)
|
|
427
|
+
return live.map((q) => malformedResult(q.job.handle));
|
|
428
|
+
return live.map((q, i) => asLoadResult(answer[i], q.job.handle));
|
|
429
|
+
} catch (e) {
|
|
430
|
+
return live.map((q) => failedResult(q.job.handle, e));
|
|
431
|
+
} finally {
|
|
432
|
+
for (const q of live) q.signal.removeEventListener("abort", onAbort);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const invalidInventory = (): Finding => ({
|
|
438
|
+
code: "invalid-inventory",
|
|
439
|
+
severity: "error",
|
|
440
|
+
message:
|
|
441
|
+
"not a handle inventory: expected an experienceId, a versionHash and well-formed handle refs",
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
const request = (job: RefJob): HandleLoadRequest => ({
|
|
445
|
+
experienceId: job.experienceId,
|
|
446
|
+
versionHash: job.versionHash,
|
|
447
|
+
handle: job.handle,
|
|
448
|
+
params: job.params,
|
|
449
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export {
|
|
2
|
+
DEFAULT_MAX_BATCH,
|
|
3
|
+
DEFAULT_MAX_CONCURRENT_CALLS,
|
|
4
|
+
createServeSurface,
|
|
5
|
+
handleInventory,
|
|
6
|
+
publicFinding,
|
|
7
|
+
serveHandle,
|
|
8
|
+
serveHandles,
|
|
9
|
+
} from "./server";
|
|
10
|
+
export type { InventoryOptions, RevisionFn, ServeOptions, ServeSurface } from "./server";
|
|
11
|
+
export { SplitHydrationController } from "./client";
|
|
12
|
+
export type {
|
|
13
|
+
HandleInventory,
|
|
14
|
+
HandleLoadRequest,
|
|
15
|
+
HandleLoader,
|
|
16
|
+
HandleRef,
|
|
17
|
+
SplitControllerOptions,
|
|
18
|
+
} from "./client";
|