@polyengine/runtime 0.1.0-pre.g633468a

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.
Files changed (126) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +23 -0
  3. package/esm/cabi/async_values.js +162 -0
  4. package/esm/cabi/bulk_lists.js +198 -0
  5. package/esm/cabi/context.js +42 -0
  6. package/esm/cabi/flatten.js +145 -0
  7. package/esm/cabi/float.js +67 -0
  8. package/esm/cabi/handles.js +253 -0
  9. package/esm/cabi/layout.js +149 -0
  10. package/esm/cabi/lift.js +196 -0
  11. package/esm/cabi/load.js +146 -0
  12. package/esm/cabi/lower.js +141 -0
  13. package/esm/cabi/memory.js +182 -0
  14. package/esm/cabi/mod.js +22 -0
  15. package/esm/cabi/store.js +186 -0
  16. package/esm/cabi/strings.js +336 -0
  17. package/esm/cabi/trap.js +38 -0
  18. package/esm/cabi/types.js +264 -0
  19. package/esm/cabi/values.js +64 -0
  20. package/esm/cache/core.js +156 -0
  21. package/esm/cache/dir.js +170 -0
  22. package/esm/cache/mod.js +4 -0
  23. package/esm/cache/web.js +136 -0
  24. package/esm/digest/digest.js +332 -0
  25. package/esm/digest/mod.js +3 -0
  26. package/esm/digest/verify.js +129 -0
  27. package/esm/embedder/casing.js +56 -0
  28. package/esm/embedder/copy.js +42 -0
  29. package/esm/embedder/errors.js +26 -0
  30. package/esm/embedder/imports.js +63 -0
  31. package/esm/embedder/instantiate.js +978 -0
  32. package/esm/embedder/mod.js +40 -0
  33. package/esm/embedder/resources.js +406 -0
  34. package/esm/embedder/streams.js +770 -0
  35. package/esm/embedder/values.js +450 -0
  36. package/esm/embedder/version.js +273 -0
  37. package/esm/exec/boundary.js +1902 -0
  38. package/esm/exec/executor.js +1004 -0
  39. package/esm/exec/host_streams.js +818 -0
  40. package/esm/exec/mod.js +4 -0
  41. package/esm/intrinsics/async_builtins.js +510 -0
  42. package/esm/intrinsics/context.js +90 -0
  43. package/esm/intrinsics/errors.js +13 -0
  44. package/esm/intrinsics/fact_calls.js +865 -0
  45. package/esm/intrinsics/mod.js +564 -0
  46. package/esm/intrinsics/stream_builtins.js +578 -0
  47. package/esm/intrinsics/transcode.js +443 -0
  48. package/esm/jspi/bridge.js +579 -0
  49. package/esm/jspi/mechanics.js +89 -0
  50. package/esm/jspi/mod.js +5 -0
  51. package/esm/jspi/suspending.js +15 -0
  52. package/esm/jspi/types.js +29 -0
  53. package/esm/package.json +3 -0
  54. package/esm/plan/format.js +5 -0
  55. package/esm/plan/loader.js +657 -0
  56. package/esm/plan/mod.js +3 -0
  57. package/esm/shim/mod.js +2 -0
  58. package/esm/shim/translator.js +94 -0
  59. package/esm/task/mod.js +483 -0
  60. package/esm/task/scheduler.js +1028 -0
  61. package/esm/task/streams.js +786 -0
  62. package/esm/task/subtask.js +135 -0
  63. package/esm/task/thread.js +255 -0
  64. package/esm/task/waitable.js +144 -0
  65. package/package.json +91 -0
  66. package/types/cabi/async_values.d.ts +35 -0
  67. package/types/cabi/bulk_lists.d.ts +18 -0
  68. package/types/cabi/context.d.ts +59 -0
  69. package/types/cabi/flatten.d.ts +14 -0
  70. package/types/cabi/float.d.ts +14 -0
  71. package/types/cabi/handles.d.ts +70 -0
  72. package/types/cabi/layout.d.ts +13 -0
  73. package/types/cabi/lift.d.ts +25 -0
  74. package/types/cabi/load.d.ts +14 -0
  75. package/types/cabi/lower.d.ts +10 -0
  76. package/types/cabi/memory.d.ts +58 -0
  77. package/types/cabi/mod.d.ts +15 -0
  78. package/types/cabi/store.d.ts +12 -0
  79. package/types/cabi/strings.d.ts +23 -0
  80. package/types/cabi/trap.d.ts +11 -0
  81. package/types/cabi/types.d.ts +206 -0
  82. package/types/cabi/values.d.ts +5 -0
  83. package/types/cache/core.d.ts +97 -0
  84. package/types/cache/dir.d.ts +6 -0
  85. package/types/cache/mod.d.ts +3 -0
  86. package/types/cache/web.d.ts +10 -0
  87. package/types/digest/digest.d.ts +17 -0
  88. package/types/digest/mod.d.ts +2 -0
  89. package/types/digest/verify.d.ts +48 -0
  90. package/types/embedder/casing.d.ts +40 -0
  91. package/types/embedder/copy.d.ts +24 -0
  92. package/types/embedder/errors.d.ts +11 -0
  93. package/types/embedder/imports.d.ts +47 -0
  94. package/types/embedder/instantiate.d.ts +88 -0
  95. package/types/embedder/mod.d.ts +11 -0
  96. package/types/embedder/resources.d.ts +158 -0
  97. package/types/embedder/streams.d.ts +202 -0
  98. package/types/embedder/values.d.ts +70 -0
  99. package/types/embedder/version.d.ts +85 -0
  100. package/types/exec/boundary.d.ts +360 -0
  101. package/types/exec/executor.d.ts +125 -0
  102. package/types/exec/host_streams.d.ts +165 -0
  103. package/types/exec/mod.d.ts +3 -0
  104. package/types/intrinsics/async_builtins.d.ts +69 -0
  105. package/types/intrinsics/context.d.ts +28 -0
  106. package/types/intrinsics/errors.d.ts +5 -0
  107. package/types/intrinsics/fact_calls.d.ts +120 -0
  108. package/types/intrinsics/mod.d.ts +187 -0
  109. package/types/intrinsics/stream_builtins.d.ts +113 -0
  110. package/types/intrinsics/transcode.d.ts +21 -0
  111. package/types/jspi/bridge.d.ts +227 -0
  112. package/types/jspi/mechanics.d.ts +50 -0
  113. package/types/jspi/mod.d.ts +3 -0
  114. package/types/jspi/suspending.d.ts +1 -0
  115. package/types/jspi/types.d.ts +26 -0
  116. package/types/plan/format.d.ts +369 -0
  117. package/types/plan/loader.d.ts +113 -0
  118. package/types/plan/mod.d.ts +2 -0
  119. package/types/shim/mod.d.ts +1 -0
  120. package/types/shim/translator.d.ts +55 -0
  121. package/types/task/mod.d.ts +257 -0
  122. package/types/task/scheduler.d.ts +421 -0
  123. package/types/task/streams.d.ts +370 -0
  124. package/types/task/subtask.d.ts +96 -0
  125. package/types/task/thread.d.ts +73 -0
  126. package/types/task/waitable.d.ts +67 -0
