@jxsuite/compiler 6.1.0 → 8.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.
@@ -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)) {
@@ -1,65 +1,30 @@
1
1
  /**
2
- * Compile-server.js — Hono server handler compilation
2
+ * Compile-server.js — the site worker
3
3
  *
4
- * Compiles `timing: "server"` entries from a Jx document into a deployable Hono server handler
5
- * file.
4
+ * Emits the one Hono worker a site deploys beside its static output: every `timing: "server"` entry
5
+ * the build collected as a `POST /_jx/server/<export>` route, the extension server mounts, and the
6
+ * adapter's fallthrough to static assets (compiler.md §6.3).
6
7
  */
7
8
 
8
9
  import { withBase } from "@jxsuite/schema/asset-paths";
9
- import { dereference } from "@apidevtools/json-schema-ref-parser";
10
- import { collectServerEntries } from "../shared.ts";
11
10
  import { localeNegotiationMiddleware } from "../site/locale-negotiation.ts";
12
11
  import type { ResolvedI18n } from "../site/i18n.ts";
13
- import type { JxDocumentValue, JxElement } from "@jxsuite/schema/types";
12
+ import type { JxDocumentValue } from "@jxsuite/schema/types";
14
13
 
15
14
  /**
16
- * Compile a Jx document to a Hono server handler file. The handler exposes each `timing: "server"`
17
- * entry as a POST endpoint under `/_jx/server/$export`. Returns null if no server entries are
18
- * found.
15
+ * The route prefix the site worker registers its server functions under, and a compiled page or
16
+ * component posts to: the deployment base (site-architecture.md §14.7) in front of `baseUrl`.
19
17
  *
20
- * @example
21
- * const server = await compileServer("./dashboard.json");
22
- * if (server) fs.writeFileSync("dist/server.js", server);
18
+ * One function builds it for both sides. The worker IS the origin, so a route left at the bare path
19
+ * never matches on a site served from a subfolder, and no pass rewrites a module after it is
20
+ * compiled, so the caller has to be given the same prefix when the module is.
23
21
  *
24
- * @param {string | JxElement} sourcePath - Path to .json file, URL, or raw document
25
- * @param {object} [opts]
26
- * @param {string} [opts.baseUrl] - Base path for server endpoints. Default is `'/_jx/server'`
27
- * @returns {Promise<string | null>} Hono server handler source string, or null
22
+ * @param {string} base - The deployment base, `""` for a site at an origin root
23
+ * @param {string} [baseUrl] - Route prefix before the base is applied, `"/_jx/server"` by default
24
+ * @returns {string}
28
25
  */
29
- export async function compileServer(
30
- sourcePath: string | JxElement,
31
- opts: {
32
- baseUrl?: string;
33
- } = {},
34
- ) {
35
- const { baseUrl = "/_jx/server" } = opts;
36
- /* SAFETY: the parameter is a Jx element document or the path of one, and `dereference` only
37
- replaces each `$ref` node with the value it points to, so the result is that same document
38
- with its references inlined. A file read from disk is not re-validated here. */
39
- const doc = (await dereference(sourcePath)) as JxElement;
40
- const entries = collectServerEntries(doc);
41
- if (entries.length === 0) {
42
- return null;
43
- }
44
-
45
- const imports = entries
46
- .map(({ exportName, src }) => `import { ${exportName} } from '${src}'`)
47
- .join("\n");
48
-
49
- const routes = entries.map(({ exportName }) => buildRoute(exportName, baseUrl)).join("\n");
50
-
51
- return `// Generated by @jxsuite/compiler — do not edit manually
52
- // Deploy as a Cloudflare Worker, Node server, or Bun process.
53
- // Requires: npm install hono
54
-
55
- import { Hono } from 'hono'
56
- ${imports}
57
-
58
- const app = new Hono()
59
- ${routes}
60
-
61
- export default app
62
- `;
26
+ export function serverRoutePrefix(base: string, baseUrl = "/_jx/server"): string {
27
+ return withBase(base, baseUrl);
63
28
  }
64
29
 
65
30
  /**
@@ -131,7 +96,7 @@ export function compileSiteServer(
131
96
  * everything behind it routes on the bare path. Each is internally consistent: production serves
132
97
  * exactly one URL space, while `jx dev` deliberately answers both.
133
98
  */
134
- const baseUrl = withBase(base, opts.baseUrl ?? "/_jx/server");
99
+ const baseUrl = serverRoutePrefix(base, opts.baseUrl);
135
100
  if (entries.length === 0 && mounts.length === 0 && !adapter) {
136
101
  return null;
137
102
  }
@@ -217,7 +182,7 @@ function buildRoute(exportName: string, baseUrl: string) {
217
182
  app.post('${baseUrl}/${exportName}', async (c) => {
218
183
  const args = await c.req.json().catch(() => ({}))
219
184
  try {
220
- return c.json(await ${exportName}(args, c.env))
185
+ return c.json((await ${exportName}(args, c.env)) ?? null)
221
186
  } catch (e) {
222
187
  return c.json({ ok: false, error: e?.message ?? 'Server error' }, 500)
223
188
  }