@jxsuite/runtime 4.0.2 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/runtime",
3
- "version": "4.0.2",
3
+ "version": "4.1.0",
4
4
  "description": "JSON-native reactive web component runtime",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -33,10 +33,10 @@
33
33
  "upgrade": "bun update --latest && bun install"
34
34
  },
35
35
  "dependencies": {
36
- "@jxsuite/schema": "^2.3.0",
36
+ "@jxsuite/schema": "^3.0.0",
37
37
  "@vue/reactivity": "3.5.43"
38
38
  },
39
39
  "devDependencies": {
40
- "@happy-dom/global-registrator": "^20.14.5"
40
+ "@happy-dom/global-registrator": "^20.14.6"
41
41
  }
42
42
  }
package/src/runtime.ts CHANGED
@@ -1,16 +1,17 @@
1
1
  /// <reference lib="dom" />
2
2
  /**
3
3
  * Jx — JSON-native reactive web component runtime
4
- * @version 3.0.0
5
- * @license MIT
6
4
  *
7
5
  * Four-step pipeline:
8
- * 1. resolve — fetch JSON source (or accept raw object)
9
- * 2. buildScope — state detection + reactive proxy construction
10
- * 3. render — walk resolved tree, build DOM, wire reactive effects
11
- * 4. output — append to target
6
+ *
7
+ * 1. `resolve` — fetch JSON source (or accept raw object)
8
+ * 2. `buildScope` — state detection + reactive proxy construction
9
+ * 3. `render` — walk resolved tree, build DOM, wire reactive effects
10
+ * 4. `output` — append to target
12
11
  *
13
12
  * @module jx
13
+ * @license MIT
14
+ * @version 3.0.0
14
15
  */
15
16
 
16
17
  import {
@@ -939,7 +940,8 @@ export async function buildScope(
939
940
  }
940
941
  }
941
942
 
