@jxsuite/compiler 6.0.2 → 7.0.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.
@@ -68,20 +68,22 @@ import { collectAssetRefs, copyMountedAssets, loadAssetMounts } from "./asset-mo
68
68
  import type { AssetMount } from "@jxsuite/schema/asset-paths";
69
69
  import type { FormatEntry, FormatRegistry } from "@jxsuite/schema/format-registry";
70
70
  import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
71
- import { resolveLayout } from "./layout-resolver.ts";
71
+ import { layoutChain, resolveLayout } from "./layout-resolver.ts";
72
+ import { dedupeServerEntries, documentServerEntries } from "./server-entries.ts";
73
+ import type { DedupedServerEntries, ServerEntryRecord } from "./server-entries.ts";
72
74
  import { mergeHead, renderHead, withPageDescription } from "@jxsuite/site/head-merger";
73
75
  import { unregisteredHeadRelations } from "./link-relations.ts";
74
76
  import { injectContext } from "./context-injection.ts";
75
- import { compile, compileServer, compileSiteServer } from "../compiler.ts";
77
+ import { compile, compileSiteServer } from "../compiler.ts";
76
78
  import type { CompileOptions } from "../compiler.ts";
77
79
  import { compileElement } from "../targets/compile-element.ts";
78
80
  import type { CompileElementOptions } from "../targets/compile-element.ts";
81
+ import { serverRoutePrefix } from "../targets/compile-server.ts";
79
82
  import type { SiteConnectorSpec, SiteMountSpec } from "../targets/compile-server.ts";
80
83
  import {
81
84
  buildComponentCSS,
82
85
  buildInitialScope,
83
86
  buildInstanceScope,
84
- collectServerEntries,
85
87
  collectStyles,
86
88
  colorSchemePrePaintScript,
87
89
  createSlotFill,
@@ -171,6 +173,13 @@ declare module "@jxsuite/schema/format-registry" {
171
173
  }
172
174
  }
173
175
 
176
+ /**
177
+ * The remedy both adapter errors give: a dynamic section and a server function are served by the
178
+ * same worker, so a project that declares either needs one of the same four adapters.
179
+ */
180
+ const ADAPTER_REMEDY =
181
+ 'set build.adapter to "cloudflare-workers", "cloudflare-pages", "node", or "bun".';
182
+
174
183
  /**
175
184
  * Build an entire Jx site from a project directory.
176
185
  *
@@ -364,8 +373,7 @@ export async function buildSite(
364
373
  .join(", ");
365
374
  throw new Error(
366
375
  `project.json declares dynamic section(s) ${sections} served by extension mounts, but ` +
367
- `build.adapter is "static". Dynamic tables need a server-capable adapter — set ` +
368
- `build.adapter to "cloudflare-workers", "cloudflare-pages", "node", or "bun".`,
376
+ `build.adapter is "static". Dynamic tables need a server-capable adapter — ${ADAPTER_REMEDY}`,
369
377
  );
370
378
  }
371
379
 
@@ -445,6 +453,8 @@ export async function buildSite(
445
453
  const compiledComponentTags: string[] = [];
446
454
  const componentCSS = new Map<string, string>(); // TagName → CSS text
447
455
  const componentDefs = new Map<string, JxElement>(); // TagName → parsed component definition
456
+ // TagName → the absolute path its definition was read from, for what is relative to that file.
457
+ const componentPathByTag = new Map<string, string>();
448
458
  if (existsSync(componentsDir)) {
449
459
  log("Compiling components...");
450
460
  const componentExtensions = [".json", ...formatRegistry.documentExtensions("component")];
@@ -478,6 +488,9 @@ export async function buildSite(
478
488
  * served from a path does not have.
479
489
  */
480
490
  const componentBase = siteBasePath(projectConfig.url);
491
+ // A component's server-function call is written into its module, which no later pass rewrites,
492
+ // So it is given the deployment base now (site-architecture.md §14.7).
493
+ const serverBaseUrl = serverRoutePrefix(componentBase);
481
494
 
