@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
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
import { MCP_INVOKE_ERRORS } from "@loomwork/plugin-mcp";
|
|
2
|
+
import { SANDBOX_ERRORS } from "@loomwork/plugin-sandbox";
|
|
3
|
+
import {
|
|
4
|
+
type Audience,
|
|
5
|
+
type ContentRefTable,
|
|
6
|
+
type DeclaredToolClasses,
|
|
7
|
+
type Experience,
|
|
8
|
+
type Finding,
|
|
9
|
+
MAX_TREE_DEPTH,
|
|
10
|
+
type ProviderSpec,
|
|
11
|
+
type ReadPolicy,
|
|
12
|
+
type SemanticCatalog,
|
|
13
|
+
type Surface,
|
|
14
|
+
type UndeclaredToolPolicy,
|
|
15
|
+
type Viewer,
|
|
16
|
+
experienceHandles,
|
|
17
|
+
handleKindsWithMedia,
|
|
18
|
+
hasMediaTypeNodes,
|
|
19
|
+
isChatKind,
|
|
20
|
+
isMediaKind,
|
|
21
|
+
mergeReadPolicy,
|
|
22
|
+
treeDepth,
|
|
23
|
+
} from "@loomwork/ui-core";
|
|
24
|
+
import { type HydrationCache, specFingerprint } from "../cache";
|
|
25
|
+
import {
|
|
26
|
+
assertContentRefConfig,
|
|
27
|
+
contentRefEnv,
|
|
28
|
+
inspectContentRefs,
|
|
29
|
+
principalScoped,
|
|
30
|
+
} from "../contentRefs";
|
|
31
|
+
import type { DriftReport } from "../controller";
|
|
32
|
+
import type { TransientInvoker } from "../invoker";
|
|
33
|
+
import { limitInvoker, memoInvoker } from "../invokers";
|
|
34
|
+
import { providerParams, relevantParams, snapshotParams } from "../params";
|
|
35
|
+
import { isReservedHandle } from "../projection";
|
|
36
|
+
import { type ExecuteOptions, executeProvider, outcomeResult } from "../provider";
|
|
37
|
+
import { partitionByViewer, restrictedFinding, snapshotViewer } from "../restricted";
|
|
38
|
+
import { type HandleLoadResult, tooDeep } from "../scheduler";
|
|
39
|
+
import type { HandleInventory, HandleLoadRequest, HandleRef } from "./client";
|
|
40
|
+
import { errorMessage, refuse, reservedFinding } from "./shared";
|
|
41
|
+
|
|
42
|
+
// The SERVER half of split hydration (see `./client` for the trust boundary): the inventory a
|
|
43
|
+
// client may hold, and the answers to its loads. It executes providers, so it imports the sandbox
|
|
44
|
+
// and the MCP plugin — never bundle it for a browser.
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Derives a handle's public revision from the provider's content fingerprint (`specFingerprint`).
|
|
48
|
+
* It must be KEYED: an unkeyed function of the fingerprint lets a client confirm a guessed provider
|
|
49
|
+
* (tool, arguments) offline. In production use an HMAC over a server secret, e.g.
|
|
50
|
+
* `createHmac("sha256", secret).update(handle + "\u0000" + fingerprint).digest("hex")`. It must be
|
|
51
|
+
* deterministic for as long as clients hold inventories (a rotated key re-asks every handle once),
|
|
52
|
+
* and it must move with the fingerprint: a per-handle constant cannot be detected here, and it
|
|
53
|
+
* defeats re-asking — a client never learns that the provider behind a handle changed.
|
|
54
|
+
*/
|
|
55
|
+
export type RevisionFn = (handle: string, fingerprint: string) => string;
|
|
56
|
+
|
|
57
|
+
export interface InventoryOptions {
|
|
58
|
+
readonly revisionOf: RevisionFn;
|
|
59
|
+
/**
|
|
60
|
+
* Who the inventory is for. A handle classified above the viewer's clearance is OMITTED — not
|
|
61
|
+
* listed with a placeholder, so the client learns neither its name nor its revision. FAIL-CLOSED:
|
|
62
|
+
* absent = no clearance (one `viewer-not-supplied` warn is returned if anything is classified).
|
|
63
|
+
* Pass the same viewer to `serveHandles`, which refuses such a handle even if a client names it.
|
|
64
|
+
*/
|
|
65
|
+
readonly viewer?: Viewer;
|
|
66
|
+
/**
|
|
67
|
+
* Who the surface is for (`@loomwork/ui-core` `Audience`, the value `compile` took). `"broadcast"`
|
|
68
|
+
* lists only unclassified handles, whoever the `viewer` is. Pass the same to `serveHandles`.
|
|
69
|
+
*/
|
|
70
|
+
readonly audience?: Audience;
|
|
71
|
+
/**
|
|
72
|
+
* The host's DECLARED class per tool (`toolKey`), a read-time floor under every handle calling
|
|
73
|
+
* that tool (`@loomwork/ui-core` `DeclaredToolClasses`): a handle whose stored nodes carry no class
|
|
74
|
+
* is still gated as strictly as its tool is declared. Absent = only the experience's own classes.
|
|
75
|
+
*/
|
|
76
|
+
readonly declaredToolClasses?: DeclaredToolClasses;
|
|
77
|
+
/**
|
|
78
|
+
* What a tool the `declaredToolClasses` table does not list reads as: `"allow"` (default) = as the
|
|
79
|
+
* experience itself says; `"deny"` = no viewer may read it (only with a table present).
|
|
80
|
+
*/
|
|
81
|
+
readonly undeclared?: UndeclaredToolPolicy;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* What a client may know about an experience's data plane: never a provider. Throws `TypeError`
|
|
86
|
+
* without a `revisionOf`, or when it returns a non-string, an empty string or the fingerprint.
|
|
87
|
+
*/
|
|
88
|
+
export function handleInventory(
|
|
89
|
+
exp: Experience,
|
|
90
|
+
opts: InventoryOptions
|
|
91
|
+
): {
|
|
92
|
+
inventory: HandleInventory;
|
|
93
|
+
findings: Finding[];
|
|
94
|
+
} {
|
|
95
|
+
const revisionOf = opts?.revisionOf;
|
|
96
|
+
if (typeof revisionOf !== "function")
|
|
97
|
+
throw new TypeError("handleInventory: a revisionOf (a keyed hash) is required");
|
|
98
|
+
const findings: Finding[] = [];
|
|
99
|
+
const handles: HandleRef[] = [];
|
|
100
|
+
if (treeDepth(exp.root) > MAX_TREE_DEPTH) {
|
|
101
|
+
findings.push(tooDeep(exp.experienceId, MAX_TREE_DEPTH));
|
|
102
|
+
} else {
|
|
103
|
+
const split = partitionByViewer(
|
|
104
|
+
experienceHandles(exp, opts.declaredToolClasses, opts.undeclared),
|
|
105
|
+
snapshotViewer(opts.viewer, opts.audience)
|
|
106
|
+
);
|
|
107
|
+
findings.push(...split.advisory);
|
|
108
|
+
for (const use of split.allowed) {
|
|
109
|
+
if (isReservedHandle(use.handle)) {
|
|
110
|
+
findings.push(reservedFinding(use.handle));
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const spec = Object.hasOwn(exp.providers, use.handle) ? exp.providers[use.handle] : undefined;
|
|
114
|
+
if (spec === undefined) {
|
|
115
|
+
const f: Finding = {
|
|
116
|
+
code: "unknown-handle",
|
|
117
|
+
severity: "error",
|
|
118
|
+
message: `nodes ${use.nodeIds.join(", ")} bind "${use.handle}", which has no provider`,
|
|
119
|
+
};
|
|
120
|
+
findings.push(f);
|
|
121
|
+
handles.push({
|
|
122
|
+
handle: use.handle,
|
|
123
|
+
priority: use.priority,
|
|
124
|
+
params: [],
|
|
125
|
+
revision: "",
|
|
126
|
+
finding: f,
|
|
127
|
+
});
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
handles.push({
|
|
131
|
+
handle: use.handle,
|
|
132
|
+
priority: use.priority,
|
|
133
|
+
params: providerParams(spec),
|
|
134
|
+
revision: publicRevision(revisionOf, use.handle, spec),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
inventory: { experienceId: exp.experienceId, versionHash: exp.versionHash, handles },
|
|
140
|
+
findings,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function publicRevision(revisionOf: RevisionFn, handle: string, spec: ProviderSpec): string {
|
|
145
|
+
const fingerprint = specFingerprint(spec);
|
|
146
|
+
const revision = revisionOf(handle, fingerprint);
|
|
147
|
+
if (typeof revision !== "string" || revision === "")
|
|
148
|
+
throw new TypeError(
|
|
149
|
+
`handleInventory: revisionOf returned ${revision === "" ? "an empty string" : `a ${typeof revision}`} for "${handle}"`
|
|
150
|
+
);
|
|
151
|
+
if (revision.includes(fingerprint))
|
|
152
|
+
throw new TypeError(
|
|
153
|
+
`handleInventory: revisionOf returned the provider fingerprint for "${handle}"; it must be keyed`
|
|
154
|
+
);
|
|
155
|
+
return revision;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The most requests one `serveHandles` call serves by default (`ServeOptions.maxBatch`). */
|
|
159
|
+
export const DEFAULT_MAX_BATCH = 256;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The most transient calls one `serveHandles` batch has in flight by default
|
|
163
|
+
* (`ServeOptions.maxConcurrentCalls`): the batch is client-sized, so the server bounds its fan-out.
|
|
164
|
+
*/
|
|
165
|
+
export const DEFAULT_MAX_CONCURRENT_CALLS = 32;
|
|
166
|
+
|
|
167
|
+
export interface ServeOptions extends Omit<ExecuteOptions, "params" | "kinds" | "contentRefArgs"> {
|
|
168
|
+
/**
|
|
169
|
+
* REQUIRED (U9b): the marked content-reference argument paths per tool, derived from the offered
|
|
170
|
+
* capabilities with the planner's `contentRefArgsFrom(capabilities)` (persist it with the capability
|
|
171
|
+
* set; `parseContentRefTable` re-reads it). `"none"` is the explicit opt-out: nothing is guarded,
|
|
172
|
+
* and serving a media-bound handle then reports `media-content-refs-unguarded` (warn).
|
|
173
|
+
*/
|
|
174
|
+
readonly contentRefArgs: ContentRefTable | "none";
|
|
175
|
+
/** Kinds for `"contract"` providers: the nodes binding the handle, resolved on this surface. */
|
|
176
|
+
readonly outputKinds?: { readonly catalog: SemanticCatalog; readonly surface: Surface };
|
|
177
|
+
readonly cache?: HydrationCache;
|
|
178
|
+
/**
|
|
179
|
+
* Who the answers are for. A handle bound by any node classified above the viewer's clearance is
|
|
180
|
+
* refused BEFORE its provider is looked up, cached or invoked, and the client is told exactly
|
|
181
|
+
* what it is told for a handle the experience does not bind (`unknown-handle`) — no oracle for
|
|
182
|
+
* which handles exist but are restricted; the full `restricted` finding goes to `onFinding`.
|
|
183
|
+
* FAIL-CLOSED: absent = no clearance. Unclassified handles are unaffected.
|
|
184
|
+
*/
|
|
185
|
+
readonly viewer?: Viewer;
|
|
186
|
+
/**
|
|
187
|
+
* Who the surface is for (the value `compile` took). `"broadcast"` refuses every classified
|
|
188
|
+
* handle exactly as `unknown-handle`, even a client naming one the requesting `viewer` may read.
|
|
189
|
+
*/
|
|
190
|
+
readonly audience?: Audience;
|
|
191
|
+
/**
|
|
192
|
+
* The host's DECLARED class per tool (`toolKey`), a read-time floor under every handle calling
|
|
193
|
+
* that tool (`@loomwork/ui-core` `DeclaredToolClasses`): a handle whose stored nodes carry no class
|
|
194
|
+
* is still gated as strictly as its tool is declared. Absent = only the experience's own classes.
|
|
195
|
+
*/
|
|
196
|
+
readonly declaredToolClasses?: DeclaredToolClasses;
|
|
197
|
+
/**
|
|
198
|
+
* What a tool the `declaredToolClasses` table does not list reads as: `"allow"` (default) = as the
|
|
199
|
+
* experience itself says; `"deny"` = no viewer may read it (only with a table present).
|
|
200
|
+
*/
|
|
201
|
+
readonly undeclared?: UndeclaredToolPolicy;
|
|
202
|
+
/** Keys a `principal`-scoped cache entry; without it such a provider is never cached. */
|
|
203
|
+
readonly principalId?: string;
|
|
204
|
+
readonly onDrift?: (report: DriftReport) => void;
|
|
205
|
+
/**
|
|
206
|
+
* Every finding a load produced, IN FULL (tool, step, script, hashes, exception text), for the
|
|
207
|
+
* server's own logs. What the client receives is only its public form (`publicFinding`). A
|
|
208
|
+
* throw here, or a rejection of the promise it returns, is swallowed: a logging hook never
|
|
209
|
+
* changes what a client is answered.
|
|
210
|
+
*/
|
|
211
|
+
readonly onFinding?: (handle: string, finding: Finding) => unknown;
|
|
212
|
+
/**
|
|
213
|
+
* At most this many transient calls in flight across the whole batch. Default
|
|
214
|
+
* `DEFAULT_MAX_CONCURRENT_CALLS` (32): a client decides how many handles a batch carries, so
|
|
215
|
+
* the server bounds the upstream fan-out unless it is told otherwise. It bounds ONE batch: for
|
|
216
|
+
* a server-wide bound, wrap the invoker once in the exported `limitInvoker` and share that
|
|
217
|
+
* wrapped invoker across every batch.
|
|
218
|
+
*/
|
|
219
|
+
readonly maxConcurrentCalls?: number;
|
|
220
|
+
/**
|
|
221
|
+
* A batch longer than this is refused whole (`batch-too-large`). Default `DEFAULT_MAX_BATCH`.
|
|
222
|
+
* The refusal is still one result per request, so a transport should bound request bodies.
|
|
223
|
+
*/
|
|
224
|
+
readonly maxBatch?: number;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The failure channels loom itself declares (ADR 0060) — `mcp.invoke`'s and the sandbox's, derived
|
|
229
|
+
* from loom's own exports so the list cannot drift. A `tool-<channel>` / `reshape-<channel>` code
|
|
230
|
+
* is built from whatever channel the (pluggable) invoker threw, which is arbitrary text; only
|
|
231
|
+
* these are served as they are.
|
|
232
|
+
*/
|
|
233
|
+
const TOOL_CHANNELS: ReadonlySet<string> = new Set(Object.keys(MCP_INVOKE_ERRORS));
|
|
234
|
+
const RESHAPE_CHANNELS: ReadonlySet<string> = new Set(Object.keys(SANDBOX_ERRORS));
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Every other code a served finding may carry: the fixed codes hydration and the server mint
|
|
238
|
+
* themselves. Anything else is replaced (`load-failed` / `load-warning`) — the code is a fixed
|
|
239
|
+
* vocabulary, never text a provider or an invoker chose.
|
|
240
|
+
*/
|
|
241
|
+
const FIXED_CODES: ReadonlySet<string> = new Set([
|
|
242
|
+
"tool-failed",
|
|
243
|
+
"tool-targetUnregistered",
|
|
244
|
+
"provider-kind-unregistered",
|
|
245
|
+
"reshape-failed",
|
|
246
|
+
"reshape-unregistered",
|
|
247
|
+
"schema-drift",
|
|
248
|
+
"schema-drift-proceeded",
|
|
249
|
+
"output-shape-drift",
|
|
250
|
+
"output-shape-unhashable",
|
|
251
|
+
"output-shape-unpinned",
|
|
252
|
+
"output-contract-drift",
|
|
253
|
+
"output-contract-missing",
|
|
254
|
+
"output-contract-unbound",
|
|
255
|
+
"param-not-overridable",
|
|
256
|
+
"param-templated",
|
|
257
|
+
"param-template-refused",
|
|
258
|
+
"invalid-params",
|
|
259
|
+
"invalid-time-token",
|
|
260
|
+
"argument-hook-failed",
|
|
261
|
+
"unresolved-step-reference",
|
|
262
|
+
"upstream-empty",
|
|
263
|
+
"dependency-failed",
|
|
264
|
+
"provider-failed",
|
|
265
|
+
"content-ref-refused",
|
|
266
|
+
"empty-graph",
|
|
267
|
+
"graph-cycle",
|
|
268
|
+
"aborted",
|
|
269
|
+
"invalid-request",
|
|
270
|
+
"version-mismatch",
|
|
271
|
+
"invalid-handle",
|
|
272
|
+
"unknown-handle",
|
|
273
|
+
"batch-too-large",
|
|
274
|
+
"tree-too-deep",
|
|
275
|
+
"hydration-internal",
|
|
276
|
+
]);
|
|
277
|
+
|
|
278
|
+
function publicCode(finding: Finding): string {
|
|
279
|
+
const { code } = finding;
|
|
280
|
+
if (FIXED_CODES.has(code)) return code;
|
|
281
|
+
if (code.startsWith("tool-")) return TOOL_CHANNELS.has(code.slice(5)) ? code : "tool-failed";
|
|
282
|
+
if (code.startsWith("reshape-"))
|
|
283
|
+
return RESHAPE_CHANNELS.has(code.slice(8)) ? code : "reshape-failed";
|
|
284
|
+
return finding.severity === "warn" ? "load-warning" : "load-failed";
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* What a client may be told about a finding: a fixed text keyed by its PUBLIC code. The fixed
|
|
289
|
+
* codes' own cases run FIRST, so one of them that happens to share a `tool-`/`reshape-` prefix
|
|
290
|
+
* with an allow-listed channel code (`reshape-unregistered`, a hydration-minted code, not a
|
|
291
|
+
* channel) gets its own text instead of the generic prefix fallback below.
|
|
292
|
+
*/
|
|
293
|
+
function publicText(code: string, severity: Finding["severity"]): string {
|
|
294
|
+
switch (code) {
|
|
295
|
+
case "schema-drift-proceeded":
|
|
296
|
+
return "input schema changed; served with the observed schema";
|
|
297
|
+
case "schema-drift":
|
|
298
|
+
return "input schema changed; waiting for a repair";
|
|
299
|
+
case "output-shape-drift":
|
|
300
|
+
case "output-shape-unhashable":
|
|
301
|
+
case "output-contract-drift":
|
|
302
|
+
return "the output no longer matches what it declares; waiting for a repair";
|
|
303
|
+
case "output-shape-unpinned":
|
|
304
|
+
return "the output is served unchecked";
|
|
305
|
+
case "output-contract-missing":
|
|
306
|
+
case "output-contract-unbound":
|
|
307
|
+
return "the output cannot be checked";
|
|
308
|
+
case "provider-kind-unregistered":
|
|
309
|
+
return "no data provider is registered for this view's source";
|
|
310
|
+
case "tool-targetUnregistered":
|
|
311
|
+
return "this view's source is not available";
|
|
312
|
+
case "reshape-unregistered":
|
|
313
|
+
return "the output could not be reshaped: the named reshaper is not registered";
|
|
314
|
+
case "upstream-empty":
|
|
315
|
+
return "no data for this view";
|
|
316
|
+
case "param-not-overridable":
|
|
317
|
+
return "a param was ignored";
|
|
318
|
+
case "invalid-params":
|
|
319
|
+
case "invalid-time-token":
|
|
320
|
+
return "the params were refused";
|
|
321
|
+
case "content-ref-refused":
|
|
322
|
+
return "a content-reference argument was refused";
|
|
323
|
+
case "aborted":
|
|
324
|
+
return "the load was aborted";
|
|
325
|
+
case "invalid-request":
|
|
326
|
+
return "the request is malformed";
|
|
327
|
+
case "version-mismatch":
|
|
328
|
+
return "the request is for another version of the experience";
|
|
329
|
+
case "invalid-handle":
|
|
330
|
+
return "the handle is reserved";
|
|
331
|
+
case "unknown-handle":
|
|
332
|
+
return "not a handle this experience binds";
|
|
333
|
+
case "batch-too-large":
|
|
334
|
+
return "the batch is too large";
|
|
335
|
+
case "tree-too-deep":
|
|
336
|
+
return "the experience cannot be served";
|
|
337
|
+
case "hydration-internal":
|
|
338
|
+
return "an internal error occurred";
|
|
339
|
+
}
|
|
340
|
+
if (code.startsWith("tool-")) return "a tool call failed";
|
|
341
|
+
if (code.startsWith("reshape-")) return "the output could not be reshaped";
|
|
342
|
+
return severity === "warn" ? "loaded with a warning" : "could not be loaded";
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* The public form of a finding: `severity` kept; the code kept when it is one of the fixed codes
|
|
347
|
+
* or a loom-declared channel (otherwise the generic `tool-failed` / `reshape-failed` /
|
|
348
|
+
* `load-failed` / `load-warning`); the message a template keyed by that code, naming only the
|
|
349
|
+
* (client-supplied) handle — never a node, step, tool, script, hash, argument, channel or
|
|
350
|
+
* exception text.
|
|
351
|
+
*/
|
|
352
|
+
export function publicFinding(handle: string | undefined, finding: Finding): Finding {
|
|
353
|
+
const code = publicCode(finding);
|
|
354
|
+
const text = publicText(code, finding.severity);
|
|
355
|
+
return {
|
|
356
|
+
code,
|
|
357
|
+
severity: finding.severity,
|
|
358
|
+
message:
|
|
359
|
+
handle === undefined ? text : `handle ${JSON.stringify(handle.slice(0, 128))}: ${text}`,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
type Report = ((finding: Finding) => void) & {
|
|
364
|
+
/** Mark a finding as already accounted for, so a later `report` of it is a no-op. */
|
|
365
|
+
readonly silence: (finding: Finding) => void;
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
/** Hands each finding to `onFinding` once, in full; a throwing hook is swallowed. */
|
|
369
|
+
function reporter(opts: ServeOptions, handle: string): Report {
|
|
370
|
+
const seen = new Set<Finding>();
|
|
371
|
+
const report = (finding: Finding): void => {
|
|
372
|
+
if (seen.has(finding)) return;
|
|
373
|
+
seen.add(finding);
|
|
374
|
+
try {
|
|
375
|
+
const ret: unknown = opts.onFinding?.(handle, finding);
|
|
376
|
+
// An async hook's rejection is its own: never an unhandled rejection in the server.
|
|
377
|
+
if (
|
|
378
|
+
ret !== null &&
|
|
379
|
+
typeof ret === "object" &&
|
|
380
|
+
typeof (ret as PromiseLike<unknown>).then === "function"
|
|
381
|
+
)
|
|
382
|
+
Promise.resolve(ret).catch(() => {});
|
|
383
|
+
} catch {
|
|
384
|
+
// A logging hook must never change what the client is answered (nor, if its `then`
|
|
385
|
+
// getter throws, what the server does).
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
return Object.assign(report, {
|
|
389
|
+
silence: (finding: Finding) => {
|
|
390
|
+
seen.add(finding);
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Report the result's finding in full, and answer the client with its public form. */
|
|
396
|
+
function publicResult(
|
|
397
|
+
handle: string | undefined,
|
|
398
|
+
result: HandleLoadResult,
|
|
399
|
+
report: Report
|
|
400
|
+
): HandleLoadResult {
|
|
401
|
+
if (result.finding === undefined) return result;
|
|
402
|
+
report(result.finding);
|
|
403
|
+
const finding = publicFinding(handle, result.finding);
|
|
404
|
+
return result.state === "ready"
|
|
405
|
+
? { state: "ready", value: result.value, finding }
|
|
406
|
+
: { state: result.state, finding };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** What one batch shares: computed once, whatever the batch asks. */
|
|
410
|
+
interface ServePass {
|
|
411
|
+
readonly exec: ExecuteOptions;
|
|
412
|
+
readonly bound: ReadonlySet<string>;
|
|
413
|
+
/** Handles bound by a node the viewer may not see: refused before anything else about them. */
|
|
414
|
+
readonly restricted: ReadonlySet<string>;
|
|
415
|
+
readonly kinds: ReadonlyMap<string, readonly string[]>;
|
|
416
|
+
/** Media nodes exist but the host gave no `outputKinds` (a dev warning, reported when a media handle is served). */
|
|
417
|
+
readonly mediaWithoutKinds: boolean;
|
|
418
|
+
/** Media nodes exist and the host opted out of the content-reference guard (`contentRefArgs: "none"`). */
|
|
419
|
+
readonly refsUnguarded: boolean;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* The server half of split hydration: answer a BATCH of handle loads for `exp` in one pass — one
|
|
424
|
+
* clock read (`opts.now`, else `Date.now()` once), one step memo in front of one call cap
|
|
425
|
+
* (`maxConcurrentCalls`, default 32), so identical steps across the batch's providers run once.
|
|
426
|
+
* The memo is keyed by principal too, and one call serves one principal (`opts.principal`), so
|
|
427
|
+
* two viewers never share a call. Every failure, a malformed request included, is a result, one per request
|
|
428
|
+
* in request order (a batch that is not a list gets one `invalid-request`) — and every finding a
|
|
429
|
+
* client receives is its PUBLIC form (`publicFinding`); the full one goes to `onFinding`. Rejects
|
|
430
|
+
* with `TypeError` only for a server misconfiguration (`maxBatch`, `maxConcurrentCalls`).
|
|
431
|
+
*/
|
|
432
|
+
export async function serveHandles(
|
|
433
|
+
exp: Experience,
|
|
434
|
+
requests: readonly HandleLoadRequest[],
|
|
435
|
+
opts: ServeOptions
|
|
436
|
+
): Promise<HandleLoadResult[]> {
|
|
437
|
+
// Server configuration, not client input: a bad bound is a programming error, and loud.
|
|
438
|
+
assertContentRefConfig(opts.contentRefArgs, "serveHandles");
|
|
439
|
+
const max = opts.maxBatch ?? DEFAULT_MAX_BATCH;
|
|
440
|
+
if (!Number.isInteger(max) || max < 1)
|
|
441
|
+
throw new TypeError(`serveHandles: maxBatch must be a positive integer, got ${String(max)}`);
|
|
442
|
+
const cap = opts.maxConcurrentCalls ?? DEFAULT_MAX_CONCURRENT_CALLS;
|
|
443
|
+
if (!Number.isInteger(cap) || cap < 1)
|
|
444
|
+
throw new TypeError(
|
|
445
|
+
`serveHandles: maxConcurrentCalls must be a positive integer, got ${String(cap)}`
|
|
446
|
+
);
|
|
447
|
+
const batch = reporter(opts, "");
|
|
448
|
+
if (!Array.isArray(requests))
|
|
449
|
+
return [
|
|
450
|
+
publicResult(
|
|
451
|
+
undefined,
|
|
452
|
+
refuse("invalid-request", "a batch load is a list of load requests"),
|
|
453
|
+
batch
|
|
454
|
+
),
|
|
455
|
+
];
|
|
456
|
+
if (requests.length > max) {
|
|
457
|
+
const tooMany = publicResult(
|
|
458
|
+
undefined,
|
|
459
|
+
refuse(
|
|
460
|
+
"batch-too-large",
|
|
461
|
+
`a batch of ${requests.length} requests exceeds the bound of ${max}`
|
|
462
|
+
),
|
|
463
|
+
batch
|
|
464
|
+
);
|
|
465
|
+
return requests.map(() => tooMany);
|
|
466
|
+
}
|
|
467
|
+
// Checked before anything walks the tree for handles or kinds.
|
|
468
|
+
if (treeDepth(exp.root) > MAX_TREE_DEPTH) {
|
|
469
|
+
const deep = publicResult(
|
|
470
|
+
undefined,
|
|
471
|
+
{ state: "error", finding: tooDeep(exp.experienceId, MAX_TREE_DEPTH) },
|
|
472
|
+
batch
|
|
473
|
+
);
|
|
474
|
+
return requests.map(() => deep);
|
|
475
|
+
}
|
|
476
|
+
let pass: ServePass;
|
|
477
|
+
try {
|
|
478
|
+
pass = openPass(exp, opts);
|
|
479
|
+
} catch (e) {
|
|
480
|
+
const failed = publicResult(undefined, refuse("hydration-internal", errorMessage(e)), batch);
|
|
481
|
+
return requests.map(() => failed);
|
|
482
|
+
}
|
|
483
|
+
return Promise.all(
|
|
484
|
+
requests.map(async (r) => {
|
|
485
|
+
// Snapshotted ONCE, inside a guard: a hostile getter or Proxy costs this request, not the
|
|
486
|
+
// batch. `experienceId`/`versionHash`/`handle` are primitives, captured by value right here.
|
|
487
|
+
// `params`, an object, is NOT safe by the same copy — a field holding a live reference can
|
|
488
|
+
// still answer a later read differently — so `serveOne` takes its own single deep-copy-and-
|
|
489
|
+
// validate pass over it (`snapshotParams`) before anything reads a key of it twice.
|
|
490
|
+
let snapshot: HandleLoadRequest | null;
|
|
491
|
+
try {
|
|
492
|
+
snapshot =
|
|
493
|
+
r !== null && typeof r === "object"
|
|
494
|
+
? {
|
|
495
|
+
experienceId: r.experienceId,
|
|
496
|
+
versionHash: r.versionHash,
|
|
497
|
+
handle: r.handle,
|
|
498
|
+
params: r.params,
|
|
499
|
+
}
|
|
500
|
+
: null;
|
|
501
|
+
} catch {
|
|
502
|
+
const report = reporter(opts, "");
|
|
503
|
+
return publicResult(
|
|
504
|
+
undefined,
|
|
505
|
+
refuse("invalid-request", "the request cannot be read"),
|
|
506
|
+
report
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
const handle = typeof snapshot?.handle === "string" ? snapshot.handle : undefined;
|
|
510
|
+
const report = reporter(opts, handle ?? "");
|
|
511
|
+
return publicResult(handle, await serveOne(exp, snapshot, opts, pass, report), report);
|
|
512
|
+
})
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Answer ONE handle load for `exp` — a batch of one (`serveHandles`). `opts.now` should be the
|
|
518
|
+
* server's clock: relative time is resolved here, never on the client.
|
|
519
|
+
*/
|
|
520
|
+
export async function serveHandle(
|
|
521
|
+
exp: Experience,
|
|
522
|
+
request: HandleLoadRequest,
|
|
523
|
+
opts: ServeOptions
|
|
524
|
+
): Promise<HandleLoadResult> {
|
|
525
|
+
const [result] = await serveHandles(exp, [request], opts);
|
|
526
|
+
return result ?? refuse("hydration-internal", "no result for the request");
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function openPass(exp: Experience, opts: ServeOptions): ServePass {
|
|
530
|
+
const { outputKinds, invoker, now, signal, principal } = opts;
|
|
531
|
+
const capped: TransientInvoker = limitInvoker(
|
|
532
|
+
invoker,
|
|
533
|
+
opts.maxConcurrentCalls ?? DEFAULT_MAX_CONCURRENT_CALLS
|
|
534
|
+
);
|
|
535
|
+
const exec: ExecuteOptions = {
|
|
536
|
+
// One memo per batch, in front of the cap: a memo hit never takes a slot.
|
|
537
|
+
invoker: memoInvoker(capped),
|
|
538
|
+
now: now ?? Date.now(),
|
|
539
|
+
...(signal !== undefined && { signal }),
|
|
540
|
+
...(principal !== undefined && { principal }),
|
|
541
|
+
...(opts.concurrency !== undefined && { concurrency: opts.concurrency }),
|
|
542
|
+
...(opts.captureSample !== undefined && { captureSample: opts.captureSample }),
|
|
543
|
+
...(opts.argumentHook !== undefined && { argumentHook: opts.argumentHook }),
|
|
544
|
+
...(opts.contentRefArgs !== "none" && { contentRefArgs: opts.contentRefArgs }),
|
|
545
|
+
...(opts.acceptContentRefParam !== undefined && {
|
|
546
|
+
acceptContentRefParam: opts.acceptContentRefParam,
|
|
547
|
+
}),
|
|
548
|
+
...(opts.inputDrift !== undefined && { inputDrift: opts.inputDrift }),
|
|
549
|
+
...(opts.reshapers !== undefined && { reshapers: opts.reshapers }),
|
|
550
|
+
...(opts.providers !== undefined && { providers: opts.providers }),
|
|
551
|
+
...(outputKinds?.catalog.valueContracts !== undefined && {
|
|
552
|
+
contracts: outputKinds.catalog.valueContracts,
|
|
553
|
+
}),
|
|
554
|
+
};
|
|
555
|
+
return {
|
|
556
|
+
exec,
|
|
557
|
+
// `bound` is handle-id collection only (no class read); `restricted` below takes the table.
|
|
558
|
+
bound: new Set(experienceHandles(exp).map((u) => u.handle)),
|
|
559
|
+
restricted: new Set(
|
|
560
|
+
partitionByViewer(
|
|
561
|
+
experienceHandles(exp, opts.declaredToolClasses, opts.undeclared),
|
|
562
|
+
snapshotViewer(opts.viewer, opts.audience)
|
|
563
|
+
).restricted.map((u) => u.handle)
|
|
564
|
+
),
|
|
565
|
+
// U9: media-typed nodes mark their handle as media even with NO `outputKinds` (fail closed).
|
|
566
|
+
kinds: handleKindsWithMedia(exp, outputKinds?.catalog, outputKinds?.surface),
|
|
567
|
+
mediaWithoutKinds: outputKinds === undefined && hasMediaTypeNodes(exp),
|
|
568
|
+
refsUnguarded: opts.contentRefArgs === "none" && hasMediaTypeNodes(exp),
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async function serveOne(
|
|
573
|
+
exp: Experience,
|
|
574
|
+
request: HandleLoadRequest | null,
|
|
575
|
+
opts: ServeOptions,
|
|
576
|
+
pass: ServePass,
|
|
577
|
+
report: Report
|
|
578
|
+
): Promise<HandleLoadResult> {
|
|
579
|
+
try {
|
|
580
|
+
if (request === null || typeof request !== "object" || typeof request.handle !== "string")
|
|
581
|
+
return refuse("invalid-request", "a load request names a handle");
|
|
582
|
+
if (request.experienceId !== exp.experienceId || request.versionHash !== exp.versionHash)
|
|
583
|
+
return refuse(
|
|
584
|
+
"version-mismatch",
|
|
585
|
+
`the request is for ${String(request.experienceId)}@${String(request.versionHash)}, not ${exp.experienceId}@${exp.versionHash}`
|
|
586
|
+
);
|
|
587
|
+
const handle = request.handle;
|
|
588
|
+
if (isReservedHandle(handle)) return { state: "error", finding: reservedFinding(handle) };
|
|
589
|
+
// Checked first, before the provider is even looked up: a restricted handle is never loaded,
|
|
590
|
+
// cached or invoked, for any request, whatever its params.
|
|
591
|
+
if (pass.restricted.has(handle)) {
|
|
592
|
+
// The server's log gets the true reason ONCE; the client's answer is the public
|
|
593
|
+
// `unknown-handle`, which is silenced so it is not logged as a second, contradicting finding.
|
|
594
|
+
report(restrictedFinding());
|
|
595
|
+
const answer = refuse("unknown-handle", `"${handle}" is not a handle this experience binds`);
|
|
596
|
+
if (answer.finding !== undefined) report.silence(answer.finding);
|
|
597
|
+
return answer;
|
|
598
|
+
}
|
|
599
|
+
// Only what a node binds is served: a provider no node binds is not the client's to ask for.
|
|
600
|
+
const declared =
|
|
601
|
+
pass.bound.has(handle) && Object.hasOwn(exp.providers, handle)
|
|
602
|
+
? exp.providers[handle]
|
|
603
|
+
: undefined;
|
|
604
|
+
if (declared === undefined)
|
|
605
|
+
return refuse("unknown-handle", `"${handle}" is not a handle this experience binds`);
|
|
606
|
+
// U9: a handle feeding a media widget is ALWAYS contract-checked, whatever the provider
|
|
607
|
+
// declared (a shape pin passes inline bytes or a locator into a client payload and the cache).
|
|
608
|
+
const mediaBound = (pass.kinds.get(handle) ?? []).some((k) => isMediaKind(k) || isChatKind(k));
|
|
609
|
+
const spec: ProviderSpec =
|
|
610
|
+
mediaBound && declared.outputCheck !== "contract"
|
|
611
|
+
? { ...declared, outputCheck: "contract" }
|
|
612
|
+
: declared;
|
|
613
|
+
if (mediaBound && pass.mediaWithoutKinds)
|
|
614
|
+
report({
|
|
615
|
+
code: "media-output-kinds-missing",
|
|
616
|
+
severity: "warn",
|
|
617
|
+
message:
|
|
618
|
+
"the experience has media nodes and serve has no outputKinds: media handles are contract-checked by node type, but pass outputKinds so kinds resolve through the catalogue",
|
|
619
|
+
});
|
|
620
|
+
if (mediaBound && pass.refsUnguarded)
|
|
621
|
+
report({
|
|
622
|
+
code: "media-content-refs-unguarded",
|
|
623
|
+
severity: "warn",
|
|
624
|
+
message:
|
|
625
|
+
'the experience has media nodes and serve runs with contentRefArgs: "none": a content-reference tool argument is not checked; derive the table with contentRefArgsFrom(capabilities)',
|
|
626
|
+
});
|
|
627
|
+
// ONE deep-copy-and-validate pass over the request's params (`snapshotParams`): the result is
|
|
628
|
+
// plain, detached data with no accessor of its own, so `relevantParams`/`pickParams` below can
|
|
629
|
+
// never re-trigger a caller-supplied getter that answered differently than the check just did.
|
|
630
|
+
const snap = snapshotParams(request.params ?? {});
|
|
631
|
+
if (!snap.ok) return { state: "error", finding: snap.finding };
|
|
632
|
+
const params = relevantParams(spec, snap.params);
|
|
633
|
+
// As in-process: only a contract provider's value is answerable to a kind.
|
|
634
|
+
const kinds = spec.outputCheck === "contract" ? (pass.kinds.get(handle) ?? []) : undefined;
|
|
635
|
+
const variant =
|
|
636
|
+
kinds === undefined
|
|
637
|
+
? { params }
|
|
638
|
+
: {
|
|
639
|
+
params,
|
|
640
|
+
kinds,
|
|
641
|
+
...(pass.exec.contracts !== undefined && { contracts: pass.exec.contracts }),
|
|
642
|
+
};
|
|
643
|
+
// U9b: the content-reference guard (gate included) runs BEFORE the cache is consulted: a cached
|
|
644
|
+
// value was produced for an argument some principal's gate accepted once, and serving it to another
|
|
645
|
+
// principal, or after the host revoked it, would skip the gate. A refusal is not served from the
|
|
646
|
+
// cache (the load below refuses it); a param that fills a guarded argument is cached per principal.
|
|
647
|
+
const refs = pass.exec.contentRefArgs;
|
|
648
|
+
const verdict =
|
|
649
|
+
refs === undefined
|
|
650
|
+
? undefined
|
|
651
|
+
: inspectContentRefs(
|
|
652
|
+
spec,
|
|
653
|
+
params,
|
|
654
|
+
contentRefEnv(refs, spec, pass.exec.acceptContentRefParam, {
|
|
655
|
+
principalId: opts.principalId,
|
|
656
|
+
principal: pass.exec.principal,
|
|
657
|
+
})
|
|
658
|
+
);
|
|
659
|
+
const cacheSpec = verdict?.paramFilled ? principalScoped(spec) : spec;
|
|
660
|
+
const hit =
|
|
661
|
+
verdict?.refusal === undefined
|
|
662
|
+
? opts.cache?.get(cacheSpec, opts.principalId, variant)
|
|
663
|
+
: undefined;
|
|
664
|
+
if (hit) return { state: "ready", value: hit.value };
|
|
665
|
+
const outcome = await executeProvider(spec, {
|
|
666
|
+
...pass.exec,
|
|
667
|
+
params,
|
|
668
|
+
...(opts.principalId !== undefined && { principalId: opts.principalId }),
|
|
669
|
+
...(kinds !== undefined && { kinds }),
|
|
670
|
+
});
|
|
671
|
+
for (const f of outcome.findings) report(f);
|
|
672
|
+
if (!outcome.ok) report(outcome.finding);
|
|
673
|
+
const drift = outcome.drift ?? [];
|
|
674
|
+
// Never cache a result that proceeded past an input drift (`report-and-proceed`): the key
|
|
675
|
+
// does not carry the drift mode, so a later request served under the default (`report`)
|
|
676
|
+
// would get it as a clean `ready` — no stale, no `onDrift`, no `onFinding`.
|
|
677
|
+
if (outcome.ok && drift.length === 0)
|
|
678
|
+
opts.cache?.set(cacheSpec, opts.principalId, outcome.value, variant);
|
|
679
|
+
if (drift.length > 0)
|
|
680
|
+
opts.onDrift?.({
|
|
681
|
+
experienceId: exp.experienceId,
|
|
682
|
+
versionHash: exp.versionHash,
|
|
683
|
+
handle,
|
|
684
|
+
spec,
|
|
685
|
+
drift,
|
|
686
|
+
findings: outcome.findings,
|
|
687
|
+
// Only a PROCEEDED input drift reaches here beside a delivered answer — a value, or an
|
|
688
|
+
// `empty` one (the retried upstream answered with no rows). `empty` itself is never drift.
|
|
689
|
+
proceeded: outcome.ok || outcome.finding.code === "upstream-empty",
|
|
690
|
+
});
|
|
691
|
+
return outcomeResult(outcome);
|
|
692
|
+
} catch (e) {
|
|
693
|
+
return refuse("hydration-internal", errorMessage(e));
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// ── The bound serve surface ───────────────────────────────────────────────────
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* `handleInventory`, `serveHandle` and `serveHandles` with the host's {@link ReadPolicy} bound
|
|
701
|
+
* ONCE. Every call applies it, so a host that wires the policy here cannot leak by forgetting it on
|
|
702
|
+
* one call (the inventory naming a handle the loader then refuses, or the reverse). A per-call
|
|
703
|
+
* `declaredToolClasses` / `undeclared` may only ADD to it (`mergeReadPolicy`): `{}` or `undefined`
|
|
704
|
+
* cannot lower what was bound. Prefer this to the free functions, which take the policy per call
|
|
705
|
+
* and are the low-level API.
|
|
706
|
+
*/
|
|
707
|
+
export interface ServeSurface {
|
|
708
|
+
/** The bound policy (a copy; read-only). */
|
|
709
|
+
readonly policy: ReadPolicy;
|
|
710
|
+
readonly handleInventory: (
|
|
711
|
+
exp: Experience,
|
|
712
|
+
opts: InventoryOptions
|
|
713
|
+
) => ReturnType<typeof handleInventory>;
|
|
714
|
+
readonly serveHandles: (
|
|
715
|
+
exp: Experience,
|
|
716
|
+
requests: readonly HandleLoadRequest[],
|
|
717
|
+
opts: ServeOptions
|
|
718
|
+
) => Promise<HandleLoadResult[]>;
|
|
719
|
+
readonly serveHandle: (
|
|
720
|
+
exp: Experience,
|
|
721
|
+
request: HandleLoadRequest,
|
|
722
|
+
opts: ServeOptions
|
|
723
|
+
) => Promise<HandleLoadResult>;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
export function createServeSurface(policy: ReadPolicy = {}): ServeSurface {
|
|
727
|
+
const bound = mergeReadPolicy(policy);
|
|
728
|
+
const joined = <
|
|
729
|
+
O extends { declaredToolClasses?: DeclaredToolClasses; undeclared?: UndeclaredToolPolicy },
|
|
730
|
+
>(
|
|
731
|
+
opts: O
|
|
732
|
+
): O => {
|
|
733
|
+
const m = mergeReadPolicy(bound, opts);
|
|
734
|
+
return {
|
|
735
|
+
...opts,
|
|
736
|
+
...(m.declaredToolClasses !== undefined && { declaredToolClasses: m.declaredToolClasses }),
|
|
737
|
+
...(m.undeclared !== undefined && { undeclared: m.undeclared }),
|
|
738
|
+
};
|
|
739
|
+
};
|
|
740
|
+
return {
|
|
741
|
+
policy: bound,
|
|
742
|
+
handleInventory: (exp, opts) => handleInventory(exp, joined(opts)),
|
|
743
|
+
serveHandles: (exp, requests, opts) => serveHandles(exp, requests, joined(opts)),
|
|
744
|
+
serveHandle: (exp, request, opts) => serveHandle(exp, request, joined(opts)),
|
|
745
|
+
};
|
|
746
|
+
}
|