@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/compiler",
3
- "version": "6.1.0",
3
+ "version": "8.0.0",
4
4
  "description": "Jx static HTML compiler, island detector, and site builder",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -17,6 +17,12 @@
17
17
  "dist"
18
18
  ],
19
19
  "type": "module",
20
+ "imports": {
21
+ "#bundler-backend": {
22
+ "bun": "./src/site/bundler-backend.bun.ts",
23
+ "default": "./src/site/bundler-backend.node.ts"
24
+ }
25
+ },
20
26
  "exports": {
21
27
  ".": "./src/compiler.ts",
22
28
  "./asset-mounts": "./src/site/asset-mounts.ts",
@@ -30,7 +36,8 @@
30
36
  "./site": "./src/site/site-build.ts",
31
37
  "./site-loader": "./src/site/site-loader.ts",
32
38
  "./types": "./src/types.ts",
33
- "./db-push": "./src/site/db-push.ts"
39
+ "./db-push": "./src/site/db-push.ts",
40
+ "./bundler": "./src/site/bundler.ts"
34
41
  },
35
42
  "publishConfig": {
36
43
  "provenance": true
@@ -45,11 +52,10 @@
45
52
  "upgrade": "bun update --latest && bun install"
46
53
  },
47
54
  "dependencies": {
48
- "@apidevtools/json-schema-ref-parser": "^16.0.5",
49
- "@jxsuite/create": "^1.4.0",
50
- "@jxsuite/runtime": "^5.2.0",
51
- "@jxsuite/schema": "^4.0.0",
52
- "@jxsuite/site": "^2.2.0",
55
+ "@jxsuite/create": "^1.5.0",
56
+ "@jxsuite/runtime": "^6.0.1",
57
+ "@jxsuite/schema": "^5.0.0",
58
+ "@jxsuite/site": "^2.2.2",
53
59
  "@vue/reactivity": "3.5.44",
54
60
  "esbuild": "^0.28.2",
55
61
  "lit-html": "^3.3.3",
@@ -57,8 +63,8 @@
57
63
  },
58
64
  "devDependencies": {
59
65
  "@happy-dom/global-registrator": "^21.0.4",
60
- "@jxsuite/connector": "^0.8.0",
61
- "@jxsuite/parser": "^4.1.0",
66
+ "@jxsuite/connector": "^0.8.2",
67
+ "@jxsuite/parser": "^4.1.2",
62
68
  "@jxsuite/test-support": "^0.0.0",
63
69
  "@types/mdast": "^4.0.4"
64
70
  }
package/src/compiler.ts CHANGED
@@ -6,7 +6,6 @@
6
6
  * - Fully static → compile-static.js (plain HTML/CSS, zero JS)
7
7
  * - Custom element (-) → compile-element.js (lit-html web component)
8
8
  * - Dynamic (standard) → compile-client.js (pre-rendered HTML + reactive bindings)
9
- * - Server → compile-server.js (Hono server handler)
10
9
  *
11
10
  * Usage (CLI): `bun packages/compiler/src/compile-cli.js <source.json> [output.html]`
12
11
  *
@@ -23,7 +22,6 @@ import {
23
22
  isDynamic,
24
23
  tagNameToClassName,
25
24
  } from "./shared.ts";
26
- import { compileServer } from "./targets/compile-server.ts";
27
25
 
28
26
  import { emitElementModule } from "./targets/compile-element.ts";
29
27
  import { compileStaticPage } from "./targets/compile-static.ts";
@@ -35,7 +33,7 @@ import type { FormatRegistry } from "@jxsuite/schema/format-registry";
35
33
 
36
34
  // Re-exports for consumers
37
35
  export { isDynamic } from "./shared.ts";
38
- export { compileServer, compileSiteServer } from "./targets/compile-server.ts";
36
+ export { compileSiteServer } from "./targets/compile-server.ts";
39
37
  export { compileElement, compileElementPage } from "./targets/compile-element.ts";
40
38
  export { compileClient } from "./targets/compile-client.ts";
41
39
 