@@ -0,0 +1,136 @@
1
+ // Cache API backend for the artifact cache (docs/architecture.md §10), for browsers
2
+ // (and other environments implementing the standard `CacheStorage`/`Cache`
3
+ // interfaces). Feature-detects `globalThis.caches`; throws a named error
4
+ // where unavailable (e.g. plain Deno without `--unstable-*` polyfills, or a
5
+ // non-secure-context page) rather than silently no-op'ing.
6
+ //
7
+ // Layout: one synthetic same-origin-ish URL per cache key, stored as a
8
+ // single JSON `Response` body containing `{meta, plan, adapters}` (adapters
9
+ // base64-encoded — `Cache` stores `Response` bodies, not arbitrary trees,
10
+ // so we can't mirror dirCache's file-per-adapter layout; one blob per entry
11
+ // is the natural shape here).
12
+ import { CACHE_LAYOUT_VERSION, keyHex } from "./core.js";
13
+ import { loadPlan } from "../plan/loader.js";
14
+ /** Thrown when the Cache API isn't available in this environment. */
15
+ export class WebCacheUnavailableError extends Error {
16
+ constructor() {
17
+ super("artifact cache: the Cache API (globalThis.caches) is not available " +
18
+ "in this environment");
19
+ this.name = "WebCacheUnavailableError";
20
+ }
21
+ }
22
+ function toBase64(bytes) {
23
+ let bin = "";
24
+ for (const b of bytes)
25
+ bin += String.fromCharCode(b);
26
+ return btoa(bin);
27
+ }
28
+ function fromBase64(s) {
29
+ const bin = atob(s);
30
+ const out = new Uint8Array(bin.length);
31
+ for (let i = 0; i < bin.length; i++)
32
+ out[i] = bin.charCodeAt(i);
33
+ return out;
34
+ }
35
+ /** Synthetic request URL an entry is stored under. Same-origin-relative so
36
+ * it works under any page origin; the path has no filesystem meaning. */
37
+ function entryUrl(hex) {
38
+ return `https://artifact-cache.invalid/${hex}`;
39
+ }
40
+ class WebCache {
41
+ cacheName;
42
+ constructor(cacheName) {
43
+ this.cacheName = cacheName;
44
+ }
45
+ async open() {
46
+ if (typeof globalThis.caches === "undefined") {
47
+ throw new WebCacheUnavailableError();
48
+ }
49
+ return await globalThis.caches.open(this.cacheName);
50
+ }
51
+ /** Internal self-heal eviction (issue #196): the caller is `get`'s own
52
+ * recovery path for a poisoned/stale entry, not an explicit caller of
53
+ * `evict()` — so a failure here must not escape and fail what would
54
+ * otherwise be a clean miss. The public `evict()` below keeps throwing;
55
+ * only this internal path swallows. */
56
+ async #tryEvict(key) {
57
+ try {
58
+ await this.evict(key);
59
+ }
60
+ catch {
61
+ // Swallowed: see docs above.
62
+ }
63
+ }
64
+ async get(key) {
65
+ // `open()` failing (no `globalThis.caches` in this environment) is a
66
+ // capability/configuration error, not a per-entry I/O failure — it
67
+ // propagates uncaught (existing behavior, `WebCacheUnavailableError`)
68
+ // so a caller who tries to use this backend somewhere it can't work
69
+ // finds out immediately rather than silently always-missing. Once open
70
+ // succeeds, every failure below (issue #196: poisoned entry, self-heal
71
+ // eviction, ...) is swallowed to a `null` miss.
72
+ const cache = await this.open();
73
+ try {
74
+ const hex = await keyHex(key);
75
+ const resp = await cache.match(entryUrl(hex));
76
+ if (resp === undefined)
77
+ return null;
78
+ const entry = await resp.json();
79
+ const { meta, plan, adapters: adaptersB64 } = entry;
80
+ if (meta.layoutVersion !== CACHE_LAYOUT_VERSION) {
81
+ await this.#tryEvict(key);
82
+ return null;
83
+ }
84
+ if (meta.componentSha256 !== key.componentSha256 ||
85
+ meta.translatorBuildHash !== key.translatorBuildHash ||
86
+ JSON.stringify([...meta.features].sort()) !==
87
+ JSON.stringify([...key.features].sort()) ||
88
+ plan.component.sha256 !== key.componentSha256) {
89
+ await this.#tryEvict(key);
90
+ return null;
91
+ }
92
+ loadPlan(plan); // structural validation; throws on a corrupted plan
93
+ const adapters = new Map();
94
+ for (const [file, b64] of Object.entries(adaptersB64)) {
95
+ adapters.set(file, fromBase64(b64));
96
+ }
97
+ return { plan, adapters };
98
+ }
99
+ catch {
100
+ // Poisoned/corrupted entry, or any other I/O failure (issue #196):
101
+ // miss + best-effort evict, never trust and never throw out of
102
+ // `get`.
103
+ await this.#tryEvict(key);
104
+ return null;
105
+ }
106
+ }
107
+ async put(key, artifacts) {
108
+ const cache = await this.open();
109
+ const hex = await keyHex(key);
110
+ const meta = {
111
+ layoutVersion: CACHE_LAYOUT_VERSION,
112
+ componentSha256: key.componentSha256,
113
+ translatorBuildHash: key.translatorBuildHash,
114
+ features: key.features,
115
+ };
116
+ const adaptersB64 = {};
117
+ for (const [file, bytes] of artifacts.adapters) {
118
+ adaptersB64[file] = toBase64(bytes);
119
+ }
120
+ const entry = { meta, plan: artifacts.plan, adapters: adaptersB64 };
121
+ const body = JSON.stringify(entry);
122
+ await cache.put(entryUrl(hex), new Response(body, { headers: { "content-type": "application/json" } }));
123
+ }
124
+ async evict(key) {
125
+ const cache = await this.open();
126
+ const hex = await keyHex(key);
127
+ await cache.delete(entryUrl(hex));
128
+ }
129
+ }
130
+ /** A `Cache`-API-backed `ArtifactCache` under the given cache name. Throws
131
+ * `WebCacheUnavailableError` (at call time, not construction time, so
132
+ * feature-detection failures surface where they're actually hit) when
133
+ * `globalThis.caches` doesn't exist. */
134
+ export function webCache(name) {
135
+ return new WebCache(name);
136
+ }
@@ -0,0 +1,332 @@
1
+ // Canonical world digest — TypeScript side, computed from a *wire* plan
2
+ // (runtime/src/plan/format.ts's `WirePlan`, i.e. the shim's plan.json
3
+ // schema — contracts/plan-format.md). Counterpart:
4
+ // crates/bindgen/src/digest.rs, computed from `wit_parser::Resolve`. Both
5
+ // must produce byte-identical canonical JSON (and therefore identical
6
+ // sha256) for a structurally-equivalent world — this equality IS the
7
+ // design validation (see runtime/tests/digest_test.ts's cross-language
8
+ // fixture test). This resolves contracts/plan-format.md v0.1 amendment #7
9
+ // ("worldDigest needs redesign before M1 bindgen").
10
+ //
11
+ // THE NORMALIZATION SPEC IS DOCUMENTED ONCE, in crates/bindgen/src/digest.rs's
12
+ // module doc comment (kept in sync with this file) — read it first. Short
13
+ // version: sort import/export lists by name; keep everything else
14
+ // (record fields, variant cases, enum/flags label order, function
15
+ // parameter order) positional because it's ABI-relevant; drop parameter
16
+ // *labels* (not ABI-relevant, this runtime has no named-argument calling
17
+ // convention) and all docs/stability metadata (not present in the plan at
18
+ // all); identify resources by qualified name, not by table index.
19
+ //
20
+ // ## Known limitation (plan side only): resourceTables aliasing
21
+ //
22
+ // The plan's `own`/`borrow` `resource: N` indexes into `resourceTables`,
23
+ // and empirically (the `resources` fixture) an `own`/`borrow` occurrence in
24
+ // an exported function's type and the `{kind:"type"}` export that *names*
25
+ // that same nominal resource can reference two *different* resourceTables
26
+ // indices (one per component-linking instance boundary — the type export
27
+ // sits at the sub-instance that defines the resource, `own`/`borrow` sites
28
+ // reference the root instance's re-export of it). Resolving this in
29
+ // general requires walking wasmtime's resource-alias chain, which the plan
30
+ // format does not expose directly — this is exactly the "impedance between
31
+ // wit-parser's view and the plan's types" flagged for the §9 degraded-mode
32
+ // question in the track report. This implementation takes an honest
33
+ // shortcut: when a world has exactly one nominal resource type, every
34
+ // `own`/`borrow` occurrence (regardless of table index) is identified with
35
+ // that resource — correct for every world in the current fixture corpus
36
+ // (each has 0 or 1 resource types), but a world with 2+ resources throws a
37
+ // clearly-labeled `DigestError` rather than silently guessing.
38
+ // cewd = component-engine world digest, the project's pre-rebrand name; kept as an opaque wire constant.
39
+ export const CEWD_VERSION = 1;
40
+ export class DigestError extends Error {
41
+ constructor(message) {
42
+ super(message);
43
+ this.name = "DigestError";
44
+ }
45
+ }
46
+ /** Compute the canonical world digest from a loaded wire plan. */
47
+ export async function computeWorldDigest(plan) {
48
+ const resourceNames = buildResourceNameMap(plan);
49
+ const imports = plan.imports.map((imp) => canonImport(plan, imp, resourceNames));
50
+ const exports = plan.exports
51
+ .map((exp) => canonExportItem(plan, exp, resourceNames))
52
+ .filter((c) => c !== null);
53
+ const doc = {
54
+ cewd: CEWD_VERSION,
55
+ imports: sortByName(imports),
56
+ exports: sortByName(exports),
57
+ };
58
+ const canonicalJson = canonicalStringify(doc);
59
+ const digestBytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonicalJson));
60
+ const digest = "sha256:" + hex(digestBytes);
61
+ return { canonicalJson, digest };
62
+ }
63
+ function hex(buf) {
64
+ return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join("");
65
+ }
66
+ // ---------------------------------------------------------------------------
67
+ // Resource naming (plan resourceTables index -> qualified name)
68
+ // ---------------------------------------------------------------------------
69
+ function buildResourceNameMap(plan) {
70
+ // CONTRACT: plan-format.md v0.1 amendment #2 / v0.2 proposal
71
+ // `importedResources` (format.ts:23-33). An imported resource occupies
72
+ // `ResourceIndex` slots *before* every defined (own/exported) resource
73
+ // (`ResourceIndex = importedResources.length + DefinedResourceIndex`), and
74
+ // this implementation has no alias map from those imported-resource
75
+ // indices to a qualified name (no plan-format extension exists yet for
76
+ // that — see the module-level "Known limitation" comment). Silently
77
+ // aliasing an own/borrow reference to an imported resource with the lone
78
+ // exported resource's name (the pre-fix single-resource-world shortcut
79
+ // below) would produce a digest that matches a WIT world it is NOT
80
+ // ABI-compatible with — worse than an unresolved-index throw. Refuse
81
+ // conservatively whenever the plan declares any imported resources, full
82
+ // stop, regardless of how many named (exported) resources exist.
83
+ if (plan.importedResources !== undefined && plan.importedResources.length > 0) {
84
+ throw new DigestError(`digest: plan declares ${plan.importedResources.length} imported ` +
85
+ `resource(s); resolving which own/borrow occurrences reference an ` +
86
+ `imported resource (vs. a defined/exported one) requires an ` +
87
+ `alias map this plan format does not yet provide (see ` +
88
+ `runtime/src/digest/digest.ts module docs) — refusing rather than ` +
89
+ `risk a silently-wrong digest`);
90
+ }
91
+ const named = new Map(); // resourceTables index -> name, as directly declared by a type export
92
+ walkExportsForResourceNames(plan.exports, [], named);
93
+ if (named.size <= 1) {
94
+ // Single-resource (or zero-resource) world: identify EVERY resourceTables
95
+ // index with the one named resource, sidestepping the aliasing gap
96
+ // documented in the module comment above.
97
+ const theOne = named.size === 1 ? [...named.values()][0] : undefined;
98
+ const all = new Map();
99
+ if (theOne !== undefined) {
100
+ for (let i = 0; i < plan.resourceTables.length; i++)
101
+ all.set(i, theOne);
102
+ }
103
+ return all;
104
+ }
105
+ // Multi-resource world: table-index aliasing across instance boundaries
106
+ // is not resolved by this implementation. Fail loudly.
107
+ if (named.size < plan.resourceTables.length) {
108
+ throw new DigestError(`digest: world has ${named.size} named resource type(s) but ` +
109
+ `${plan.resourceTables.length} resourceTables entries, and more ` +
110
+ `than one named resource — resourceTables alias resolution across ` +
111
+ `component-linking instance boundaries is not implemented (see ` +
112
+ `runtime/src/digest/digest.ts module docs); cannot safely identify ` +
113
+ `which own/borrow occurrence means which resource`);
114
+ }
115
+ return named;
116
+ }
117
+ function walkExportsForResourceNames(exports, path, out) {
118
+ for (const exp of exports) {
119
+ if (exp.kind === "instance") {
120
+ walkExportsForResourceNames(exp.exports, [...path, exp.name], out);
121
+ }
122
+ else if (exp.kind === "type" && exp.type.kind === "resource") {
123
+ out.set(exp.type.resource, [...path, exp.name].join("/"));
124
+ }
125
+ }
126
+ }
127
+ // ---------------------------------------------------------------------------
128
+ // Canonicalization
129
+ // ---------------------------------------------------------------------------
130
+ function canonImport(plan, imp, resourceNames) {
131
+ // CONTRACT: the plan's `imports` list is flat (`{name, path, kind, type}`)
132
+ // even for interface-qualified imports (plan-format.md v0.1 amendment #4,
133
+ // "Untested: current corpus has no imports"); no fixture in this repo's
134
+ // sync corpus (hello/values/resources) has any imports, so this path is
135
+ // exercised by no test. Best-effort flattened-name treatment, chosen to
136
+ // be structurally analogous to the export side's nested naming without
137
+ // requiring the executor to reconstruct nested WorldItem::Interface shape
138
+ // from a flat list. Revisit when a corpus component actually imports
139
+ // something (flagged in the track report for §9's degraded-mode
140
+ // question).
141
+ const name = imp.path.length > 0 ? [...imp.path, imp.name].join("/") : imp.name;
142
+ if (imp.kind === "func" && imp.type !== undefined) {
143
+ return {
144
+ kind: "func",
145
+ name,
146
+ func: canonFuncType(plan, plan.types[imp.type], resourceNames),
147
+ };
148
+ }
149
+ return { kind: imp.kind, name };
150
+ }
151
+ function canonExportItem(plan, exp, resourceNames) {
152
+ if (exp.kind === "lifted-func") {
153
+ return {
154
+ kind: "func",
155
+ name: exp.name,
156
+ func: canonFuncType(plan, plan.types[exp.type], resourceNames),
157
+ };
158
+ }
159
+ if (exp.kind === "instance") {
160
+ return {
161
+ kind: "instance",
162
+ name: exp.name,
163
+ items: sortByName(exp.exports
164
+ .map((e) => canonExportItem(plan, e, resourceNames))
165
+ .filter((c) => c !== null)),
166
+ };
167
+ }
168
+ if (exp.kind === "module") {
169
+ // digest.md's item rule: only functions and resources contribute as
170
+ // export items. A module export is not WIT-expressible (bindgen can
171
+ // never emit a digest containing one) and does not affect
172
+ // positional-calling ABI shape, so it is excluded — plan-format.md v4
173
+ // amendment 2.
174
+ return null;
175
+ }
176
+ // exp.kind === "type"
177
+ if (exp.type.kind === "resource") {
178
+ return { kind: "resource", name: exp.name };
179
+ }
180
+ // Non-resource named types (records/variants/etc. declared at
181
+ // interface/world scope) do not themselves appear as component exports —
182
+ // only functions and resources do — so they are intentionally NOT
183
+ // emitted as a top-level item here; they still affect the digest via
184
+ // whichever function signatures reference them. Mirrors
185
+ // crates/bindgen/src/digest.rs's `canon_interface`/`canon_items` (both
186
+ // skip non-resource `TypeDefKind` entries with the identical rationale).
187
+ // Caller must filter these out of the containing list (see canonExportItem's
188
+ // caller / the `null`-sentinel handling below), since this returns a
189
+ // JSON value, not `undefined`, when called directly.
190
+ return null;
191
+ }
192
+ function canonFuncType(plan, decl, resourceNames) {
193
+ if (decl.kind !== "func") {
194
+ throw new DigestError(`digest: expected a func type declaration, got kind ${decl.kind}`);
195
+ }
196
+ return {
197
+ // Parameter LABELS excluded deliberately (see module docs); only
198
+ // positional types survive into the digest.
199
+ params: decl.params.map((p) => canonValType(p.type, resourceNames)),
200
+ results: decl.results.map((r) => canonValType(r, resourceNames)),
201
+ async: decl.async,
202
+ };
203
+ }
204
+ function canonValType(t, resourceNames) {
205
+ switch (t.kind) {
206
+ case "bool":
207
+ case "s8":
208
+ case "u8":
209
+ case "s16":
210
+ case "u16":
211
+ case "s32":
212
+ case "u32":
213
+ case "s64":
214
+ case "u64":
215
+ case "f32":
216
+ case "f64":
217
+ case "char":
218
+ case "string":
219
+ case "error-context":
220
+ return { kind: t.kind };
221
+ case "list":
222
+ return {
223
+ kind: "list",
224
+ element: canonValType(t.element, resourceNames),
225
+ ...(t.length !== undefined ? { length: t.length } : {}),
226
+ };
227
+ case "record":
228
+ return {
229
+ kind: "record",
230
+ fields: t.fields.map((f) => ({
231
+ label: f.label,
232
+ type: canonValType(f.type, resourceNames),
233
+ })),
234
+ };
235
+ case "tuple":
236
+ return {
237
+ kind: "tuple",
238
+ elements: t.elements.map((e) => canonValType(e, resourceNames)),
239
+ };
240
+ case "variant":
241
+ return {
242
+ kind: "variant",
243
+ cases: t.cases.map((c) => ({
244
+ label: c.label,
245
+ type: c.type === null ? null : canonValType(c.type, resourceNames),
246
+ })),
247
+ };
248
+ case "enum":
249
+ return { kind: "enum", labels: [...t.labels] };
250
+ case "option":
251
+ return { kind: "option", type: canonValType(t.type, resourceNames) };
252
+ case "result":
253
+ return {
254
+ kind: "result",
255
+ ok: t.ok === null ? null : canonValType(t.ok, resourceNames),
256
+ err: t.err === null ? null : canonValType(t.err, resourceNames),
257
+ };
258
+ case "map":
259
+ // Despecialized to list<tuple<K,V>> — matches crates/bindgen/src/
260
+ // digest.rs's treatment of wit_parser's `TypeDefKind::Map`. Fixture-
261
+ // only path (descriptor-ir.md "Open items": not emitted by current
262
+ // translators), unexercised by the sync corpus.
263
+ return {
264
+ kind: "list",
265
+ element: {
266
+ kind: "tuple",
267
+ elements: [
268
+ canonValType(t.key, resourceNames),
269
+ canonValType(t.value, resourceNames),
270
+ ],
271
+ },
272
+ };
273
+ case "flags":
274
+ return { kind: "flags", labels: [...t.labels] };
275
+ case "own":
276
+ case "borrow": {
277
+ const name = resourceNames.get(t.resource);
278
+ if (name === undefined) {
279
+ throw new DigestError(`digest: ${t.kind}<T> references resourceTables[${t.resource}], ` +
280
+ `which has no resolvable qualified name`);
281
+ }
282
+ return { kind: t.kind, resource: name };
283
+ }
284
+ case "stream":
285
+ return {
286
+ kind: "stream",
287
+ element: t.element === null ? null : canonValType(t.element, resourceNames),
288
+ };
289
+ case "future":
290
+ return {
291
+ kind: "future",
292
+ element: t.element === null ? null : canonValType(t.element, resourceNames),
293
+ };
294
+ default: {
295
+ const exhaustive = t;
296
+ throw new DigestError(`digest: unknown ValType kind ${exhaustive.kind}`);
297
+ }
298
+ }
299
+ }
300
+ function sortByName(items) {
301
+ // `<`/`>` here is UTF-16 code-unit ordering (no `localeCompare`); the Rust
302
+ // side (`crates/bindgen/src/digest.rs::sort_by_name`) uses `str::cmp`
303
+ // (byte-wise/UTF-8). These orderings diverge in general, but WIT
304
+ // identifiers are ASCII-only (kebab-case `[a-z0-9-]`), where byte-wise and
305
+ // UTF-16-code-unit ordering coincide — so this is not a latent bug; don't
306
+ // "fix" one side alone if that assumption changes.
307
+ return [...items].sort((a, b) => {
308
+ const an = typeof a?.name === "string" ? a.name : "";
309
+ const bn = typeof b?.name === "string" ? b.name : "";
310
+ return an < bn ? -1 : an > bn ? 1 : 0;
311
+ });
312
+ }
313
+ /**
314
+ * Recursively sort object keys (alphabetically) and serialize with no extra
315
+ * whitespace. Array order is preserved verbatim — this must match
316
+ * `crates/bindgen/src/digest.rs::canonical_string` byte-for-byte.
317
+ */
318
+ export function canonicalStringify(v) {
319
+ return JSON.stringify(sortKeysDeep(v));
320
+ }
321
+ // deno-lint-ignore no-explicit-any
322
+ function sortKeysDeep(v) {
323
+ if (Array.isArray(v))
324
+ return v.map(sortKeysDeep);
325
+ if (v !== null && typeof v === "object") {
326
+ const out = {};
327
+ for (const k of Object.keys(v).sort())
328
+ out[k] = sortKeysDeep(v[k]);
329
+ return out;
330
+ }
331
+ return v;
332
+ }
@@ -0,0 +1,3 @@
1
+ // Runtime digest handshake — public surface (docs/architecture.md §9).
2
+ export * from "./digest.js";
3
+ export * from "./verify.js";
@@ -0,0 +1,129 @@
1
+ // Runtime handshake (docs/architecture.md §9): verify a loaded plan's world against the
2
+ // digest embedded by generated bindgen code, producing a rich mismatch
3
+ // report that names the first divergent import/export/type path rather
4
+ // than just "digests differ".
5
+ import { computeWorldDigest } from "./digest.js";
6
+ /**
7
+ * Thrown by generated `instantiate` wrappers when the loaded plan's world
8
+ * digest does not match the constant bindgen embedded at generation time
9
+ * (contracts/digest.md: "fails fast with a structural diff on mismatch").
10
+ * Raised BEFORE the component is instantiated, so no guest code has run
11
+ * when a caller catches this.
12
+ *
13
+ * Named and catchable: `err instanceof WorldDigestMismatchError`, or
14
+ * `err.name === "WorldDigestMismatchError"` across realms.
15
+ */
16
+ export class WorldDigestMismatchError extends Error {
17
+ name = "WorldDigestMismatchError";
18
+ /** The world these bindings were generated from. */
19
+ world;
20
+ /** The full mismatch report (expected/actual digest, first divergence). */
21
+ mismatch;
22
+ constructor(world, mismatch) {
23
+ super(`world digest mismatch for \`${world}\`: bindings expect ` +
24
+ `${mismatch.expected}, loaded plan computes ${mismatch.actual}` +
25
+ (mismatch.firstDivergence
26
+ ? ` (first divergence: ${mismatch.firstDivergence})`
27
+ : "") +
28
+ " — regenerate the bindings from the component's WIT");
29
+ this.world = world;
30
+ this.mismatch = mismatch;
31
+ }
32
+ /** Digest the generated bindings were built against. */
33
+ get expected() {
34
+ return this.mismatch.expected;
35
+ }
36
+ /** Digest computed from the plan actually loaded. */
37
+ get actual() {
38
+ return this.mismatch.actual;
39
+ }
40
+ }
41
+ /**
42
+ * Verify `plan`'s computed world digest against `expectedDigest` (the
43
+ * constant bindgen embedded at generation time). Returns `null` on match,
44
+ * or a `DigestMismatch` report naming the first divergent path on
45
+ * mismatch.
46
+ */
47
+ export async function verifyWorldDigest(plan, expectedDigest) {
48
+ const actual = await computeWorldDigest(plan);
49
+ if (actual.digest === expectedDigest)
50
+ return null;
51
+ return {
52
+ expected: expectedDigest,
53
+ actual: actual.digest,
54
+ firstDivergence: null, // filled in by compareAgainstExpectedJson if available
55
+ };
56
+ }
57
+ /**
58
+ * `diffWorldDigest` — richer variant for tests/tooling: compare against
59
+ * another plan's (or a WIT-derived) canonical JSON directly, walking both
60
+ * trees in parallel to name the first divergent import/export/type path.
61
+ * `expectedCanonicalJson` is normally produced by `crates/bindgen`'s
62
+ * `digest --json` output, or by `computeWorldDigest` on a reference plan.
63
+ */
64
+ export async function diffWorldDigest(plan, expectedCanonicalJson) {
65
+ const actual = await computeWorldDigest(plan);
66
+ const expected = JSON.parse(expectedCanonicalJson);
67
+ const expectedDigestBytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(expectedCanonicalJson));
68
+ const expectedDigest = "sha256:" +
69
+ Array.from(new Uint8Array(expectedDigestBytes)).map((b) => b.toString(16).padStart(2, "0")).join("");
70
+ if (actual.digest === expectedDigest)
71
+ return null;
72
+ const actualParsed = JSON.parse(actual.canonicalJson);
73
+ return {
74
+ expected: expectedDigest,
75
+ actual: actual.digest,
76
+ firstDivergence: firstDivergentPath(expected, actualParsed, "$"),
77
+ };
78
+ }
79
+ /**
80
+ * Walk two canonical world trees in parallel (both already sorted by name
81
+ * at every `imports`/`exports`/`items` level — see digest.ts), returning a
82
+ * human-readable path to the first field that differs, or `null` if the
83
+ * trees are structurally identical (shouldn't happen if the digests
84
+ * differ, but the digest is a hash — collisions or caller error are
85
+ * possible, so this is a defensive `null`, not a promise of "same").
86
+ */
87
+ function firstDivergentPath(expected, actual, path) {
88
+ if (Array.isArray(expected) && Array.isArray(actual)) {
89
+ if (expected.length !== actual.length) {
90
+ return `${path}: length ${expected.length} (expected) vs ${actual.length} (actual)`;
91
+ }
92
+ for (let i = 0; i < expected.length; i++) {
93
+ const label = itemLabel(expected[i]) ?? `[${i}]`;
94
+ const d = firstDivergentPath(expected[i], actual[i], `${path}.${label}`);
95
+ if (d)
96
+ return d;
97
+ }
98
+ return null;
99
+ }
100
+ if (expected !== null && actual !== null &&
101
+ typeof expected === "object" && typeof actual === "object" &&
102
+ !Array.isArray(expected) && !Array.isArray(actual)) {
103
+ const e = expected;
104
+ const a = actual;
105
+ const keys = new Set([...Object.keys(e), ...Object.keys(a)]);
106
+ for (const k of [...keys].sort()) {
107
+ if (!(k in a))
108
+ return `${path}.${k}: present (expected) but missing (actual)`;
109
+ if (!(k in e))
110
+ return `${path}.${k}: missing (expected) but present (actual)`;
111
+ const d = firstDivergentPath(e[k], a[k], `${path}.${k}`);
112
+ if (d)
113
+ return d;
114
+ }
115
+ return null;
116
+ }
117
+ if (expected !== actual) {
118
+ return `${path}: ${JSON.stringify(expected)} (expected) vs ${JSON.stringify(actual)} (actual)`;
119
+ }
120
+ return null;
121
+ }
122
+ function itemLabel(v) {
123
+ if (v !== null && typeof v === "object" && "name" in v) {
124
+ const name = v.name;
125
+ if (typeof name === "string")
126
+ return JSON.stringify(name);
127
+ }
128
+ return undefined;
129
+ }
@@ -0,0 +1,56 @@
1
+ // WIT label -> JS identifier casing, and the mangled export/import leaf
2
+ // grammar that carries resource membership.
3
+ //
4
+ // Governing contract: contracts/embedder-api.md §"Naming and casing".
5
+ // Casing applies to *identifiers* only — function/method/static names, record
6
+ // fields, flag names, resource class names. It NEVER applies to data: enum
7
+ // values, variant/result case tags and interface ids stay kebab-case verbatim.
8
+ /**
9
+ * `get-resolution` -> `getResolution`.
10
+ *
11
+ * The rule, stated exactly: split the label on `-`; the first fragment is
12
+ * unchanged; every later fragment has its first character upper-cased and its
13
+ * remainder preserved. Preserving the remainder is what keeps acronym
14
+ * fragments intact — `outgoing-HTTP-request` -> `outgoingHTTPRequest` — which
15
+ * a naive `toLowerCase()` of the tail would destroy.
16
+ *
17
+ * WIT labels are already lower-kebab in practice, so the first fragment needs
18
+ * no adjustment; nothing here lower-cases anything.
19
+ */
20
+ export function camelCase(label) {
21
+ const parts = label.split("-");
22
+ return parts[0] + parts.slice(1).map(upperFirst).join("");
23
+ }
24
+ /** `tcp-socket` -> `TcpSocket` (resource class names). */
25
+ export function pascalCase(label) {
26
+ return label.split("-").map(upperFirst).join("");
27
+ }
28
+ function upperFirst(s) {
29
+ return s.length === 0 ? s : s[0].toUpperCase() + s.slice(1);
30
+ }
31
+ const MANGLED = /^\[([a-z-]+)\](.*)$/;
32
+ /** Decode a mangled leaf name; unmangled names come back as `plain`. */
33
+ export function parseLeafName(raw) {
34
+ const m = MANGLED.exec(raw);
35
+ if (m === null)
36
+ return { form: "plain", name: raw };
37
+ const [, tag, rest] = m;
38
+ switch (tag) {
39
+ case "constructor":
40
+ return { form: "constructor", resource: rest };
41
+ case "method":
42
+ case "static": {
43
+ const dot = rest.indexOf(".");
44
+ if (dot < 0)
45
+ break;
46
+ return {
47
+ form: tag,
48
+ resource: rest.slice(0, dot),
49
+ member: rest.slice(dot + 1),
50
+ };
51
+ }
52
+ }
53
+ // Unknown bracket forms (`[async]`, `[dtor]`, future spellings) are left
54
+ // alone rather than guessed at: they surface verbatim, which is loud.
55
+ return { form: "plain", name: raw };
56
+ }