482
495
  for (const file of componentFiles) {
483
496
  try {
@@ -491,6 +504,7 @@ export async function buildSite(
491
504
  },
492
505
  rewriteSrc: (specifier: string, docDir: string | null) =>
493
506
  withBase(componentBase, rewriteSidecarSrc(specifier, docDir)),
507
+ serverBaseUrl,
494
508
  };
495
509
  if (projectConfig.$media) {
496
510
  elementOptions.$media = projectConfig.$media;
@@ -528,6 +542,7 @@ export async function buildSite(
528
542
  const doc = await readPageDocument(componentPath, formatRegistry);
529
543
  if (doc.tagName) {
530
544
  componentDefs.set(doc.tagName, doc);
545
+ componentPathByTag.set(doc.tagName, componentPath);
531
546
  const componentShadow = resolveShadowMode(doc, projectConfig.defaults);
532
547
  const css = buildComponentCSS(
533
548
  doc.tagName,
@@ -561,19 +576,31 @@ export async function buildSite(
561
576
  );
562
577
  }
563
578
 
564
- // ── 5b. Collect server entries from components (for site-wide bundling) ──
565
- const siteServerEntries: { exportName: string; src: string }[] = [];
566
- if (projectConfig.build.adapter) {
567
- for (const [, doc] of componentDefs) {
568
- const entries = collectServerEntries(doc);
569
- for (const entry of entries) {
570
- const resolvedSrc = `./components/${entry.src.replace(/^\.\//, "")}`;
571
- siteServerEntries.push({
572
- exportName: entry.exportName,
573
- src: resolvedSrc,
574
- });
575
- }
576
- }
579
+ // ── 5b. Collect server entries ──────────────────────────────────────────
580
+ /*
581
+ * Every `timing: "server"` entry the site declares, before deduplication: each compiled
582
+ * component's here, then `project.json`'s `state`, which every page that does not shadow a key
583
+ * inherits (site-architecture.md §3.2), then each route's page and the layouts it is wrapped in,
584
+ * pushed as the route compiles. Each document is read as written, so a relative `$src` resolves
585
+ * against the file that declares it (`server-entries.ts`). Collected whatever the adapter; step
586
+ * 6c deduplicates and serves them when there is one.
587
+ */
588
+ const siteServerEntries: ServerEntryRecord[] = [];
589
+ for (const [tag, doc] of componentDefs) {
590
+ siteServerEntries.push(
591
+ ...documentServerEntries(doc, componentPathByTag.get(tag)!, projectRoot),
592
+ );
593
+ }
594
+ if (projectConfig.state) {
595
+ siteServerEntries.push(
596
+ ...documentServerEntries(
597
+ /* SAFETY: `ProjectConfig.state` is a plain record of state entries, and the collector reads
598
+ only a document's `state`, so a document carrying that one member is all it needs. */
599
+ { state: projectConfig.state } as JxElement,
600
+ resolve(projectRoot, "project.json"),
601
+ projectRoot,
602
+ ),
603
+ );
577
604
  }
578
605
 
579
606
  // ── 6. Compile each route ───────────────────────────────────────────────
@@ -741,6 +768,7 @@ export async function buildSite(
741
768
  runtimeImports,
742
769
  registerElementBundle,
743
770
  );
771
+ siteServerEntries.push(...result.serverEntries);
744
772
 
745
773
  for (const relation of result.unregisteredRelations) {
746
774
  if (!warnedRelations.has(relation)) {
@@ -877,13 +905,6 @@ export async function buildSite(
877
905
  writeFileSync(filePath, file.content, "utf8");
878
906
  fileCount += 1;
879
907
  }
880
-
881
- // Write server handler if present
882
- if (result.serverHandler) {
883
- const serverPath = resolve(dirname(outPath), "_server.js");
884
- writeFileSync(serverPath, result.serverHandler, "utf8");
885
- fileCount += 1;
886
- }
887
908
  } catch (error) {
888
909
  const msg = `Error compiling ${route.urlPattern}: ${errorMessage(error)}`;
889
910
  errors.push(msg);
@@ -909,15 +930,47 @@ export async function buildSite(
909
930
  }
910
931
 
911
932
  // ── 6c. Generate site-wide server worker ────────────────────────────────
933
+ // What the worker serves, handed back to the caller: `jx dev` answers the same routes from it.
934
+ // Empty with no adapter, where nothing is served (compiler.md §6.3).
935
+ let serverEntries: DedupedServerEntries["entries"] = [];
936
+ /*
937
+ * A server function is served by the site worker and by nothing else, so with no adapter its
938
+ * entry has no route, and a page built that way would ship without the value it declared. It is
939
+ * an error as an active mount is, but a server entry is only known once each page's layout is
940
+ * resolved, which is after `dist/` is cleaned: the error goes into `errors`, so the pages are
941
+ * still written and `jx dev` still serves them, and `jx build` exits non-zero. `siteServerEntries`
942
+ * holds one record per document that declares an entry, a shared layout's once per page using it,
943
+ * so each distinct export and document is named once, in the order first seen.
944
+ */
945
+ if (!projectConfig.build.adapter && siteServerEntries.length > 0) {
946
+ const named = [
947
+ ...new Set(
948
+ siteServerEntries.map(({ exportName, declaredIn }) => `${exportName} (${declaredIn})`),
949
+ ),
950
+ ];
951
+ const message =
952
+ `timing: "server" entries ${named.join(", ")} need a server-capable adapter, but ` +
953
+ `build.adapter is "static": a static deployment has nothing to serve /_jx/server — ${
954
+ ADAPTER_REMEDY
955
+ }`;
956
+ errors.push(message);
957
+ console.error(message);
958
+ }
912
959
  if (projectConfig.build.adapter) {
913
960
  const { adapter } = projectConfig.build;
914
961
  log("Generating site-wide server worker...");
915
962
 
916
- const deduped = new Map<string, { exportName: string; src: string }>();
917
- for (const entry of siteServerEntries) {
918
- if (!deduped.has(entry.exportName)) {
919
- deduped.set(entry.exportName, entry);
920
- }
963
+ /*
964
+ * One route per export name (compiler.md §6.3). Two modules claiming one name is a build
965
+ * error, not a silent choice: the worker can serve only one of them, and a page calling the
966
+ * other would receive its answer. The first module is still kept, so the worker is written
967
+ * and `jx dev` keeps serving the pages while the author renames one.
968
+ */
969
+ const { entries: dedupedEntries, errors: clashErrors } = dedupeServerEntries(siteServerEntries);
970
+ serverEntries = dedupedEntries;
971
+ for (const error of clashErrors) {
972
+ errors.push(error);
973
+ console.error(error);
921
974
  }
922
975
 
923
976
  // Extension server mounts: static imports of the mount modules and used connector classes,
@@ -932,10 +985,11 @@ export async function buildSite(
932
985
  // Cloudflare Pages uses advanced mode (_worker.js inside the build output) — the
933
986
  // Functions/ directory convention only works from the project root, not from dist/.
934
987
  // A static-only Pages site needs no worker at all; sites with mounts always get one.
935
- const skipWorker = adapter === "cloudflare-pages" && deduped.size === 0 && mounts.length === 0;
988
+ const skipWorker =
989
+ adapter === "cloudflare-pages" && serverEntries.length === 0 && mounts.length === 0;
936
990
  const workerSource = skipWorker
937
991
  ? null
938
- : compileSiteServer([...deduped.values()], {
992
+ : compileSiteServer(serverEntries, {
939
993
  adapter,
940
994
  base: basePath,
941
995
  connectors,
@@ -955,7 +1009,7 @@ export async function buildSite(
955
1009
  projectRoot,
956
1010
  });
957
1011
  fileCount += 1;
958
- log(` Generated dist/${workerName} (bundled, ${deduped.size} server function(s))`);
1012
+ log(` Generated dist/${workerName} (bundled, ${serverEntries.length} server function(s))`);
959
1013
  } catch (error) {
960
1014
  const msg = `Error bundling dist/${workerName}: ${errorMessage(error)}`;
961
1015
  errors.push(msg);
@@ -966,12 +1020,18 @@ export async function buildSite(
966
1020
  // Only invoke the worker for server routes; everything else stays static.
967
1021
  writeFileSync(
968
1022
  resolve(outDir, "_routes.json"),
969
- // `/` joins the include list only when there is a locale to negotiate: Pages invokes
970
- // The worker for included paths only, so an uninvoked middleware is an inert one.
1023
+ /*
1024
+ * `/` joins the include list only when there is a locale to negotiate: Pages invokes the
1025
+ * worker for included paths only, so an uninvoked middleware is an inert one. Both carry
1026
+ * the deployment base, because Pages matches them against the request path, and the
1027
+ * worker's own routes are registered under it (site-architecture.md §14.7).
1028
+ */
971
1029
  `${JSON.stringify(
972
1030
  {
973
1031
  exclude: [],
974
- include: i18n && i18n.locales.length > 1 ? ["/", "/_jx/*"] : ["/_jx/*"],
1032
+ include: (i18n && i18n.locales.length > 1 ? ["/", "/_jx/*"] : ["/_jx/*"]).map(
1033
+ (pattern) => withBase(basePath, pattern),
1034
+ ),
975
1035
  version: 1,
976
1036
  },
977
1037
  null,
@@ -1283,7 +1343,7 @@ export async function buildSite(
1283
1343
  log(` ${errors.length} error(s)`);
1284
1344
  }
1285
1345
 
1286
- return { errors, files: fileCount, routes: routes.length };
1346
+ return { errors, files: fileCount, routes: routes.length, serverEntries };
1287
1347
  }
1288
1348
 
1289
1349
  /** The route `compilePage` renders: a URL and the page document that produces it. */
@@ -1308,7 +1368,7 @@ interface PageRoute {
1308
1368
  * @returns {Promise<{
1309
1369
  * html: string;
1310
1370
  * files: { path: string; content: string; tagName?: string }[];
1311
- * serverHandler: string | null;
1371
+ * serverEntries: ServerEntryRecord[];
1312
1372
  * doc: JxDocument;
1313
1373
  * }>}
1314
1374
  * Every parameter is required. There is exactly one caller and it passes all fifteen, so the
@@ -1344,6 +1404,19 @@ async function compilePage(
1344
1404
  // Resolve layout (wraps page in layout with slot distribution)
1345
1405
  const layoutDoc = await resolveLayout(pageDoc, projectConfig, projectRoot);
1346
1406
 
1407
+ /*
1408
+ * This page's and its layouts' server entries, each read from the file that declares it, so a
1409
+ * relative `$src` resolves against that file (compiler.md §6.3). Read here, before
1410
+ * `injectContext`: with no layout `resolveLayout` hands back `pageDoc` itself, and injection
1411
+ * then adds project.json's state to it, which `buildSite` collects once, as project.json's own.
1412
+ */
1413
+ const serverEntries = [
1414
+ ...documentServerEntries(pageDoc, route.sourcePath, projectRoot),
1415
+ ...layoutChain(pageDoc, projectConfig, projectRoot).flatMap((level) =>
1416
+ documentServerEntries(level.doc, level.path, projectRoot),
1417
+ ),
1418
+ ];
1419
+
1347
1420
  // Extract head arrays before they get lost in the merge
1348
1421
  /* SAFETY: `_pageHead` is written only by `resolveLayout`, which copies the page's `$head` (a
1349
1422
  JxHeadEntry[]) into it when the page has one. */
@@ -1559,6 +1632,8 @@ async function compilePage(
1559
1632
  */
1560
1633
  rewriteSrc: (specifier: string) =>
1561
1634
  withBase(basePath, rewriteSidecarSrc(specifier, dirname(route.sourcePath))),
1635
+ // The page's own module calls the worker's routes, under the same base the worker registers.
1636
+ serverBaseUrl: serverRoutePrefix(basePath),
1562
1637
  title,
1563
1638
  };
1564
1639
  /*
@@ -1616,19 +1691,6 @@ async function compilePage(
1616
1691
  result.html = injectNpmElementScripts(result.html, registerElementBundle(npmElements));
1617
1692
  }
1618
1693
 
1619
- // Compile server handler if applicable (skip when provider bundles site-wide)
1620
- let serverHandler: string | null = null;
1621
- if (!projectConfig.build.adapter) {
1622
- try {
1623
- const serverResult = await compileServer(route.sourcePath);
1624
- if (serverResult) {
1625
- serverHandler = serverResult;
1626
- }
1627
- } catch {
1628
- // No server entries — that's fine
1629
- }
1630
- }
1631
-
1632
1694
  return {
1633
1695
  doc: layoutDoc,
1634
1696
  // A page opts out of the sitemap by setting `$sitemap: false` (interim escape hatch
@@ -1642,7 +1704,7 @@ async function compilePage(
1642
1704
  * say it once instead of four hundred times.
1643
1705
  */
1644
1706
  unregisteredRelations: unregisteredHeadRelations(mergedHead),
1645
- serverHandler,
1707
+ serverEntries,
1646
1708
  };
1647
1709
  }
1648
1710
 
@@ -27,6 +27,7 @@ import {
27
27
  createCompileContext,
28
28
  emitFormulaFn,
29
29
  emitRequestFetch,
30
+ emitServerCall,
30
31
  escapeHtml,
31
32
  isMutating,
32
33
  isRefObject,
@@ -36,6 +37,7 @@ import {
36
37
  PREFORMATTED_TAGS,
37
38
  pureSchemeOf,
38
39
  resolveStaticValue,
40
+ serverCallHelperSource,
39
41
  srcImportBinding,
40
42
  templateValueSource,
41
43
  } from "../shared.ts";
@@ -51,6 +53,7 @@ import {
51
53
  isNonNullObject,
52
54
  isNumber,
53
55
  isPrototypeDef,
56
+ isServerFnDef,
54
57
  isString,
55
58
  isTagExpression,
56
59
  paramNames,
@@ -62,6 +65,7 @@ import {
62
65
  objectKey,
63
66
  refAccessor,
64
67
  refBindingKey,
68
+ refReader,
65
69
  refSegments,
66
70
  } from "@jxsuite/runtime/pointer";
67
71
  import type {
@@ -97,6 +101,7 @@ interface HandlerDef {
97
101
  * litHtmlSrc?: string;
98
102
  * modulePath?: string;
99
103
  * projectStyle?: JxStyle | null;
104
+ * serverBaseUrl?: string;
100
105
  * }} opts
101
106
  * @returns {{ html: string; files: { path: string; content: string }[] }}
102
107
  */
@@ -110,6 +115,12 @@ export function compileClient(
110
115
  projectStyle?: JxStyle | null;
111
116
  prePaintScheme?: boolean;
112
117
  rewriteSrc?: (specifier: string) => string;
118
+ /**
119
+ * The route prefix a `timing: "server"` entry posts to, deployment base included
120
+ * (`serverRoutePrefix`). The call is written into the module, and no later pass rewrites a
121
+ * module, so the base has to be known when it is compiled.
122
+ */
123
+ serverBaseUrl?: string;
113
124
  },
114
125
  ) {
115
126
  const {
@@ -117,6 +128,7 @@ export function compileClient(
117
128
  reactivitySrc = DEFAULT_REACTIVITY_SRC,
118
129
  litHtmlSrc = DEFAULT_LIT_HTML_SRC,
119
130
  modulePath = "app.js",
131
+ serverBaseUrl = "/_jx/server",
120
132
  } = opts;
121
133
 
122
134
  const context = createCompileContext(raw, null, raw.state ?? {}, raw.$media ?? {});
@@ -133,6 +145,7 @@ export function compileClient(
133
145
  const bindEntries: [string, string][] = []; // [key, bodyExpr] → bind = {...}
134
146
  const onEntries: [string, HandlerDef][] = []; // [key, { args, body }] → on = {...}
135
147
  const initBlocks: string[] = []; // Lines emitted after state for prototype init
148
+ const serverCalls: string[] = []; // One block per `timing: "server"` entry, after the computed block
136
149
 
137
150
  // Map $src path → Set of function names to import
138
151
  const srcImportMap = new Map<string, Set<string>>();
@@ -241,6 +254,13 @@ export function compileClient(
241
254
  }
242
255
  }
243
256
 
257
+ // `timing: "server"`: null until the site worker's route answers (compiler.md §6.1)
258
+ if (isServerFnDef(def)) {
259
+ stateEntries.push([key, null]);
260
+ serverCalls.push(emitServerCall(key, def, { serverBaseUrl }));
261
+ continue;
262
+ }
263
+
244
264
  /*
245
265
  * Plain object → reactive state.
246
266
  * SAFETY: `raw.state` is parsed JSON, so this entry is structurally a JsonObject; the checks
@@ -272,6 +292,7 @@ export function compileClient(
272
292
  bindEntries,
273
293
  onEntries,
274
294
  initBlocks,
295
+ serverCalls,
275
296
  srcImportMap,
276
297
  counter,
277
298
  reactivitySrc,
@@ -540,7 +561,7 @@ function buildClientNode(
540
561
  const swKey = `_sw${counter.sw}`;
541
562
  counter.sw += 1;
542
563
  const discriminant = isRefObject(source.$switch)
543
- ? `state.${refToBindingKey(source.$switch.$ref)}`
564
+ ? stateRead(source.$switch.$ref)
544
565
  : `\`${String(source.$switch)}\``;
545
566
  const cases = source.cases ?? {};
546
567
  // Parsed JSON, so a case slot can hold `null` or a bare value the declared type leaves out.
@@ -575,13 +596,9 @@ function buildClientNode(
575
596
  const arrayDef = source.children;
576
597
 
577
598
  // Resolve items source expression
578
- let itemsExpr;
579
- if (isRefObject(arrayDef.items)) {
580
- const path = refToBindingKey(arrayDef.items.$ref);
581
- itemsExpr = `state.${path}`;
582
- } else {
583
- itemsExpr = JSON.stringify(arrayDef.items);
584
- }
599
+ const itemsExpr = isRefObject(arrayDef.items)
600
+ ? stateRead(arrayDef.items.$ref)
601
+ : JSON.stringify(arrayDef.items);
585
602
 
586
603
  // Compile the map template to a lit-html template string
587
604
  const litTemplate = emitLitMapTemplate(arrayDef.map);
@@ -845,9 +862,7 @@ function emitChildLit(c: JxMutableNode | JsonPrimitive, preformatted = false): s
845
862
  */
846
863
  function emitArrayHole(arrayDef: RepeaterSlot): string {
847
864
  const { items } = arrayDef;
848
- const itemsExpr = isRefObject(items)
849
- ? `state.${refToBindingKey(items.$ref)}`
850
- : JSON.stringify(items ?? []);
865
+ const itemsExpr = isRefObject(items) ? stateRead(items.$ref) : JSON.stringify(items ?? []);
851
866
  const tpl = emitLitMapTemplate(arrayDef.map);
852
867
  return `\${(${itemsExpr} ?? []).map((item, index) => html\`${tpl}\`)}`;
853
868
  }
@@ -870,6 +885,7 @@ function mapRefsToLit(str: string) {
870
885
  * @param {[string, string][]} bindEntries
871
886
  * @param {[string, any][]} onEntries
872
887
  * @param {string[]} initBlocks
888
+ * @param {string[]} serverCalls
873
889
  * @param {Map<string, Set<string>>} srcImportMap
874
890
  * @param {{
875
891
  * t: number;
@@ -889,6 +905,7 @@ function emitClientModule(
889
905
  bindEntries: [string, string][],
890
906
  onEntries: [string, HandlerDef][],
891
907
  initBlocks: string[],
908
+ serverCalls: string[],
892
909
  srcImportMap: Map<string, Set<string>>,
893
910
  counter: {
894
911
  t: number;
@@ -930,7 +947,11 @@ function emitClientModule(
930
947
  // State — reactive state
931
948
  /* The boolean-attribute rule, inlined because this module loads with `@vue/reactivity` and
932
949
  nothing else — see `attrHelperSource`. The hydration script below is its only caller. */
933
- lines.push("", attrHelperSource(), "", "const state = reactive({");
950
+ lines.push("", attrHelperSource());
951
+ if (serverCalls.length > 0) {
952
+ lines.push("", serverCallHelperSource());
953
+ }
954
+ lines.push("", "const state = reactive({");
934
955
  for (const [key, val] of stateEntries) {
935
956
  /*
936
957
  * State keys come from the document, so `{"user.name": 1}` is legal input. Pasting one raw as
@@ -956,6 +977,15 @@ function emitClientModule(
956
977
  lines.push("");
957
978
  }
958
979
 
980
+ // Server-function calls, after the computed block so a reference to a computed reads its value on
981
+ // The first run
982
+ if (serverCalls.length > 0) {
983
+ for (const block of serverCalls) {
984
+ lines.push(block);
985
+ }
986
+ lines.push("");
987
+ }
988
+
959
989
  // Bind — DOM getters
960
990
  if (bindEntries.length > 0) {
961
991
  lines.push("const bind = {");
@@ -1126,6 +1156,18 @@ function mapRefToClientExpr(ref: string) {
1126
1156
  return refAccessor("state", ref);
1127
1157
  }
1128
1158
 
1159
+ /**
1160
+ * The read of the state a `$ref` names, for a binding whose value arrives later: a `$switch`
1161
+ * discriminant and a repeater's `items`. A server entry or a `Request` is `null` until it answers,
1162
+ * so `#/state/contact/status` must read `state.contact?.status` rather than throw on the first run,
1163
+ * which inside the hydration loop would end it before any later binding or handler was attached.
1164
+ * The path is the ref's own, never its flattened binding key: `contact/status` is two members, not
1165
+ * `contact_status`.
1166
+ */
1167
+ function stateRead(ref: string) {
1168
+ return refReader("state", ref.startsWith("#/state/") ? ref.slice("#/state/".length) : ref);
1169
+ }
1170
+
1129
1171
  /**
1130
1172
  * The `bind`/`on` map key for a ref. It is emitted both as an object-literal key and as a
1131
1173
  * `data-bind` attribute value, so it has to survive being a JavaScript identifier and an HTML
@@ -22,9 +22,11 @@ import {
22
22
  compileStatements,
23
23
  emitFormulaFn,
24
24
  emitRequestFetch,
25
+ emitServerCall,
25
26
  escapeHtml,
26
27
  isMutating,
27
28
  isSchemaOnly,
29
+ serverCallHelperSource,
28
30
  srcExportName,
29
31
  srcImportBinding,
30
32
  tagNameToClassName,
@@ -42,6 +44,7 @@ import {
42
44
  isNumber,
43
45
  isPrototypeDef,
44
46
  isRef,
47
+ isServerFnDef,
45
48
  isString,
46
49
  isTagExpression,
47
50
  paramNames,
@@ -62,6 +65,7 @@ import type {
62
65
  JxMutableNode,
63
66
  JxPrototypeDef,
64
67
  JxRef,
68
+ JxServerFnDef,
65
69
  JxStateDefinition,
66
70
  JxStyle,
67
71
  ProjectConfig,
@@ -89,6 +93,12 @@ export interface CompileElementOptions {
89
93
  basePath?: string | null;
90
94
  /** Project `defaults`, read for `defaults.shadow` (spec.md §16.6). */
91
95
  defaults?: ProjectConfig["defaults"];
96
+ /**
97
+ * The route prefix a `timing: "server"` entry posts to, deployment base included
98
+ * (`serverRoutePrefix`). The call is written into the module and no later pass rewrites a module,
99
+ * so the base has to be known when it is compiled. Handed to every `$elements` dependency too.
100
+ */
101
+ serverBaseUrl?: string;
92
102
  }
93
103
 
94
104
  /**
@@ -185,6 +195,7 @@ export async function compileElement(
185
195
  elementImports,
186
196
  rewriteSrc ? (spec: string) => rewriteSrc(spec, currentDir) : undefined,
187
197
  resolveShadowMode(doc, opts.defaults),
198
+ opts.serverBaseUrl,
188
199
  );
189
200
  const outputPath = filePath ? filePath.replace(/\.json$/, ".js") : `${tagName}.js`;
190
201
  files.push({ content: jsContent, path: outputPath, tagName });
@@ -284,6 +295,10 @@ function extractInitialValue(def: JxStateDefinition) {
284
295
  if (def.$prototype === "Request") {
285
296
  return "null";
286
297
  }
298
+ // A `timing: "server"` entry holds nothing until the site worker's route answers (§6.1)
299
+ if (isServerFnDef(def)) {
300
+ return "null";
301
+ }
287
302
  // Plain object → treat as initial state value
288
303
  return JSON.stringify(def);
289
304
  }
@@ -371,6 +386,7 @@ export function emitElementModule(
371
386
  elementImports: string[],
372
387
  rewriteSrc?: (specifier: string) => string,
373
388
  shadow: ShadowMode | null = null,
389
+ serverBaseUrl = "/_jx/server",
374
390
  ) {
375
391
  const lines: string[] = ["// Generated by @jxsuite/compiler — do not edit manually"];
376
392
 
@@ -434,6 +450,12 @@ export function emitElementModule(
434
450
  isPrototypeDef(def) && def.$prototype === "Request" ? [[key, def]] : [],
435
451
  );
436
452
 
453
+ // `timing: "server"` entries each call the site worker's route from connectedCallback, after the
454
+ // Request fetches, and share one module-level helper that reads the response.
455
+ const serverEntries = Object.entries(defs).flatMap(([key, def]): [string, JxServerFnDef][] =>
456
+ isServerFnDef(def) ? [[key, def]] : [],
457
+ );
458
+
437
459
  lines.push(
438
460
  `import { reactive, computed, effect, stop } from '@vue/reactivity';`,
439
461
  /* `nothing` is what REMOVES an attribute in a lit template, and it is the only way to express a
@@ -442,6 +464,9 @@ export function emitElementModule(
442
464
  `import { render, html, nothing } from 'lit-html';`,
443
465
  attrHelperSource(),
444
466
  );
467
+ if (serverEntries.length > 0) {
468
+ lines.push(serverCallHelperSource());
469
+ }
445
470
  /* Light-DOM slot distribution, by the rule the interpreter applies (spec.md §8.5): only a module
446
471
  whose template draws a slot carries it, and a shadow component never does — its light children
447
472
  ARE the slotted content, and a real `<slot>` distributes them. */
@@ -780,6 +805,18 @@ export function emitElementModule(
780
805
  }),
781
806
  );
782
807
  }
808
+ // Last, so a `$ref` argument reads the props the parent delivered above, and an effect it starts
809
+ // Joins the registry `disconnectedCallback` stops.
810
+ for (const [key, def] of serverEntries) {
811
+ lines.push(
812
+ emitServerCall(key, def, {
813
+ collect: "this.#effects",
814
+ indent: " ",
815
+ serverBaseUrl,
816
+ statePrefix: "this.state",
817
+ }),
818
+ );
819
+ }
783
820
  if (isNonNullObject(doc.style)) {
784
821
  const dynamicStyles: [string, string][] = [];
785
822
  for (const [prop, value] of Object.entries(doc.style)) {