@@ -49,6 +47,11 @@ export interface CompileOptions {
49
47
  formats?: FormatRegistry;
50
48
  /** Rewrite Function-def `$src` specifiers to served URLs (see CompileElementOptions). */
51
49
  rewriteSrc?: (specifier: string) => string;
50
+ /**
51
+ * The route prefix a `timing: "server"` entry posts to, deployment base included
52
+ * (`serverRoutePrefix`). Default `"/_jx/server"`, for a site served from its origin root.
53
+ */
54
+ serverBaseUrl?: string;
52
55
  /**
53
56
  * Inject the color-scheme pre-paint script when `$media` declares a pure scheme query (default
54
57
  * true). The site pipeline disables this and injects via the merged <head> instead.
@@ -132,7 +135,14 @@ export async function compile(sourcePath: string | JxDocument, opts: CompileOpti
132
135
  if (raw.tagName && raw.tagName.includes("-")) {
133
136
  const { tagName } = raw;
134
137
  const className = tagNameToClassName(tagName);
135
- const moduleContent = emitElementModule(raw, className, [], opts.rewriteSrc);
138
+ const moduleContent = emitElementModule(
139
+ raw,
140
+ className,
141
+ [],
142
+ opts.rewriteSrc,
143
+ null,
144
+ opts.serverBaseUrl,
145
+ );
136
146
  const moduleFile = {
137
147
  content: moduleContent,
138
148
  path: `${tagName}.js`,
@@ -166,13 +176,17 @@ export async function compile(sourcePath: string | JxDocument, opts: CompileOpti
166
176
  }
167
177
 
168
178
  // Route 3: Dynamic with standard tagName → pre-rendered HTML + reactive bindings
169
- return compileClient(raw, {
179
+ const clientOptions: Parameters<typeof compileClient>[1] = {
170
180
  litHtmlSrc,
171
181
  projectStyle,
172
182
  reactivitySrc,
173
183
  ...pageTargetOverrides(opts),
174
184
  title,
175
- });
185
+ };
186
+ if (opts.serverBaseUrl !== undefined) {
187
+ clientOptions.serverBaseUrl = opts.serverBaseUrl;
188
+ }
189
+ return compileClient(raw, clientOptions);
176
190
  }
177
191
 
178
192
  /**
@@ -197,7 +211,7 @@ function pageTargetOverrides(opts: CompileOptions): PageTargetOverrides {
197
211
  * @param {string} [out]
198
212
  */
199
213
  export async function runCli(src: string, out?: string) {
200
- const [result, server] = await Promise.all([compile(src), compileServer(src)]);
214
+ const result = await compile(src);
201
215
  const { writeFileSync, mkdirSync } = await import("node:fs");
202
216
  const { dirname, join } = await import("node:path");
203
217
  if (out) {
@@ -213,9 +227,4 @@ export async function runCli(src: string, out?: string) {
213
227
  } else {
214
228
  process.stdout.write(result.html);
215
229
  }
216
- if (server && out) {
217
- const serverOut = out.replace(/(\.[^.]+)?$/, "-server.js");
218
- writeFileSync(serverOut, /** @type {string} */ server, "utf8");
219
- console.error(`Server handler written to ${serverOut}`);
220
- }
221
230
  }
package/src/shared.ts CHANGED
@@ -23,7 +23,8 @@ import {
23
23
  isSingleExpression,
24
24
  splitSelectorList,
25
25
  } from "@jxsuite/runtime";
26
- import { evaluateExpression, isMutating } from "@jxsuite/runtime/expression";
26
+ import { spliceNestingSelector } from "@jxsuite/runtime/css";
27
+ import { compileOperandSource, evaluateExpression, isMutating } from "@jxsuite/runtime/expression";
27
28
  import { runStatements } from "@jxsuite/runtime/statements";
28
29
  import { buildSiteStyleCSS } from "@jxsuite/site/site-style";
29
30
  import {
@@ -52,7 +53,7 @@ import {
52
53
  import type { TagNameSlot } from "@jxsuite/schema/guards";
53
54
  import { resolveShadowMode, styleScopePrefix } from "./shadow.ts";
54
55
  import type { ShadowMode } from "./shadow.ts";
55
- import { readPath } from "@jxsuite/runtime/pointer";
56
+ import { escapeToken, objectKey, readPath, refAccessor } from "@jxsuite/runtime/pointer";
56
57
  import type { JxScope } from "@jxsuite/runtime/types";
57
58
  import type {
58
59
  CemParameter,
@@ -63,6 +64,7 @@ import type {
63
64
  JxMutableNode,
64
65
  JxPrototypeDef,
65
66
  JxRef,
67
+ JxServerFnDef,
66
68
  JxStateDefinition,
67
69
  JxStyle,
68
70
  ProjectConfig,
@@ -258,6 +260,84 @@ export function emitRequestFetch(
258
260
  return lines.map((line) => `${indent}${line}`).join("\n");
259
261
  }
260
262
 
263
+ /**
264
+ * A `$ref` scheme that names something only a loop, an event or a formula has. A state entry sits
265
+ * in none of those scopes, so lowering one would emit an identifier nothing declares.
266
+ */
267
+ const SERVER_CALL_REFUSED_REFS = ["$map/", "event#/", "$args/", "$reduce/"];
268
+
269
+ /**
270
+ * Emit the call a `timing: "server"` state entry makes to its route in the site worker (compiler.md
271
+ * §6.1): a `POST <serverBaseUrl>/<export>` whose JSON body is the entry's `arguments`, the answer
272
+ * written to the entry's state key.
273
+ *
274
+ * Shared by the client and element targets, as `emitRequestFetch` is, so one rule decides what an
275
+ * answer, a failure and a superseded call each do. A literal argument is sent as written. A
276
+ * top-level `$ref` argument is lowered by the runtime's own table (`compileOperandSource`) and read
277
+ * inside an `effect()`, so the call repeats when it changes. Only the latest call may write: a
278
+ * sequence number drops the answer to a call whose arguments have since moved on, because aborting
279
+ * a `POST` does not stop the function that is already running on the server. A non-2xx answer is
280
+ * never stored: the entry keeps its last value and the error goes to the console naming the
281
+ * export.
282
+ *
283
+ * @param {string} key - State key receiving the answer
284
+ * @param {JxServerFnDef} def - The server entry
285
+ * @param {{ statePrefix?: string; indent?: string; collect?: string; serverBaseUrl?: string }} [opts] -
286
+ * `statePrefix` is the expression the emitted code assigns through (`state` for the client module
287
+ * scope, `this.state` inside a custom element); `indent` prefixes every emitted line; `collect`,
288
+ * when given, is an array expression the effect runner is pushed onto so the caller can `stop()`
289
+ * it on teardown; `serverBaseUrl` is the route prefix, deployment base included.
290
+ * @returns {string}
291
+ */
292
+ export function emitServerCall(
293
+ key: string,
294
+ def: JxServerFnDef,
295
+ opts: { statePrefix?: string; indent?: string; collect?: string; serverBaseUrl?: string } = {},
296
+ ): string {
297
+ const { statePrefix = "state", indent = "", collect, serverBaseUrl = "/_jx/server" } = opts;
298
+ const url = JSON.stringify(`${serverBaseUrl}/${def.$export}`);
299
+
300
+ const written = Object.entries(def.arguments ?? {});
301
+ const args = written.map(([name, value]) => {
302
+ if (!isRef(value)) {
303
+ return `${objectKey(name)}: ${JSON.stringify(value)}`;
304
+ }
305
+ if (SERVER_CALL_REFUSED_REFS.some((prefix) => value.$ref.startsWith(prefix))) {
306
+ throw new Error(
307
+ `Server function "${key}": argument "${name}" references "${value.$ref}", which a state ` +
308
+ `entry cannot read. Use a #/state/, parent#/, window#/ or document#/ reference.`,
309
+ );
310
+ }
311
+ return `${objectKey(name)}: ${compileOperandSource({ $ref: value.$ref }, { statePrefix })}`;
312
+ });
313
+ // Any top-level reference makes the call reactive: it is read inside an effect, so it repeats.
314
+ const reactive = written.some(([, value]) => isRef(value));
315
+ const call = `${SERVER_CALL_HELPER}(${url}, ${args.length > 0 ? `{ ${args.join(", ")} }` : "{}"})`;
316
+ const target = refAccessor(statePrefix, escapeToken(key));
317
+ const failed = `console.error(${JSON.stringify(`Jx server function ${def.$export}:`)}, e)`;
318
+
319
+ const lines = reactive
320
+ ? [
321
+ `// ${key}: server function ${def.$export}, called again when an argument changes`,
322
+ "{",
323
+ " let _jxSeq = 0;",
324
+ collect ? ` ${collect}.push(effect(() => {` : " effect(() => {",
325
+ " const _seq = ++_jxSeq;",
326
+ ` ${call}`,
327
+ ` .then((d) => { if (_seq === _jxSeq) ${target} = d; })`,
328
+ ` .catch((e) => { if (_seq === _jxSeq) ${failed}; });`,
329
+ collect ? " }));" : " });",
330
+ "}",
331
+ ]
332
+ : [
333
+ `// ${key}: server function ${def.$export}, called once`,
334
+ call,
335
+ ` .then((d) => { ${target} = d; })`,
336
+ ` .catch((e) => { ${failed}; });`,
337
+ ];
338
+ return lines.map((line) => `${indent}${line}`).join("\n");
339
+ }
340
+
261
341
  /**
262
342
  * Rebase the bare `state.` reads in a template string onto `statePrefix`.
263
343
  *
@@ -624,6 +704,12 @@ export function buildInitialScope(
624
704
  setOwnScopeValue(scope, key, cloneValue(def.default));
625
705
  continue;
626
706
  }
707
+ if (isServerFnDef(def)) {
708
+ // Its value is an answer from the site worker. Storing the definition object instead would
709
+ // Let a template that reads it bake `{ $src, $export, timing }` into the page.
710
+ runtimeOnly.add(key);
711
+ continue;
712
+ }
627
713
  if (!def.$prototype && !("$expression" in def) && !isSchemaOnlyDef(def)) {
628
714
  setOwnScopeValue(scope, key, cloneValue(def));
629
715
  }
@@ -1282,6 +1368,39 @@ export function attrHelperSource(): string {
1282
1368
  );
1283
1369
  }
1284
1370
 
1371
+ /** The name of the call helper a module holding a `timing: "server"` entry declares once. */
1372
+ export const SERVER_CALL_HELPER = "__jxServerCall";
1373
+
1374
+ /**
1375
+ * The request a compiled server-function call makes, as source for a generated module's preamble
1376
+ * (compiler.md §6.1), declared once per module and called by every `emitServerCall` block in it.
1377
+ *
1378
+ * The response rule lives here, in one place: the body is read as text and parsed when it is not
1379
+ * empty, so a function that returns nothing answers `null`; a non-2xx answer rejects with the
1380
+ * parsed body's `error`, else the body text, else the status text. A body that is not JSON only
1381
+ * matters on success, where it is a defect worth surfacing, and a failure still reports its text.
1382
+ *
1383
+ * @returns {string} One `async function` declaration.
1384
+ */
1385
+ export function serverCallHelperSource(): string {
1386
+ return [
1387
+ `async function ${SERVER_CALL_HELPER}(url, args) {`,
1388
+ " const r = await fetch(url, {",
1389
+ " method: 'POST',",
1390
+ " headers: { 'Content-Type': 'application/json' },",
1391
+ " body: JSON.stringify(args),",
1392
+ " });",
1393
+ " const text = await r.text();",
1394
+ " let body = null;",
1395
+ " if (text !== '') {",
1396
+ " try { body = JSON.parse(text); } catch (e) { if (r.ok) throw e; }",
1397
+ " }",
1398
+ " if (!r.ok) throw new Error((body && body.error) || text || r.statusText);",
1399
+ " return body;",
1400
+ "}",
1401
+ ].join("\n");
1402
+ }
1403
+
1285
1404
  /** The capture helper a light-DOM component module calls before its first render. */
1286
1405
  export const SLOT_CAPTURE_HELPER = "__jxCaptureSlotted";
1287
1406
 
@@ -1625,24 +1744,27 @@ function _walkSrc(def: JxElement | JxMutableNode | string, srcs: Set<string>) {
1625
1744
  }
1626
1745
 
1627
1746
  /**
1628
- * Recursively collect all `timing: "server"` entries from the document tree.
1747
+ * Recursively collect all `timing: "server"` entries from the document tree: one record per entry,
1748
+ * in walk order (the root's `state` keys, then `children` depth-first). Nothing is deduplicated, so
1749
+ * two keys naming one export are both listed; which of them a handler serves is the caller's rule
1750
+ * (compiler.md §6.3).
1629
1751
  *
1630
1752
  * @param {JxElement} doc
1631
1753
  * @returns {{ key: string; exportName: string; src: string }[]}
1632
1754
  */
1633
1755
  export function collectServerEntries(doc: JxElement) {
1634
- const entries = new Map<string, { key: string; exportName: string; src: string }>();
1756
+ const entries: { key: string; exportName: string; src: string }[] = [];
1635
1757
  _walkServerEntries(doc, entries);
1636
- return [...entries.values()];
1758
+ return entries;
1637
1759
  }
1638
1760
 
1639
1761
  /**
1640
1762
  * @param {JxElement | JxMutableNode | string} def
1641
- * @param {Map<string, { key: string; exportName: string; src: string }>} entries
1763
+ * @param {{ key: string; exportName: string; src: string }[]} entries
1642
1764
  */
1643
1765
  function _walkServerEntries(
1644
1766
  def: JxElement | JxMutableNode | string,
1645
- entries: Map<string, { key: string; exportName: string; src: string }>,
1767
+ entries: { key: string; exportName: string; src: string }[],
1646
1768
  ) {
1647
1769
  if (!isNonNullObject(def)) {
1648
1770
  return;
@@ -1650,7 +1772,7 @@ function _walkServerEntries(
1650
1772
  if (def.state) {
1651
1773
  for (const [key, d] of Object.entries(def.state)) {
1652
1774
  if (isServerFnDef(d)) {
1653
- entries.set(d.$export, { exportName: d.$export, key, src: d.$src });
1775
+ entries.push({ exportName: d.$export, key, src: d.$src });
1654
1776
  }
1655
1777
  }
1656
1778
  }
@@ -2346,8 +2468,9 @@ function resolveSelector(prop: string, scope: string): string {
2346
2468
 
2347
2469
  /** One member of {@link resolveSelector}'s list against the scope. */
2348
2470
  function resolveSelectorMember(prop: string, scope: string): string {
2349
- if (prop.startsWith("&")) {
2350
- return prop.replaceAll("&", scope);
2471
+ const spliced = spliceNestingSelector(prop, scope);
2472
+ if (spliced !== null) {
2473
+ return spliced;
2351
2474
  }
2352
2475
  if (prop.startsWith(":host")) {
2353
2476
  const inner = /^:host\((.*)\)$/.exec(prop)?.[1];
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The Bun bundler backend: `Bun.build` and `Bun.resolveSync`. Selected by the `bun` condition of
3
+ * `#bundler-backend` (`bundler-backend.ts`), so only a Bun process ever loads this file.
4
+ *
5
+ * `JX_BUNDLER=esbuild` hands every call to the esbuild backend instead, so a parity check can run
6
+ * both under one Bun.
7
+ */
8
+ import { rmSync, writeFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import type { BuildConfig } from "bun";
11
+ import type { BackendBuild, BundleTargetName } from "./bundler-backend.ts";
12
+ import { TRANSIENT_ENTRY_PREFIX } from "./bundler-backend.ts";
13
+ import * as esbuildBackend from "./bundler-backend.node.ts";
14
+
15
+ /** True when this process was told to bundle with esbuild instead. */
16
+ function forcedToEsbuild(): boolean {
17
+ return process.env.JX_BUNDLER === "esbuild";
18
+ }
19
+
20
+ /** Which bundler this backend runs. */
21
+ export function backendName(): "bun" | "esbuild" {
22
+ return forcedToEsbuild() ? "esbuild" : "bun";
23
+ }
24
+
25
+ /** Resolve a bare specifier as an import from a module in `fromDir`. Throws when it does not. */
26
+ export function resolveBare(specifier: string, fromDir: string): string {
27
+ if (forcedToEsbuild()) {
28
+ return esbuildBackend.resolveBare(specifier, fromDir);
29
+ }
30
+ return Bun.resolveSync(specifier, fromDir);
31
+ }
32
+
33
+ /** Map a bundle target to Bun.build's target. */
34
+ function bunTarget(target: BundleTargetName): "browser" | "node" | "bun" {
35
+ if (target === "workerd") {
36
+ return "browser";
37
+ }
38
+ return target;
39
+ }
40
+
41
+ /** Bundle one file with `Bun.build` and return the output's text. */
42
+ async function buildFile(entryPath: string, request: BackendBuild): Promise<string> {
43
+ const config: BuildConfig = {
44
+ entrypoints: [entryPath],
45
+ external: request.external,
46
+ format: "esm",
47
+ minify: request.minify,
48
+ plugins: request.plugins,
49
+ target: bunTarget(request.target),
50
+ throw: false,
51
+ };
52
+ if (request.conditions) {
53
+ config.conditions = request.conditions;
54
+ }
55
+ if (request.define) {
56
+ config.define = request.define;
57
+ }
58
+ const result = await Bun.build(config);
59
+ if (!result.success || result.outputs.length === 0) {
60
+ const logs = result.logs.map(String).join("\n");
61
+ throw new Error(`Bun.build failed for ${entryPath}:\n${logs}`);
62
+ }
63
+ return result.outputs[0]!.text();
64
+ }
65
+
66
+ /** Bundle to one ESM module and return its text. Throws with the backend's diagnostics on failure. */
67
+ export async function build(request: BackendBuild): Promise<string> {
68
+ if (forcedToEsbuild()) {
69
+ return esbuildBackend.build(request);
70
+ }
71
+ const { entry } = request;
72
+ if ("path" in entry) {
73
+ return buildFile(entry.path, request);
74
+ }
75
+ // Bun.build has no stdin input: write a transient entry in `resolveDir` so resolution matches
76
+ // The stdin/resolveDir behaviour, and always remove it.
77
+ const entryPath = join(entry.resolveDir, `${TRANSIENT_ENTRY_PREFIX}${process.pid}.mjs`);
78
+ writeFileSync(entryPath, entry.source, "utf8");
79
+ try {
80
+ return await buildFile(entryPath, request);
81
+ } finally {
82
+ rmSync(entryPath, { force: true });
83
+ }
84
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The esbuild bundler backend: the default of `#bundler-backend` (`bundler-backend.ts`), so every
3
+ * runtime that is not Bun bundles with it, and Bun does too under `JX_BUNDLER=esbuild`.
4
+ */
5
+ import { createRequire } from "node:module";
6
+ import { join } from "node:path";
7
+ import type { BuildOptions } from "esbuild";
8
+ import type { BackendBuild, BundleTargetName } from "./bundler-backend.ts";
9
+
10
+ /** Which bundler this backend runs. */
11
+ export function backendName(): "bun" | "esbuild" {
12
+ return "esbuild";
13
+ }
14
+
15
+ /** Resolve a bare specifier as an import from a module in `fromDir`. Throws when it does not. */
16
+ export function resolveBare(specifier: string, fromDir: string): string {
17
+ return createRequire(join(fromDir, "package.json")).resolve(specifier);
18
+ }
19
+
20
+ /** Map a bundle target to esbuild's platform (conditions carry the workerd specifics). */
21
+ function esbuildPlatform(target: BundleTargetName): "browser" | "node" {
22
+ return target === "node" || target === "bun" ? "node" : "browser";
23
+ }
24
+
25
+ /** Bundle to one ESM module and return its text. Throws with esbuild's diagnostics on failure. */
26
+ export async function build(request: BackendBuild): Promise<string> {
27
+ // Dynamic import keeps esbuild (a native-binary dependency) off the load path until a bundle runs.
28
+ const esbuild = await import("esbuild");
29
+ const { entry } = request;
30
+ const options: BuildOptions = {
31
+ bundle: true,
32
+ external: request.external,
33
+ format: "esm",
34
+ logLevel: "silent",
35
+ minify: request.minify,
36
+ platform: esbuildPlatform(request.target),
37
+ plugins: request.plugins,
38
+ write: false,
39
+ };
40
+ if (request.conditions) {
41
+ options.conditions = request.conditions;
42
+ }
43
+ if (request.define) {
44
+ options.define = request.define;
45
+ }
46
+ if ("path" in entry) {
47
+ options.entryPoints = [entry.path];
48
+ } else {
49
+ options.stdin = {
50
+ contents: entry.source,
51
+ loader: "js",
52
+ resolveDir: entry.resolveDir,
53
+ sourcefile: "jx-entry.js",
54
+ };
55
+ }
56
+ const result = await esbuild.build({ ...options, write: false });
57
+ return result.outputFiles[0]!.text;
58
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The contract between `bundler.ts` and the backend that does the bundling.
3
+ *
4
+ * The backend is chosen at **resolution time** by the `#bundler-backend` import's conditions
5
+ * (Node's conditional exports): `bundler-backend.bun.ts` under Bun, `bundler-backend.node.ts`
6
+ * (esbuild) everywhere else. A build of this package for Node therefore contains no Bun code at
7
+ * all, which is what lets the desktop backend run on Electron's Node (specs/desktop.md §7.1). Under
8
+ * Bun, `JX_BUNDLER=esbuild` still forces the esbuild backend for parity checks.
9
+ *
10
+ * `bundler.ts` owns everything both backends agree on (the defines, the minify default, the hook
11
+ * plugin); a backend owns only the call into its bundler and the spelling of its target.
12
+ */
13
+
14
+ /** Resolution/runtime targets: client sidecars use "browser"; the site worker uses the rest. */
15
+ export type BundleTargetName = "browser" | "node" | "bun" | "workerd";
16
+
17
+ /**
18
+ * The part of a plugin builder the hooks use, typed so that both Bun's `PluginBuilder` and
19
+ * esbuild's `PluginBuild` are assignable to it: a plugin written against it is a plugin for either
20
+ * backend.
21
+ */
22
+ export interface PluginBuild {
23
+ onResolve: (
24
+ options: { filter: RegExp },
25
+ callback: (args: {
26
+ path: string;
27
+ importer: string;
28
+ }) => { path: string; external: true } | undefined,
29
+ ) => void;
30
+ /** An `undefined` result hands the file back to the backend's own loader. */
31
+ onLoad: (options: { filter: RegExp }, callback: (args: { path: string }) => undefined) => void;
32
+ }
33
+
34
+ /** A plugin in the one shape both `Bun.build` and `esbuild.build` accept. */
35
+ export interface BundlePlugin {
36
+ name: string;
37
+ setup: (build: PluginBuild) => void;
38
+ }
39
+
40
+ /** One bundle, with every option already decided by `bundler.ts`. */
41
+ export interface BackendBuild {
42
+ /** A file to start from, or source text resolved as though it sat in `resolveDir`. */
43
+ entry: { path: string } | { source: string; resolveDir: string };
44
+ target: BundleTargetName;
45
+ external: string[];
46
+ conditions?: string[];
47
+ define?: Record<string, string>;
48
+ minify: boolean;
49
+ plugins: BundlePlugin[];
50
+ }
51
+
52
+ /** Basename prefix of the throwaway entry the Bun backend writes for a source entry. */
53
+ export const TRANSIENT_ENTRY_PREFIX = ".jx-entry-";