@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.
- package/LICENSE +202 -0
- package/README.md +23 -0
- package/esm/cabi/async_values.js +162 -0
- package/esm/cabi/bulk_lists.js +198 -0
- package/esm/cabi/context.js +42 -0
- package/esm/cabi/flatten.js +145 -0
- package/esm/cabi/float.js +67 -0
- package/esm/cabi/handles.js +253 -0
- package/esm/cabi/layout.js +149 -0
- package/esm/cabi/lift.js +196 -0
- package/esm/cabi/load.js +146 -0
- package/esm/cabi/lower.js +141 -0
- package/esm/cabi/memory.js +182 -0
- package/esm/cabi/mod.js +22 -0
- package/esm/cabi/store.js +186 -0
- package/esm/cabi/strings.js +336 -0
- package/esm/cabi/trap.js +38 -0
- package/esm/cabi/types.js +264 -0
- package/esm/cabi/values.js +64 -0
- package/esm/cache/core.js +156 -0
- package/esm/cache/dir.js +170 -0
- package/esm/cache/mod.js +4 -0
- package/esm/cache/web.js +136 -0
- package/esm/digest/digest.js +332 -0
- package/esm/digest/mod.js +3 -0
- package/esm/digest/verify.js +129 -0
- package/esm/embedder/casing.js +56 -0
- package/esm/embedder/copy.js +42 -0
- package/esm/embedder/errors.js +26 -0
- package/esm/embedder/imports.js +63 -0
- package/esm/embedder/instantiate.js +978 -0
- package/esm/embedder/mod.js +40 -0
- package/esm/embedder/resources.js +406 -0
- package/esm/embedder/streams.js +770 -0
- package/esm/embedder/values.js +450 -0
- package/esm/embedder/version.js +273 -0
- package/esm/exec/boundary.js +1902 -0
- package/esm/exec/executor.js +1004 -0
- package/esm/exec/host_streams.js +818 -0
- package/esm/exec/mod.js +4 -0
- package/esm/intrinsics/async_builtins.js +510 -0
- package/esm/intrinsics/context.js +90 -0
- package/esm/intrinsics/errors.js +13 -0
- package/esm/intrinsics/fact_calls.js +865 -0
- package/esm/intrinsics/mod.js +564 -0
- package/esm/intrinsics/stream_builtins.js +578 -0
- package/esm/intrinsics/transcode.js +443 -0
- package/esm/jspi/bridge.js +579 -0
- package/esm/jspi/mechanics.js +89 -0
- package/esm/jspi/mod.js +5 -0
- package/esm/jspi/suspending.js +15 -0
- package/esm/jspi/types.js +29 -0
- package/esm/package.json +3 -0
- package/esm/plan/format.js +5 -0
- package/esm/plan/loader.js +657 -0
- package/esm/plan/mod.js +3 -0
- package/esm/shim/mod.js +2 -0
- package/esm/shim/translator.js +94 -0
- package/esm/task/mod.js +483 -0
- package/esm/task/scheduler.js +1028 -0
- package/esm/task/streams.js +786 -0
- package/esm/task/subtask.js +135 -0
- package/esm/task/thread.js +255 -0
- package/esm/task/waitable.js +144 -0
- package/package.json +91 -0
- package/types/cabi/async_values.d.ts +35 -0
- package/types/cabi/bulk_lists.d.ts +18 -0
- package/types/cabi/context.d.ts +59 -0
- package/types/cabi/flatten.d.ts +14 -0
- package/types/cabi/float.d.ts +14 -0
- package/types/cabi/handles.d.ts +70 -0
- package/types/cabi/layout.d.ts +13 -0
- package/types/cabi/lift.d.ts +25 -0
- package/types/cabi/load.d.ts +14 -0
- package/types/cabi/lower.d.ts +10 -0
- package/types/cabi/memory.d.ts +58 -0
- package/types/cabi/mod.d.ts +15 -0
- package/types/cabi/store.d.ts +12 -0
- package/types/cabi/strings.d.ts +23 -0
- package/types/cabi/trap.d.ts +11 -0
- package/types/cabi/types.d.ts +206 -0
- package/types/cabi/values.d.ts +5 -0
- package/types/cache/core.d.ts +97 -0
- package/types/cache/dir.d.ts +6 -0
- package/types/cache/mod.d.ts +3 -0
- package/types/cache/web.d.ts +10 -0
- package/types/digest/digest.d.ts +17 -0
- package/types/digest/mod.d.ts +2 -0
- package/types/digest/verify.d.ts +48 -0
- package/types/embedder/casing.d.ts +40 -0
- package/types/embedder/copy.d.ts +24 -0
- package/types/embedder/errors.d.ts +11 -0
- package/types/embedder/imports.d.ts +47 -0
- package/types/embedder/instantiate.d.ts +88 -0
- package/types/embedder/mod.d.ts +11 -0
- package/types/embedder/resources.d.ts +158 -0
- package/types/embedder/streams.d.ts +202 -0
- package/types/embedder/values.d.ts +70 -0
- package/types/embedder/version.d.ts +85 -0
- package/types/exec/boundary.d.ts +360 -0
- package/types/exec/executor.d.ts +125 -0
- package/types/exec/host_streams.d.ts +165 -0
- package/types/exec/mod.d.ts +3 -0
- package/types/intrinsics/async_builtins.d.ts +69 -0
- package/types/intrinsics/context.d.ts +28 -0
- package/types/intrinsics/errors.d.ts +5 -0
- package/types/intrinsics/fact_calls.d.ts +120 -0
- package/types/intrinsics/mod.d.ts +187 -0
- package/types/intrinsics/stream_builtins.d.ts +113 -0
- package/types/intrinsics/transcode.d.ts +21 -0
- package/types/jspi/bridge.d.ts +227 -0
- package/types/jspi/mechanics.d.ts +50 -0
- package/types/jspi/mod.d.ts +3 -0
- package/types/jspi/suspending.d.ts +1 -0
- package/types/jspi/types.d.ts +26 -0
- package/types/plan/format.d.ts +369 -0
- package/types/plan/loader.d.ts +113 -0
- package/types/plan/mod.d.ts +2 -0
- package/types/shim/mod.d.ts +1 -0
- package/types/shim/translator.d.ts +55 -0
- package/types/task/mod.d.ts +257 -0
- package/types/task/scheduler.d.ts +421 -0
- package/types/task/streams.d.ts +370 -0
- package/types/task/subtask.d.ts +96 -0
- package/types/task/thread.d.ts +73 -0
- package/types/task/waitable.d.ts +67 -0
package/esm/cache/web.js
ADDED
|
@@ -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,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
|
+
}
|