@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/README.md +0 -2
- package/dist/cli.js +255 -296
- package/dist/compiler.js +147 -186
- package/package.json +15 -9
- package/src/compiler.ts +21 -12
- package/src/shared.ts +133 -10
- package/src/site/bundler-backend.bun.ts +84 -0
- package/src/site/bundler-backend.node.ts +58 -0
- package/src/site/bundler-backend.ts +53 -0
- package/src/site/bundler.ts +49 -110
- package/src/site/client-runtime.ts +6 -11
- package/src/site/format-host.ts +98 -48
- package/src/site/image-cache.ts +38 -12
- package/src/site/layout-resolver.ts +43 -3
- package/src/site/schema-command.ts +26 -28
- package/src/site/server-entries.ts +118 -0
- package/src/site/site-build.ts +114 -52
- package/src/targets/compile-client.ts +54 -12
- package/src/targets/compile-element.ts +37 -0
- package/src/targets/compile-server.ts +17 -52
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/compiler",
|
|
3
|
-
"version": "
|
|
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
|
-
"@
|
|
49
|
-
"@jxsuite/
|
|
50
|
-
"@jxsuite/
|
|
51
|
-
"@jxsuite/
|
|
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.
|
|
61
|
-
"@jxsuite/parser": "^4.1.
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
|
1756
|
+
const entries: { key: string; exportName: string; src: string }[] = [];
|
|
1635
1757
|
_walkServerEntries(doc, entries);
|
|
1636
|
-
return
|
|
1758
|
+
return entries;
|
|
1637
1759
|
}
|
|
1638
1760
|
|
|
1639
1761
|
/**
|
|
1640
1762
|
* @param {JxElement | JxMutableNode | string} def
|
|
1641
|
-
* @param {
|
|
1763
|
+
* @param {{ key: string; exportName: string; src: string }[]} entries
|
|
1642
1764
|
*/
|
|
1643
1765
|
function _walkServerEntries(
|
|
1644
1766
|
def: JxElement | JxMutableNode | string,
|
|
1645
|
-
entries:
|
|
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.
|
|
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
|
-
|
|
2350
|
-
|
|
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-";
|