942
- // Fifth pass: timing: "server" entries (dev mode — execute client-side, boundary unenforced)
943
+ /* Fifth pass: timing: "server" entries, each called through the host's /__jx_server__ proxy, or
944
+ in process only when the host registered the module itself (spec.md §11.4) */
943
945
  if (!(ctx?.skipServerFunctions ?? _serverFnConfig.skip)) {
944
946
  for (const [key, def] of Object.entries(defs)) {
945
947
  if (isServerFnDef(def)) {
@@ -1083,6 +1085,14 @@ type ModuleLoader = () => Promise<Record<string, unknown>>;
1083
1085
  */
1084
1086
  const _moduleLoaders = new Map<string, ModuleLoader | Promise<ImportedModule>>();
1085
1087
 
1088
+ /**
1089
+ * The specifiers a host registered through `preloadModule`, in either form. Kept apart from
1090
+ * `_moduleCache` because that cache is shared: a Function entry's `$src` import lands there too,
1091
+ * and a `timing: "server"` entry naming the same specifier must still not run in the browser
1092
+ * (spec.md §11.4). Only the host's own registration admits a server module in process.
1093
+ */
1094
+ const _hostSeeded = new Set<string>();
1095
+
1086
1096
  /**
1087
1097
  * Seed the `$src` module cache with a module the host already imported, under the specifier the
1088
1098
  * document spells. A bundled document cannot have its sidecar fetched by URL — the bundler saw
@@ -1095,6 +1105,9 @@ const _moduleLoaders = new Map<string, ModuleLoader | Promise<ImportedModule>>()
1095
1105
  * canvas frame registers every kit behaviour this way, so a page that uses no kit element loads
1096
1106
  * none of them (embedding.md §6).
1097
1107
  *
1108
+ * A registered module is also how a host runs a `timing: "server"` entry in process; for any
1109
+ * specifier not registered here, the runtime calls the host's `/__jx_server__` proxy instead.
1110
+ *
1098
1111
  * @param {string} specifier - The `$src` value as written in the document
1099
1112
  * @param {Record<string, unknown> | (() => Promise<Record<string, unknown>>)} mod - The imported
1100
1113
  * module namespace, or a loader that imports it on first use
@@ -1103,6 +1116,7 @@ export function preloadModule(
1103
1116
  specifier: string,
1104
1117
  mod: Record<string, unknown> | ModuleLoader,
1105
1118
  ): void {
1119
+ _hostSeeded.add(specifier);
1106
1120
  if (typeof mod === "function") {
1107
1121
  _moduleCache.delete(specifier);
1108
1122
  _moduleLoaders.set(specifier, mod);
@@ -3368,13 +3382,59 @@ async function resolveViaDevProxy(def: JxPrototypeDef, state: JxScope, key: stri
3368
3382
  return s;
3369
3383
  }
3370
3384
 
3371
- // ─── Server function resolution (dev mode) ────────────────────────────────────
3385
+ // ─── Server function resolution ───────────────────────────────────────────────
3372
3386
 
3373
3387
  /**
3374
- * Resolve a timing: "server" entry in dev mode by executing the function client-side. In
3375
- * production, the compiler replaces this with a fetch to the generated server handler.
3376
- *
3377
- * @param {JxScope} def
3388
+ * Start one `timing: "server"` entry's calls, wherever they run. Each call takes the next number,
3389
+ * and only the latest may write the entry or report a failure: the answer to a call whose arguments
3390
+ * have since changed is dropped whenever it arrives, and a failed call keeps the entry's last
3391
+ * value. A call that throws before it returns a promise (an argument the proxy cannot serialize, or
3392
+ * a host function that throws synchronously) fails the same way, rather than out of whichever state
3393
+ * write started it. The proxy and a host-registered module share it, so a reactive entry's calls
3394
+ * are ordered and reported the same way on either path.
3395
+ *
3396
+ * @param {Ref<unknown>} s - The entry's value
3397
+ * @param {(args: JxScope) => unknown} invoke - One call, with resolved arguments: it may return a
3398
+ * value or a promise, or throw
3399
+ * @param {string} label - The `console.error` prefix a failed call is reported under
3400
+ * @returns {(args: JxScope) => void} Starts a call
3401
+ */
3402
+ function latestServerCall(
3403
+ s: Ref<unknown>,
3404
+ invoke: (args: JxScope) => unknown,
3405
+ label: string,
3406
+ ): (args: JxScope) => void {
3407
+ let seq = 0;
3408
+ return (args: JxScope) => {
3409
+ seq += 1;
3410
+ const n = seq;
3411
+ /* The executor runs before the constructor returns, so the call still starts synchronously,
3412
+ and a throw inside it rejects this promise instead of escaping to the caller. */
3413
+ new Promise<unknown>((settle) => {
3414
+ settle(invoke(args));
3415
+ })
3416
+ .then((result: unknown) => {
3417
+ if (n === seq) {
3418
+ s.value = result;
3419
+ }
3420
+ })
3421
+ .catch((error: unknown) => {
3422
+ if (n === seq) {
3423
+ console.error(label, error);
3424
+ }
3425
+ });
3426
+ };
3427
+ }
3428
+
3429
+ /**
3430
+ * Resolve a timing: "server" entry. The runtime never imports a server module itself: every call is
3431
+ * a POST to the host's `/__jx_server__` proxy, which runs the function server-side with `env`, so
3432
+ * the runtime never requests the module's source (spec.md §11.4). A host may still serve that file
3433
+ * at its URL, which is why a secret belongs in `env` and never in the module body. The one
3434
+ * exception is the host's own act: a module it registered with `preloadModule` (embedding.md §6) is
3435
+ * called in process, and a reactive entry's calls are ordered there as the proxy orders them.
3436
+ *
3437
+ * @param {JxServerFnDef} def
3378
3438
  * @param {JxScope} state
3379
3439
  * @param {string} key
3380
3440
  * @param {string} [base]
@@ -3389,30 +3449,13 @@ async function resolveServerFunction(
3389
3449
  const src = def.$src;
3390
3450
  const exportName = def.$export;
3391
3451
 
3392
- let mod: ImportedModule;
3393
- const seeded = seededModule(src);
3394
- if (seeded instanceof Promise) {
3395
- mod = await seeded;
3396
- } else if (seeded) {
3397
- mod = seeded;
3398
- } else {
3399
- try {
3400
- mod = (await import(src)) as ImportedModule;
3401
- } catch {
3402
- if (base) {
3403
- try {
3404
- const resolvedSrc = new URL(src, base).href;
3405
- mod = (await import(resolvedSrc)) as ImportedModule;
3406
- } catch {
3407
- // Module cannot run in the browser — fall back to dev server proxy
3408
- return resolveServerFunctionViaProxy(def, state, key, base);
3409
- }
3410
- } else {
3411
- return resolveServerFunctionViaProxy(def, state, key, base);
3412
- }
3413
- }
3414
- _moduleCache.set(src, mod);
3452
+ /* The host-seeded set, not the module cache: a Function entry's own `$src` import lands in that
3453
+ cache too, and reusing it here would put a server module in the browser after all. */
3454
+ const seeded = _hostSeeded.has(src) ? seededModule(src) : undefined;
3455
+ if (!seeded) {
3456
+ return resolveServerFunctionViaProxy(def, state, key, base);
3415
3457
  }
3458
+ const mod = seeded instanceof Promise ? await seeded : seeded;
3416
3459
 
3417
3460
  const candidate = mod[exportName] ?? mod.default?.[exportName];
3418
3461
  if (!candidate) {
@@ -3421,7 +3464,7 @@ async function resolveServerFunction(
3421
3464
  if (typeof candidate !== "function") {
3422
3465
  throw new TypeError(`Jx: "${exportName}" from "${src}" is not a function`);
3423
3466
  }
3424
- const fn = candidate as (args: JxScope) => Promise<unknown>;
3467
+ const fn = candidate as (args: JxScope) => unknown;
3425
3468
 
3426
3469
  const rawArgs = def.arguments ?? {};
3427
3470
  const hasReactiveArg = Object.values(rawArgs).some((v: unknown) => isRefObj(v));
@@ -3436,14 +3479,11 @@ async function resolveServerFunction(
3436
3479
  // Always wrap in ref for reactivity
3437
3480
  const s: Ref<unknown> = ref(null);
3438
3481
  if (hasReactiveArg) {
3482
+ const call = latestServerCall(s, fn, "Jx server function:");
3439
3483
  effect(() => {
3440
3484
  const args = resolveArgs();
3441
3485
  onEffectCleanup(() => {});
3442
- fn(args)
3443
- .then((result: unknown) => {
3444
- s.value = result;
3445
- })
3446
- .catch(() => {});
3486
+ call(args);
3447
3487
  });
3448
3488
  } else {
3449
3489
  s.value = await fn(resolveArgs());
@@ -3452,9 +3492,12 @@ async function resolveServerFunction(
3452
3492
  }
3453
3493
 
3454
3494
  /**
3455
- * Dev-mode fallback: when a timing: "server" module cannot run in the browser, proxy the function
3456
- * call through the Jx dev server (POST /**jx_server**). Supports reactive $ref arguments via Vue
3457
- * effect().
3495
+ * Call a timing: "server" entry through the host's `/__jx_server__` proxy, which imports the module
3496
+ * server-side and calls the export with `env` (server.md §3.3). This is the only path an
3497
+ * interpreting runtime takes for a module the host did not register. A `$ref` argument makes the
3498
+ * call reactive: it re-posts from an `effect()` when the reference changes. A failed call leaves
3499
+ * the entry's last value and logs, and the answer to a superseded call is dropped, so a slow answer
3500
+ * to an earlier call can never overwrite a later one (`latestServerCall`).
3458
3501
  *
3459
3502
  * @param {JxScope} def
3460
3503
  * @param {JxScope} state
@@ -3499,22 +3542,15 @@ async function resolveServerFunctionViaProxy(
3499
3542
 
3500
3543
  // Always wrap in ref for reactivity
3501
3544
  const s: Ref<unknown> = ref(null);
3545
+ const call = latestServerCall(s, doResolve, "Jx server proxy:");
3502
3546
  if (hasReactiveArg) {
3503
3547
  effect(() => {
3504
3548
  const args = resolveArgs();
3505
3549
  onEffectCleanup(() => {});
3506
- doResolve(args)
3507
- .then((result: unknown) => {
3508
- s.value = result;
3509
- })
3510
- .catch((error: unknown) => console.error("Jx server proxy:", error));
3550
+ call(args);
3511
3551
  });
3512
3552
  } else {
3513
- doResolve(resolveArgs())
3514
- .then((result: unknown) => {
3515
- s.value = result;
3516
- })
3517
- .catch((error: unknown) => console.error("Jx server proxy:", error));
3553
+ call(resolveArgs());
3518
3554
  }
3519
3555
  return s;
3520
3556
  }