@hew-lang/sandbox-vm 0.6.6 → 0.7.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/README.md CHANGED
@@ -94,6 +94,11 @@ option. The Node test host loads that package automatically; in a source
94
94
  checkout, run `make wasm` to build `hew-wasm/pkg` first. An explicit
95
95
  `HEW_WASM_MODULE` module URL can select another build of the same package.
96
96
 
97
+ JSON, TOML and YAML values use this same bridge for parsing and formatting.
98
+ Their ordinary value operations execute in the VM, including independent copies,
99
+ checked access, mutation and cleanup. The bridge preserves full-width JSON
100
+ integers, YAML mapping keys and tags, and TOML datetimes using the native engines.
101
+
97
102
  UTC calendar operations also require the initialized `executeCalendar`
98
103
  export as `calendarBridge`. Both hosts use the shared Chrono calendar engine;
99
104
  the bridge preserves integer seconds and nanoseconds exactly.
@@ -50,9 +50,12 @@ try {
50
50
  let codecBridge;
51
51
  if (typeof bytecode === "object" &&
52
52
  bytecode !== null &&
53
- "codecs" in bytecode &&
54
- Array.isArray(bytecode.codecs) &&
55
- bytecode.codecs.length) {
53
+ (("codecs" in bytecode &&
54
+ Array.isArray(bytecode.codecs) &&
55
+ bytecode.codecs.length) ||
56
+ ("runtime_families" in bytecode &&
57
+ Array.isArray(bytecode.runtime_families) &&
58
+ bytecode.runtime_families.some((entry) => entry?.family === "Encoding")))) {
56
59
  const local = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../hew-wasm/pkg/hew_wasm.js");
57
60
  const location = process.env.HEW_WASM_MODULE ?? pathToFileURL(local).href;
58
61
  const wasm = await import(location);
@@ -0,0 +1,8 @@
1
+ import { type VmValue } from "../values.js";
2
+ import type { EncodingOp, EncodingFormat } from "./instructions.generated.js";
3
+ import type { ShimHost } from "./shims.js";
4
+ /** Lossless positional bridge to each format's native Value. Null denotes an invalid
5
+ * handle; a live JSON or YAML null is ["null"]. Numeric text retains native kinds. */
6
+ export type { EncodingFormat } from "./instructions.generated.js";
7
+ export type EncodingNode = ["null"] | ["bool", boolean] | ["integer" | "float" | "string", string] | ["array", EncodingNode[]] | ["object", [string, EncodingNode][]] | ["mapping", [EncodingNode, EncodingNode][]] | ["tagged", string, EncodingNode] | ["datetime", string];
8
+ export declare function executeEncoding(host: ShimHost, format: EncodingFormat, operation: EncodingOp, args: VmValue[]): VmValue;
@@ -0,0 +1,215 @@
1
+ import { cloneValue, compareUtf8, stringValue, UNIT, } from "../values.js";
2
+ const owner = (format, value) => ({ kind: "encoding", format, value });
3
+ const integer = (value) => ({
4
+ kind: "i64",
5
+ value: BigInt(value),
6
+ });
7
+ function checkedValue(arg, format) {
8
+ if (arg?.kind !== "encoding" || arg.format !== format)
9
+ throw new TypeError("encoding operation requires a matching format value");
10
+ return arg;
11
+ }
12
+ function text(arg) {
13
+ if (arg?.kind !== "string")
14
+ throw new TypeError("encoding operation requires a string");
15
+ return arg.value;
16
+ }
17
+ function number(arg) {
18
+ if (arg?.kind !== "i64")
19
+ throw new TypeError("encoding operation requires an integer");
20
+ return arg.value;
21
+ }
22
+ function equal(format, left, right) {
23
+ if (!left || !right || left[0] !== right[0])
24
+ return false;
25
+ switch (left[0]) {
26
+ case "null":
27
+ return true;
28
+ case "float":
29
+ return (float(left[1]) === float(right[1]) ||
30
+ (format === "Yaml" &&
31
+ Number.isNaN(float(left[1])) &&
32
+ Number.isNaN(float(right[1]))));
33
+ case "integer":
34
+ return BigInt(left[1]) === BigInt(right[1]);
35
+ case "datetime":
36
+ case "string":
37
+ case "bool":
38
+ return left[1] === right[1];
39
+ case "array": {
40
+ const items = right[1];
41
+ return (left[1].length === items.length &&
42
+ left[1].every((item, i) => equal(format, item, items[i])));
43
+ }
44
+ case "tagged":
45
+ return (left[1] === right[1] && equal(format, left[2], right[2]));
46
+ case "mapping": {
47
+ const entries = right[1];
48
+ return (left[1].length === entries.length &&
49
+ left[1].every(([key, child]) => {
50
+ const entry = entries.find(([candidate]) => equal(format, key, candidate));
51
+ return entry !== undefined && equal(format, child, entry[1]);
52
+ }));
53
+ }
54
+ case "object": {
55
+ const entries = new Map(right[1]);
56
+ return (left[1].length === entries.size &&
57
+ left[1].every(([key, child]) => equal(format, child, entries.get(key) ?? null)));
58
+ }
59
+ }
60
+ }
61
+ export function executeEncoding(host, format, operation, args) {
62
+ const owned = (node) => owner(format, node);
63
+ const value = (arg) => checkedValue(arg, format);
64
+ const copy = (node) => cloneValue(owned(node));
65
+ if (operation === "Parse" || operation === "Stringify") {
66
+ if (!host.codecBridge)
67
+ throw new Error("Encoding text operations require the hew-wasm executeCodec bridge");
68
+ const result = JSON.parse(host.codecBridge(JSON.stringify({
69
+ encoding_value: operation === "Parse"
70
+ ? { format, operation: "parse", text: text(args[0]) }
71
+ : { format, operation: "stringify", value: value(args[0]).value },
72
+ })));
73
+ if (result.failure)
74
+ throw new Error(result.failure);
75
+ host.encodingError(format, result.error ?? "");
76
+ return operation === "Parse"
77
+ ? owned(result.value ?? null)
78
+ : stringValue(result.text ?? "");
79
+ }
80
+ switch (operation) {
81
+ case "LastError":
82
+ return stringValue(host.encodingError(format));
83
+ case "ObjectNew":
84
+ return owned(format === "Yaml" ? ["mapping", []] : ["object", []]);
85
+ case "ArrayNew":
86
+ return owned(["array", []]);
87
+ case "FromNull":
88
+ return owned(["null"]);
89
+ case "FromString":
90
+ return owned(["string", text(args[0])]);
91
+ case "FromBool":
92
+ return owned(["bool", number(args[0]) !== 0n]);
93
+ case "FromInt":
94
+ return owned(["integer", BigInt.asIntN(64, number(args[0])).toString()]);
95
+ case "FromU64":
96
+ return owned(["integer", BigInt.asUintN(64, number(args[0])).toString()]);
97
+ case "FromFloat": {
98
+ const arg = args[0];
99
+ if (arg?.kind !== "f64")
100
+ throw new TypeError("encoding operation requires a float");
101
+ return owned(format !== "Json" || Number.isFinite(arg.value)
102
+ ? ["float", Object.is(arg.value, -0) ? "-0" : String(arg.value)]
103
+ : null);
104
+ }
105
+ case "Eq":
106
+ return integer(equal(format, value(args[0]).value, value(args[1]).value) ? 1 : 0);
107
+ case "Clone":
108
+ return copy(value(args[0]).value);
109
+ case "Free":
110
+ value(args[0]).value = null;
111
+ return UNIT;
112
+ default:
113
+ break;
114
+ }
115
+ const receiver = value(args[0]), node = receiver.value;
116
+ let scalar = node;
117
+ while (scalar?.[0] === "tagged")
118
+ scalar = scalar[2];
119
+ switch (operation) {
120
+ case "Type":
121
+ return integer(node?.[0] === "tagged" || node?.[0] === "datetime"
122
+ ? 7
123
+ : node?.[0] === "mapping"
124
+ ? 6
125
+ : node
126
+ ? [
127
+ "null",
128
+ "bool",
129
+ "integer",
130
+ "float",
131
+ "string",
132
+ "array",
133
+ "object",
134
+ ].indexOf(node[0])
135
+ : -1);
136
+ case "IntStatus":
137
+ return integer(node?.[0] === "integer"
138
+ ? BigInt(node[1]) <= (1n << 63n) - 1n
139
+ ? 1
140
+ : 0
141
+ : -1);
142
+ case "GetBool":
143
+ return integer(scalar?.[0] === "bool" && scalar[1] ? 1 : 0);
144
+ case "GetInt":
145
+ return integer(scalar?.[0] === "integer" && BigInt(scalar[1]) <= (1n << 63n) - 1n
146
+ ? BigInt(scalar[1])
147
+ : 0);
148
+ case "GetU64":
149
+ return integer(node?.[0] === "integer" && BigInt(node[1]) >= 0 ? BigInt(node[1]) : 0);
150
+ case "GetFloat":
151
+ return {
152
+ kind: "f64",
153
+ value: scalar?.[0] === "float" || scalar?.[0] === "integer"
154
+ ? float(scalar[1])
155
+ : 0,
156
+ };
157
+ case "GetString":
158
+ return stringValue(scalar?.[0] === "string" ? scalar[1] : "");
159
+ case "ArrayLen":
160
+ return integer(node?.[0] === "array" ? node[1].length : -1);
161
+ case "GetDatetime":
162
+ return stringValue(node?.[0] === "datetime" ? node[1] : "");
163
+ case "GetField":
164
+ return copy(node?.[0] === "object"
165
+ ? (node[1].find(([key]) => key === text(args[1]))?.[1] ?? null)
166
+ : node?.[0] === "mapping"
167
+ ? (node[1].find(([key]) => key[0] === "string" && key[1] === text(args[1]))?.[1] ?? null)
168
+ : null);
169
+ case "ArrayGet":
170
+ return copy(node?.[0] === "array"
171
+ ? (node[1][Number(BigInt.asIntN(32, number(args[1])))] ?? null)
172
+ : null);
173
+ case "ObjectKeys":
174
+ return owned(node?.[0] === "object"
175
+ ? ["array", node[1].map(([key]) => ["string", key])]
176
+ : node?.[0] === "mapping"
177
+ ? ["array", node[1].map(([key]) => structuredClone(key))]
178
+ : null);
179
+ case "ObjectSet": {
180
+ const child = value(args[2]), key = text(args[1]);
181
+ if (node?.[0] === "object" && child.value) {
182
+ const index = node[1].findIndex(([name]) => name === key);
183
+ if (index < 0) {
184
+ node[1].push([key, child.value]);
185
+ node[1].sort(([a], [b]) => compareUtf8(a, b));
186
+ }
187
+ else
188
+ node[1][index] = [key, child.value];
189
+ }
190
+ if (node?.[0] === "mapping" && child.value) {
191
+ const index = node[1].findIndex(([name]) => name[0] === "string" && name[1] === key);
192
+ if (index < 0)
193
+ node[1].push([["string", key], child.value]);
194
+ else
195
+ node[1][index] = [["string", key], child.value];
196
+ }
197
+ child.value = null;
198
+ return receiver;
199
+ }
200
+ case "ArrayPush": {
201
+ const child = value(args[1]);
202
+ if (node?.[0] === "array" && child.value)
203
+ node[1].push(child.value);
204
+ child.value = null;
205
+ return receiver;
206
+ }
207
+ }
208
+ }
209
+ function float(text) {
210
+ return text === "inf" || text === "Infinity"
211
+ ? Infinity
212
+ : text === "-inf" || text === "-Infinity"
213
+ ? -Infinity
214
+ : Number(text);
215
+ }
@@ -102,6 +102,7 @@ class ExecutorV1 {
102
102
  trace;
103
103
  stdin;
104
104
  options;
105
+ encodingErrors = {};
105
106
  current;
106
107
  host;
107
108
  scheduler;
@@ -140,6 +141,16 @@ class ExecutorV1 {
140
141
  argv: [...(options.argv ?? ["main.hew"])],
141
142
  unixTimeMillis: () => this.trace.virtualTimeMs,
142
143
  calendarBridge: options.calendarBridge,
144
+ codecBridge: options.codecBridge,
145
+ encodingError: (format, message) => {
146
+ const actor = this.current.context.actor;
147
+ const errors = actor
148
+ ? (actor.encodingErrors ??= {})
149
+ : this.encodingErrors;
150
+ if (message !== undefined)
151
+ errors[format] = message;
152
+ return errors[format] ?? "";
153
+ },
143
154
  fileError: (operation) => {
144
155
  const error = this.current.context.fileError;
145
156
  switch (operation) {
@@ -2446,6 +2457,9 @@ class ExecutorV1 {
2446
2457
  while (pending.length) {
2447
2458
  const value = pending.pop();
2448
2459
  switch (value.kind) {
2460
+ case "encoding":
2461
+ value.value = null;
2462
+ break;
2449
2463
  case "string":
2450
2464
  value.release();
2451
2465
  break;
@@ -2578,6 +2592,8 @@ class ExecutorV1 {
2578
2592
  this.directories.call(release.operation, [value]);
2579
2593
  }
2580
2594
  closeValue(value, fault) {
2595
+ if (value.kind === "encoding")
2596
+ value.value = null;
2581
2597
  if (value.kind === "host_resource")
2582
2598
  this.closeHostResource(value);
2583
2599
  if (value.kind === "string")
@@ -1,4 +1,5 @@
1
1
  import { type DirectoryOperation } from "./browser-directory.js";
2
+ import { type EncodingFormat } from "./encoding-values.js";
2
3
  import type { Pipes } from "./pipes.js";
3
4
  import { type VmValue } from "../values.js";
4
5
  import { Mt19937 } from "./mt19937.js";
@@ -12,6 +13,8 @@ export interface ShimHost {
12
13
  unixTimeMillis(): number;
13
14
  fileError(operation: "HasError" | "LastErrorKind" | "LastErrno" | "LastError"): VmValue;
14
15
  calendarBridge?: (request: string) => string;
16
+ codecBridge?: (request: string) => string;
17
+ encodingError?(format: EncodingFormat, message?: string): string;
15
18
  pipes?: Pipes;
16
19
  newPipe?(capacity: number): VmValue;
17
20
  generatorStream?(generator: VmValue): VmValue;
@@ -1,5 +1,6 @@
1
1
  import { DIRECTORY_OPERATIONS, } from "./browser-directory.js";
2
2
  import { executeCalendar } from "./calendar.js";
3
+ import { executeEncoding } from "./encoding-values.js";
3
4
  import { charProperty, charCase } from "../unicode.js";
4
5
  /// Runtime implementations and their host execution registration. Admission and
5
6
  /// execution consume the same ordinary, resumable or refused disposition.
@@ -79,6 +80,18 @@ export function resolveRuntime(entry) {
79
80
  case "VecSliceRange":
80
81
  return refused(`${entry.family}::${entry.detail}`);
81
82
  case "Encoding":
83
+ return (entry.detail.op === "GetDatetime"
84
+ ? entry.detail.format === "Toml"
85
+ : entry.detail.format !== "Toml" ||
86
+ !["GetU64", "IntStatus", "FromU64", "FromNull"].includes(entry.detail.op))
87
+ ? {
88
+ kind: "ordinary",
89
+ shim: (host, args) => executeEncoding(host, entry.detail.format, entry.detail.op, args),
90
+ ...(entry.detail.op === "Parse" || entry.detail.op === "Stringify"
91
+ ? { bridge: "codecBridge" }
92
+ : {}),
93
+ }
94
+ : refused(`Encoding::${entry.detail.format}`);
82
95
  case "VecScalar":
83
96
  return refused(entry.family);
84
97
  case "Vector":
@@ -1,5 +1,6 @@
1
1
  import type { JsonValue } from "./types.js";
2
2
  import { IndexedString } from "./strings.js";
3
+ import type { EncodingNode, EncodingFormat } from "./v1/encoding-values.js";
3
4
  export { stringValue } from "./strings.js";
4
5
  /** One creator-owned membership list; payload destruction uses the ordinary VM release path. */
5
6
  export interface RcOwner {
@@ -15,6 +16,10 @@ export interface RcCell {
15
16
  typeName: string;
16
17
  }
17
18
  export type VmValue = {
19
+ kind: "encoding";
20
+ format: EncodingFormat;
21
+ value: EncodingNode | null;
22
+ } | {
18
23
  kind: "rc";
19
24
  cell: RcCell | null;
20
25
  closed: boolean;
@@ -17,6 +17,8 @@ export function compareUtf8(a, b) {
17
17
  const I64_LITERAL_DECIMAL = /^(?:0|-?[1-9]\d*)$/;
18
18
  export function cloneValue(value) {
19
19
  switch (value.kind) {
20
+ case "encoding":
21
+ return { ...value, value: structuredClone(value.value) };
20
22
  case "rc":
21
23
  if (value.closed)
22
24
  throw new Error("cannot copy a released Rc");
@@ -206,6 +208,8 @@ export function renderF64(value) {
206
208
  }
207
209
  export function renderStdout(value) {
208
210
  switch (value.kind) {
211
+ case "encoding":
212
+ return JSON.stringify(value.value);
209
213
  case "host_resource":
210
214
  return "FilesystemOwner";
211
215
  case "timer":
@@ -265,6 +269,8 @@ export function renderStdout(value) {
265
269
  }
266
270
  export function toJsonValue(value) {
267
271
  switch (value.kind) {
272
+ case "encoding":
273
+ return value.value;
268
274
  case "host_resource":
269
275
  return "FilesystemOwner";
270
276
  case "timer":
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@hew-lang/sandbox-vm",
3
- "version": "0.6.6",
3
+ "version": "0.7.0",
4
4
  "hewSource": {
5
- "commit": "e0b661d972fa8f97f46df73065a5e10d01900821",
6
- "tag": "v0.6.6",
5
+ "commit": "89cb17bde678afd8b1cdbd791be97a0df393e882",
6
+ "tag": "v0.7.0",
7
7
  "dirty": false
8
8
  },
9
9
  "description": "Deterministic TypeScript interpreter for the Hew educational sandbox",
@@ -21,7 +21,7 @@
21
21
  "cli/"
22
22
  ],
23
23
  "dependencies": {
24
- "@hew-lang/wasm": "0.6.6"
24
+ "@hew-lang/wasm": "0.7.0"
25
25
  },
26
26
  "keywords": [
27
27
  "hew",