@jxsuite/runtime 6.2.1 → 6.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/runtime",
3
- "version": "6.2.1",
3
+ "version": "6.3.0",
4
4
  "description": "JSON-native reactive web component runtime",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -33,11 +33,12 @@
33
33
  "upgrade": "bun update --latest && bun install"
34
34
  },
35
35
  "dependencies": {
36
- "@jxsuite/schema": "^5.4.0",
36
+ "@jxsuite/schema": "^5.6.0",
37
37
  "@vue/reactivity": "3.5.44"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@happy-dom/global-registrator": "^21.0.4",
41
+ "@jxsuite/erase": "^0.2.0",
41
42
  "@jxsuite/test-support": "^0.0.0"
42
43
  }
43
44
  }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * The runtime's seam for TypeScript function bodies (`spec.md` §23.1).
3
+ *
4
+ * A string `body` may use erasable TypeScript, and only JavaScript can run, so every body the
5
+ * runtime compiles goes through {@link compileBody}. The runtime carries no stripper: a host hands
6
+ * it one, eagerly with {@link setBodyEraser} (`eraseBody` from `@jxsuite/erase/browser`), or lazily
7
+ * with {@link setBodyEraserLoader}, which is fetched only when a document has a body that needs it.
8
+ * With neither, a body compiles as written, and one that does not compile says what would fix it.
9
+ *
10
+ * @docs framework/concepts/functions
11
+ */
12
+ import { isFunctionEntry, isJsonObject, isString } from "@jxsuite/schema/guards";
13
+ import type { JsonValue, JxClassDef, JxDocument } from "@jxsuite/schema/types";
14
+ import type { JxScopeValue } from "./types.ts";
15
+
16
+ /** Something erasing found. Only `unsupported` stops a body from running. */
17
+ export interface BodyEraserProblem {
18
+ kind: string;
19
+ message: string;
20
+ }
21
+
22
+ /** What `eraseBody` from `@jxsuite/erase/browser` is: TypeScript in, JavaScript out. */
23
+ export type BodyEraser = (text: string) => {
24
+ code: string;
25
+ problems: readonly BodyEraserProblem[];
26
+ };
27
+
28
+ /** What importing `@jxsuite/erase/browser` gives. */
29
+ export interface BodyEraserModule {
30
+ eraseBody: BodyEraser;
31
+ }
32
+
33
+ /** A compiled body: called with what its caller passes, returning what its body returns. */
34
+ export type CompiledBody = (...args: never[]) => JxScopeValue;
35
+
36
+ /** Fetches the eraser, once, when a document first needs it. */
37
+ export type BodyEraserLoader = () => Promise<BodyEraserModule>;
38
+
39
+ let eraser: BodyEraser | null = null;
40
+ let loader: BodyEraserLoader | null = null;
41
+ let loading: Promise<void> | null = null;
42
+
43
+ /** Erased bodies by authored text, so a body erases once however often it compiles. */
44
+ const erased = new Map<string, string>();
45
+
46
+ /**
47
+ * Install (or with `null`, remove) the function that erases TypeScript from a body.
48
+ *
49
+ * @param fn `eraseBody` from `@jxsuite/erase/browser`.
50
+ */
51
+ export function setBodyEraser(fn: BodyEraser | null): void {
52
+ eraser = fn;
53
+ erased.clear();
54
+ if (!fn) {
55
+ // Taken away, so a later document that needs one loads it again.
56
+ loading = null;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Say how to fetch the eraser when a document needs it, so a page with no typed body downloads
62
+ * nothing. The runtime installs what the promise gives.
63
+ *
64
+ * @param load Typically `() => import("/path/to/erase.js")`, or null to take it away.
65
+ */
66
+ export function setBodyEraserLoader(load: BodyEraserLoader | null): void {
67
+ loader = load;
68
+ loading = null;
69
+ }
70
+
71
+ /** Whether text parses as a function body, sync or async. Parsed, never called. */
72
+ function compiles(text: string): boolean {
73
+ for (const source of [text, `return async function () {\n${text}\n}`]) {
74
+ try {
75
+ Reflect.construct(Function, [source]);
76
+ return true;
77
+ } catch {
78
+ // Not as this kind of function; the other may still take it.
79
+ }
80
+ }
81
+ return false;
82
+ }
83
+
84
+ /** `<…>` followed by a call or a tagged template: a generic call, which JavaScript reads otherwise. */
85
+ const GENERIC_CALL = /<[^<>]*>\s*[(`]/u;
86
+
87
+ /**
88
+ * Whether a body needs the eraser before it can run as meant: it does not compile as JavaScript, or
89
+ * it holds what TypeScript reads as a generic call.
90
+ *
91
+ * @param text The body as authored.
92
+ */
93
+ export function bodyNeedsEraser(text: string): boolean {
94
+ return GENERIC_CALL.test(text) || !compiles(text);
95
+ }
96
+
97
+ /**
98
+ * Every function entry's string body in a tree, accessor halves and `string[]` lines included.
99
+ *
100
+ * @param value A document, a class definition, or a part of one.
101
+ * @yields {string} Each body's text.
102
+ */
103
+ function* bodiesOf(value: JsonValue): Generator<string> {
104
+ if (Array.isArray(value)) {
105
+ for (const item of value) {
106
+ yield* bodiesOf(item);
107
+ }
108
+ return;
109
+ }
110
+ if (!isJsonObject(value)) {
111
+ return;
112
+ }
113
+ const isFunction = isFunctionEntry(value);
114
+ for (const [key, child] of Object.entries(value)) {
115
+ if (isFunction && key === "body") {
116
+ if (isString(child)) {
117
+ yield child;
118
+ } else if (Array.isArray(child) && child.every((line) => isString(line))) {
119
+ yield child.join("\n");
120
+ } else {
121
+ yield* bodiesOf(child);
122
+ }
123
+ } else if (isFunction && (key === "getter" || key === "setter") && isJsonObject(child)) {
124
+ if (isString(child.body)) {
125
+ yield child.body;
126
+ }
127
+ } else {
128
+ yield* bodiesOf(child);
129
+ }
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Load the eraser first when anything in a document needs it. Called where the runtime already
135
+ * waits (a fetched document, a fetched class), so nothing that compiles later has to.
136
+ *
137
+ * @param doc A document or class definition.
138
+ */
139
+ export async function prepareBodies(doc: JsonValue | JxDocument | JxClassDef): Promise<void> {
140
+ const load = loader;
141
+ if (eraser || !load) {
142
+ return;
143
+ }
144
+ // SAFETY: a Jx document and a class definition are JSON data; their types name what they hold.
145
+ const tree = doc as JsonValue;
146
+ if (!bodiesOf(tree).some((body) => bodyNeedsEraser(body))) {
147
+ return;
148
+ }
149
+ loading ??= load().then((module) => {
150
+ if (loader === load) {
151
+ setBodyEraser(module.eraseBody);
152
+ }
153
+ });
154
+ await loading;
155
+ }
156
+
157
+ /** A body's JavaScript, through the eraser when one is installed. */
158
+ function javaScriptOf(body: string, where: string): string {
159
+ if (!eraser) {
160
+ return body;
161
+ }
162
+ let code = erased.get(body);
163
+ if (code === undefined) {
164
+ const result = eraser(body);
165
+ const refused = result.problems.find((problem) => problem.kind === "unsupported");
166
+ if (refused) {
167
+ throw new SyntaxError(`Jx: ${where} cannot run: ${refused.message}`);
168
+ }
169
+ ({ code } = result);
170
+ erased.set(body, code);
171
+ }
172
+ return code;
173
+ }
174
+
175
+ /**
176
+ * Compile a function body, erasing its TypeScript first when an eraser is installed.
177
+ *
178
+ * @param params The parameter names.
179
+ * @param body The body as authored.
180
+ * @param where Names the body in an error, as `'save'` or `the constructor of Counter`.
181
+ * @throws {SyntaxError} When the body does not compile, naming it, and the call that installs an
182
+ * eraser when none is.
183
+ */
184
+ export function compileBody(params: readonly string[], body: string, where: string): CompiledBody {
185
+ const code = javaScriptOf(body, where);
186
+ try {
187
+ /* SAFETY: `new Function` returns an ordinary function of the named parameters; each caller
188
+ says what it is called with and what it returns. */
189
+ return new Function(...params, code) as CompiledBody;
190
+ } catch (error) {
191
+ if (!(error instanceof SyntaxError) || eraser) {
192
+ throw error;
193
+ }
194
+ throw new SyntaxError(
195
+ `Jx: ${where} does not compile as JavaScript (${error.message}). A function body may be ` +
196
+ `TypeScript: give this page an eraser with setBodyEraser(eraseBody), eraseBody from ` +
197
+ `@jxsuite/erase/browser.`,
198
+ { cause: error },
199
+ );
200
+ }
201
+ }
package/src/runtime.ts CHANGED
@@ -52,7 +52,6 @@ import type {
52
52
  JxScopeValue,
53
53
  } from "./types.ts";
54
54
  import {
55
- bodyReturnsValue,
56
55
  hasStructuredBody,
57
56
  isBoolean,
58
57
  isExpressionDef,
@@ -71,11 +70,18 @@ import {
71
70
  isString,
72
71
  isTagExpression,
73
72
  isTemplateString,
74
- paramNames,
75
73
  } from "@jxsuite/schema/guards";
76
74
  import type { TagNameSlot } from "@jxsuite/schema/guards";
75
+ import {
76
+ classifyFunctionEntries,
77
+ declaresBodyAndSrc,
78
+ handlerParamNames,
79
+ } from "@jxsuite/schema/function-role";
80
+ import type { FunctionRole } from "@jxsuite/schema/function-role";
77
81
  import { formatSrcset, parseSrcset } from "@jxsuite/schema/asset-paths";
78
82
  import { runStatements } from "./statements.ts";
83
+ import { compileBody, prepareBodies } from "./body-eraser.ts";
84
+
79
85
  import { readCookie, serializeCookie } from "./cookie.ts";
80
86
  import type {
81
87
  CemParameter,
@@ -96,6 +102,14 @@ import type {
96
102
  } from "@jxsuite/schema/types";
97
103
  import type { EffectScope, Ref } from "@vue/reactivity";
98
104
 
105
+ export {
106
+ bodyNeedsEraser,
107
+ prepareBodies,
108
+ setBodyEraser,
109
+ setBodyEraserLoader,
110
+ } from "./body-eraser.ts";
111
+ export type { BodyEraser, BodyEraserLoader, BodyEraserModule } from "./body-eraser.ts";
112
+
99
113
  // ─── Public API ───────────────────────────────────────────────────────────────
100
114
 
101
115
  /**
@@ -357,6 +371,7 @@ function responseContentType(res: Partial<Pick<Response, "headers">>): string {
357
371
 
358
372
  export async function resolve(source: string | JxDocument, ctx?: JxContext): Promise<JxDocument> {
359
373
  if (!isString(source)) {
374
+ await prepareBodies(source);
360
375
  return source;
361
376
  }
362
377
  // A mount's resolver answers for THIS mount and its hit never enters the shared cache.
@@ -367,7 +382,7 @@ export async function resolve(source: string | JxDocument, ctx?: JxContext): Pro
367
382
  if (_resolveCache.has(source)) {
368
383
  return _resolveCache.get(source)!;
369
384
  }
370
- const p = fetch(source).then(async (res) => {
385
+ const fetched = fetch(source).then(async (res) => {
371
386
  if (!res.ok) {
372
387
  throw new Error(`Jx: failed to fetch ${source} (${res.status})`);
373
388
  }
@@ -406,6 +421,11 @@ export async function resolve(source: string | JxDocument, ctx?: JxContext): Pro
406
421
  the JSON is that document; the runtime does not schema-validate documents by design. */
407
422
  return (await res.json()) as JxDocument;
408
423
  });
424
+ // A body that needs the eraser has it before anything compiles, which happens synchronously.
425
+ const p = fetched.then(async (doc) => {
426
+ await prepareBodies(doc);
427
+ return doc;
428
+ });
409
429
  _resolveCache.set(source, p);
410
430
  return p;
411
431
  }
@@ -1007,8 +1027,15 @@ export async function buildScope(
1007
1027
  }
1008
1028
  }
1009
1029
 
1010
- // Third pass: $prototype: "Function" entries
1030
+ // Third pass: $prototype: "Function" entries. One verdict per entry, from the document: the same
1031
+ // One every compiled target reads (spec.md §5.3 4b, 4d).
1032
+ const roles = classifyFunctionEntries(doc);
1011
1033
  for (const [key, def] of Object.entries(defs)) {
1034
+ /* Refused ahead of the structured-body branch, so a structured body beside a `$src` is caught
1035
+ too, and by presence, so `body: ""` is (spec.md §5.3 4d). */
1036
+ if (isFunctionDef(def) && declaresBodyAndSrc(def)) {
1037
+ throw new Error(`Jx: '${key}' declares both body and $src — these are mutually exclusive`);
1038
+ }
1012
1039
  if (hasStructuredBody(def)) {
1013
1040
  // Structured body (spec §20): with parameters → a callable mapping positional args onto
1014
1041
  // $args names (invoked via the call operator); without → an event handler.
@@ -1029,7 +1056,7 @@ export async function buildScope(
1029
1056
  state[key] = handler;
1030
1057
  }
1031
1058
  } else if (isFunctionDef(def)) {
1032
- state[key] = await resolveFunction(def, state, key, base);
1059
+ state[key] = await resolveFunction(def, state, key, roles.get(key) ?? "callable", base);
1033
1060
  }
1034
1061
  }
1035
1062
 
@@ -1589,21 +1616,25 @@ type ScopeFunction = (...args: JxScopeValue[]) => JxScopeValue;
1589
1616
  /**
1590
1617
  * Resolve a $prototype: "Function" entry into a function or computed.
1591
1618
  *
1592
- * Functions receive state as their first parameter at call time. Functions with a return statement
1593
- * in their body are wrapped in computed() for reactive evaluation.
1619
+ * Functions receive state as their first parameter at call time. A `computed` role (decided by
1620
+ * `classifyFunctionEntries` from the document) is wrapped in computed() for reactive evaluation.
1594
1621
  *
1595
1622
  * @param {JxFunctionDef} def - State entry with $prototype: "Function"
1596
1623
  * @param {JxScope} state - Reactive scope proxy
1597
1624
  * @param {string} key - Def key name
1625
+ * @param {FunctionRole} role - The document's verdict for this entry
1598
1626
  * @param {string} [base] - Base URL for resolving $src imports
1599
1627
  * @returns {Promise<JxScopeValue>}
1600
1628
  */
1601
- async function resolveFunction(def: JxFunctionDef, state: JxScope, key: string, base?: string) {
1602
- if (def.body && def.$src) {
1603
- throw new Error(`Jx: '${key}' declares both body and $src — these are mutually exclusive`);
1604
- }
1629
+ async function resolveFunction(
1630
+ def: JxFunctionDef,
1631
+ state: JxScope,
1632
+ key: string,
1633
+ role: FunctionRole,
1634
+ base?: string,
1635
+ ) {
1605
1636
  if (!def.body && !def.$src) {
1606
- const params = resolveParamNames(def);
1637
+ const params = handlerParamNames(def);
1607
1638
  const noop = new Function(...params, "");
1608
1639
  Object.defineProperty(noop, "name", {
1609
1640
  configurable: true,
@@ -1615,10 +1646,10 @@ async function resolveFunction(def: JxFunctionDef, state: JxScope, key: string,
1615
1646
  let fn: ScopeFunction;
1616
1647
 
1617
1648
  if (isString(def.body)) {
1618
- const params = resolveParamNames(def);
1649
+ const params = handlerParamNames(def);
1619
1650
  /* SAFETY: `new Function` returns an ordinary function of the named parameters, `state` first.
1620
1651
  A call never checks its arguments, and what the body returns is one more scope member. */
1621
- fn = new Function(...params, def.body) as ScopeFunction;
1652
+ fn = compileBody(params, def.body, `'${key}'`) as ScopeFunction;
1622
1653
  Object.defineProperty(fn, "name", {
1623
1654
  configurable: true,
1624
1655
  value: def.name ?? key,
@@ -1649,16 +1680,8 @@ async function resolveFunction(def: JxFunctionDef, state: JxScope, key: string,
1649
1680
  fn = candidate as ScopeFunction;
1650
1681
  }
1651
1682
 
1652
- // Detect computed: body contains a return statement, or $src function introspection.
1653
- // Functions with parameters (event handlers, callbacks) are never computed.
1654
- const hasParams = (def.parameters ?? def.arguments ?? []).length > 0;
1655
- let isComputed = false;
1656
- if (!hasParams) {
1657
- isComputed = isString(def.body)
1658
- ? bodyReturnsValue(def.body)
1659
- : fn.length <= 1 && bodyReturnsValue(fn.toString());
1660
- }
1661
- if (isComputed) {
1683
+ // The role comes from the document (spec.md §5.3 4b, 4d), never from the import's arity or source.
1684
+ if (role === "computed") {
1662
1685
  return computed(() => fn(state));
1663
1686
  }
1664
1687
 
@@ -1667,19 +1690,6 @@ async function resolveFunction(def: JxFunctionDef, state: JxScope, key: string,
1667
1690
 
1668
1691
  // ─── Step 3: Render ───────────────────────────────────────────────────────────
1669
1692
 
1670
- /**
1671
- * Extract parameter names from a function definition. Supports both legacy "arguments" (string
1672
- * array) and CEM-compatible "parameters" (object array). Always ensures "state" is the first
1673
- * parameter.
1674
- *
1675
- * @param {JxFunctionDef | JxPrototypeDef} def
1676
- * @returns {string[]}
1677
- */
1678
- function resolveParamNames(def: JxFunctionDef | JxPrototypeDef) {
1679
- const names = def.parameters ? paramNames(def.parameters) : (def.arguments ?? []);
1680
- return names.length > 0 && names[0] === "state" ? names : ["state", ...names];
1681
- }
1682
-
1683
1693
  /**
1684
1694
  * Reserved Jx keys — never set as DOM properties.
1685
1695
  *
@@ -2121,6 +2131,11 @@ function bindHandler(
2121
2131
  }
2122
2132
  return true;
2123
2133
  }
2134
+ if (isFunctionDef(val) && declaresBodyAndSrc(val)) {
2135
+ throw new Error(
2136
+ `Jx: inline '${key}' handler declares both body and $src — these are mutually exclusive`,
2137
+ );
2138
+ }
2124
2139
  if (hasStructuredBody(val)) {
2125
2140
  const { body } = val;
2126
2141
  el.addEventListener(type, (e) => {
@@ -2129,10 +2144,10 @@ function bindHandler(
2129
2144
  return true;
2130
2145
  }
2131
2146
  if (isFunctionDef(val) && isString(val.body)) {
2132
- const params = resolveParamNames(val);
2147
+ const params = handlerParamNames(val);
2133
2148
  /* SAFETY: `new Function` returns an ordinary function of the named parameters, `state` first;
2134
2149
  the listener calls it with the scope and the event, and discards what it returns. */
2135
- const fn = new Function(...params, val.body) as JxEventHandler;
2150
+ const fn = compileBody(params, val.body, `the ${type} handler`) as JxEventHandler;
2136
2151
  el.addEventListener(type, (e) => fn(scope, e));
2137
2152
  return true;
2138
2153
  }
@@ -3867,6 +3882,7 @@ async function resolveClassJson(def: JxPrototypeDef, state: JxScope, key: string
3867
3882
  // Fall back to dev proxy (server will handle .class.json resolution)
3868
3883
  return resolveViaDevProxy(def, state, key, base);
3869
3884
  }
3885
+ await prepareBodies(classDef);
3870
3886
 
3871
3887
  // Hybrid mode: $implementation points to the real JS module
3872
3888
  if (classDef.$implementation) {
@@ -3934,6 +3950,7 @@ function classFromSchema(classDef: JxClassDef) {
3934
3950
  const rawCtor = classDef.$defs?.constructor;
3935
3951
  const ctor = isCtorDef(rawCtor) ? rawCtor : undefined;
3936
3952
  const methods = classDef.$defs?.methods ?? {};
3953
+ const className = classDef.title ?? "a class";
3937
3954
 
3938
3955
  // oxlint-disable-next-line typescript/no-extraneous-class -- methods are attached to the prototype dynamically below
3939
3956
  class DynClass {
@@ -3961,7 +3978,10 @@ function classFromSchema(classDef: JxClassDef) {
3961
3978
  /* SAFETY: `new Function` returns an ordinary function of the one named parameter; `call`
3962
3979
  runs it with the instance under construction as `this`, which is the constructor
3963
3980
  body's contract. */
3964
- const init = new Function("config", bodyStr) as (this: DynClass, config: JxScope) => void;
3981
+ const init = compileBody(["config"], bodyStr, `the constructor of ${className}`) as (
3982
+ this: DynClass,
3983
+ config: JxScope,
3984
+ ) => void;
3965
3985
  init.call(this, config);
3966
3986
  }
3967
3987
  }
@@ -3986,7 +4006,11 @@ function classFromSchema(classDef: JxClassDef) {
3986
4006
  if (typedMethod.getter) {
3987
4007
  /* SAFETY: `new Function` returns an ordinary function; as a getter it is called with no
3988
4008
  arguments and the instance as `this`, and what it returns is the property's value. */
3989
- descriptor.get = new Function(typedMethod.getter.body ?? "") as () => JxScopeValue;
4009
+ descriptor.get = compileBody(
4010
+ [],
4011
+ typedMethod.getter.body ?? "",
4012
+ `the getter ${name} of ${className}`,
4013
+ ) as () => JxScopeValue;
3990
4014
  }
3991
4015
  if (typedMethod.setter) {
3992
4016
  const sp = (typedMethod.setter.parameters ?? []).map(
@@ -3994,18 +4018,20 @@ function classFromSchema(classDef: JxClassDef) {
3994
4018
  );
3995
4019
  /* SAFETY: `new Function` returns an ordinary function of the named parameters; as a setter
3996
4020
  it receives the assigned value as its one argument, and its return is discarded. */
3997
- descriptor.set = new Function(...sp, typedMethod.setter.body ?? "") as (
3998
- v: JxScopeValue,
3999
- ) => void;
4021
+ descriptor.set = compileBody(
4022
+ sp,
4023
+ typedMethod.setter.body ?? "",
4024
+ `the setter ${name} of ${className}`,
4025
+ ) as (v: JxScopeValue) => void;
4000
4026
  }
4001
4027
  Object.defineProperty(DynClass.prototype, name, {
4002
4028
  ...descriptor,
4003
4029
  configurable: true,
4004
4030
  });
4005
4031
  } else if (typedMethod.scope === "static") {
4006
- DynClass[name] = new Function(...params, bodyStr);
4032
+ DynClass[name] = compileBody(params, bodyStr, `${name} of ${className}`);
4007
4033
  } else {
4008
- DynClass.prototype[name] = new Function(...params, bodyStr);
4034
+ DynClass.prototype[name] = compileBody(params, bodyStr, `${name} of ${className}`);
4009
4035
  }
4010
4036
  }
4011
4037