@jxsuite/compiler 1.5.0 → 2.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/dist/cli.js +563 -228
- package/dist/compiler.js +335 -132
- package/package.json +11 -9
- package/src/compiler.ts +1 -1
- package/src/shadow.ts +65 -0
- package/src/shared.ts +234 -52
- package/src/site/bundler.ts +149 -18
- package/src/site/client-runtime.ts +374 -0
- package/src/site/context-injection.ts +45 -2
- package/src/site/csp.ts +224 -0
- package/src/site/head-merger.ts +57 -13
- package/src/site/headers-emitter.ts +232 -0
- package/src/site/i18n.ts +365 -0
- package/src/site/image-optimizer.ts +7 -0
- package/src/site/image-transform.ts +269 -118
- package/src/site/img-loading.ts +55 -0
- package/src/site/link-relations.ts +251 -0
- package/src/site/locale-negotiation.ts +258 -0
- package/src/site/pages-discovery.ts +149 -0
- package/src/site/preview-server.ts +9 -1
- package/src/site/schema-command.ts +109 -18
- package/src/site/service-worker.ts +294 -0
- package/src/site/site-build.ts +903 -95
- package/src/site/site-loader.ts +1 -0
- package/src/site/validate-command.ts +1 -1
- package/src/site/well-known.ts +221 -0
- package/src/targets/compile-client.ts +85 -18
- package/src/targets/compile-element.ts +393 -46
- package/src/targets/compile-server.ts +19 -2
- package/src/targets/compile-static.ts +4 -1
- package/src/types.ts +2 -0
package/src/site/bundler.ts
CHANGED
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
|
|
14
14
|
import { rmSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { createRequire } from "node:module";
|
|
16
|
-
import {
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { basename, join, resolve } from "node:path";
|
|
17
18
|
import { isNpmSpecifier, NPM_SPECIFIER_PREFIX } from "@jxsuite/schema/asset-paths";
|
|
19
|
+
import type { BunPlugin } from "bun";
|
|
18
20
|
|
|
19
21
|
/** Import specifiers left external in client bundles — provided by the page importmap. */
|
|
20
22
|
export const CLIENT_EXTERNALS = ["@vue/reactivity", "lit-html"];
|
|
@@ -31,7 +33,7 @@ export function isBundleableSrc(specifier: string): boolean {
|
|
|
31
33
|
* True when the Node code path (createRequire resolution, esbuild backend) should be used: always
|
|
32
34
|
* under plain Node, or when `JX_BUNDLER=esbuild` forces it for parity checks.
|
|
33
35
|
*/
|
|
34
|
-
function useNodeFallback(): boolean {
|
|
36
|
+
export function useNodeFallback(): boolean {
|
|
35
37
|
return typeof Bun === "undefined" || process.env.JX_BUNDLER === "esbuild";
|
|
36
38
|
}
|
|
37
39
|
|
|
@@ -65,12 +67,68 @@ export interface BundleRequest {
|
|
|
65
67
|
/** Resolution/runtime targets: client sidecars use "browser"; the site worker uses the rest. */
|
|
66
68
|
export type BundleTargetName = "browser" | "node" | "bun" | "workerd";
|
|
67
69
|
|
|
70
|
+
/**
|
|
71
|
+
* A resolution hook, in the one shape both backends accept.
|
|
72
|
+
*
|
|
73
|
+
* `external: true` leaves the import in the output **as the source wrote it** — Bun ignores a
|
|
74
|
+
* rewritten `path` for an external, so a caller that needs the two backends to agree returns the
|
|
75
|
+
* specifier it was given and plans around the original text.
|
|
76
|
+
*/
|
|
77
|
+
export interface BundleResolver {
|
|
78
|
+
/** Which specifiers the hook is consulted for. */
|
|
79
|
+
filter: RegExp;
|
|
80
|
+
/** `undefined` falls through to the bundler's own resolution. */
|
|
81
|
+
resolve: (args: {
|
|
82
|
+
path: string;
|
|
83
|
+
importer: string;
|
|
84
|
+
}) => { path: string; external: true } | undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
68
87
|
export interface BundleOptions {
|
|
69
88
|
target: BundleTargetName;
|
|
70
89
|
/** Bare specifiers left unresolved in the output. */
|
|
71
90
|
external?: string[];
|
|
72
91
|
/** Package.json export conditions for resolution (e.g. ["workerd", "worker"]). */
|
|
73
92
|
conditions?: string[];
|
|
93
|
+
/** Resolution hooks, applied before the bundler's own resolution. */
|
|
94
|
+
resolver?: BundleResolver;
|
|
95
|
+
/**
|
|
96
|
+
* Called with the absolute path of every file the bundler loads.
|
|
97
|
+
*
|
|
98
|
+
* The only way to ask a backend where a bare specifier _actually_ lands: resolution depends on
|
|
99
|
+
* the target, the export conditions and the `NODE_ENV` define, so a caller that resolves the
|
|
100
|
+
* specifier itself gets a different file than the bundle did — which is how the client runtime
|
|
101
|
+
* used to hand pages the `node` build of a browser package.
|
|
102
|
+
*/
|
|
103
|
+
onFileLoaded?: (path: string) => void;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface PluginBuild {
|
|
107
|
+
onResolve: (
|
|
108
|
+
options: { filter: RegExp },
|
|
109
|
+
callback: (args: { path: string; importer: string }) => unknown,
|
|
110
|
+
) => void;
|
|
111
|
+
onLoad: (options: { filter: RegExp }, callback: (args: { path: string }) => unknown) => void;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Wrap the hooks as the one-plugin array both backends take. */
|
|
115
|
+
function hookPlugins(opts: BundleOptions): NonNullable<Parameters<typeof Bun.build>[0]["plugins"]> {
|
|
116
|
+
const { onFileLoaded, resolver } = opts;
|
|
117
|
+
if (resolver === undefined && onFileLoaded === undefined) {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
const setup = (build: PluginBuild): void => {
|
|
121
|
+
if (resolver !== undefined) {
|
|
122
|
+
build.onResolve({ filter: resolver.filter }, (args) => resolver.resolve(args));
|
|
123
|
+
}
|
|
124
|
+
if (onFileLoaded !== undefined) {
|
|
125
|
+
// Returning nothing hands the file back to the backend's own loader — this only observes.
|
|
126
|
+
build.onLoad({ filter: /.*/ }, (args) => {
|
|
127
|
+
onFileLoaded(args.path);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
return [{ name: "jx-bundle-hooks", setup } as unknown as BunPlugin];
|
|
74
132
|
}
|
|
75
133
|
|
|
76
134
|
/**
|
|
@@ -90,6 +148,19 @@ export function workerBundleOptions(adapter: string): BundleOptions {
|
|
|
90
148
|
return { target: adapter === "bun" ? "bun" : "node" };
|
|
91
149
|
}
|
|
92
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Substitutions applied to browser bundles.
|
|
153
|
+
*
|
|
154
|
+
* `process` does not exist in a browser, so `process.env.NODE_ENV` has to be replaced by something
|
|
155
|
+
* — but the value also decides which `exports` condition a package resolves to, and that is the
|
|
156
|
+
* part that mattered. Bun reads it from the build's own `define` (it ignores a `NODE_ENV` set after
|
|
157
|
+
* the process started), and with no answer it assumes development: `lit-html` was resolving to its
|
|
158
|
+
* 31 kB dev build, which logs "Lit is in dev mode" into every visitor's console, instead of the 10
|
|
159
|
+
* kB production one. esbuild already chose production, so the two backends were shipping different
|
|
160
|
+
* code — exactly what this module exists to prevent.
|
|
161
|
+
*/
|
|
162
|
+
const BROWSER_DEFINE = { "process.env.NODE_ENV": '"production"' } as const;
|
|
163
|
+
|
|
93
164
|
/** Map a bundle target to esbuild's platform (conditions carry the workerd specifics). */
|
|
94
165
|
function esbuildPlatform(target: BundleTargetName): "browser" | "node" {
|
|
95
166
|
return target === "node" || target === "bun" ? "node" : "browser";
|
|
@@ -109,6 +180,7 @@ function bunTarget(target: BundleTargetName): "browser" | "node" | "bun" {
|
|
|
109
180
|
*/
|
|
110
181
|
export async function bundleEntry(request: BundleRequest, opts: BundleOptions): Promise<void> {
|
|
111
182
|
const external = opts.external ?? [];
|
|
183
|
+
const plugins = hookPlugins(opts);
|
|
112
184
|
|
|
113
185
|
if (useNodeFallback()) {
|
|
114
186
|
// Dynamic import keeps esbuild (a native-binary dependency) out of Bun-run builds entirely.
|
|
@@ -116,6 +188,7 @@ export async function bundleEntry(request: BundleRequest, opts: BundleOptions):
|
|
|
116
188
|
await esbuild.build({
|
|
117
189
|
bundle: true,
|
|
118
190
|
...(opts.conditions ? { conditions: opts.conditions } : {}),
|
|
191
|
+
...(opts.target === "browser" ? { define: { ...BROWSER_DEFINE } } : {}),
|
|
119
192
|
entryPoints: [request.entryPath],
|
|
120
193
|
external,
|
|
121
194
|
format: "esm",
|
|
@@ -123,16 +196,19 @@ export async function bundleEntry(request: BundleRequest, opts: BundleOptions):
|
|
|
123
196
|
minify: false,
|
|
124
197
|
outfile: request.outfile,
|
|
125
198
|
platform: esbuildPlatform(opts.target),
|
|
199
|
+
plugins: plugins as unknown as NonNullable<Parameters<typeof esbuild.build>[0]["plugins"]>,
|
|
126
200
|
});
|
|
127
201
|
return;
|
|
128
202
|
}
|
|
129
203
|
|
|
130
204
|
const result = await Bun.build({
|
|
131
205
|
...(opts.conditions ? { conditions: opts.conditions } : {}),
|
|
206
|
+
...(opts.target === "browser" ? { define: { ...BROWSER_DEFINE } } : {}),
|
|
132
207
|
entrypoints: [request.entryPath],
|
|
133
208
|
external,
|
|
134
209
|
format: "esm",
|
|
135
210
|
minify: false,
|
|
211
|
+
plugins,
|
|
136
212
|
target: bunTarget(opts.target),
|
|
137
213
|
throw: false,
|
|
138
214
|
});
|
|
@@ -143,41 +219,84 @@ export async function bundleEntry(request: BundleRequest, opts: BundleOptions):
|
|
|
143
219
|
writeFileSync(request.outfile, await result.outputs[0]!.text(), "utf8");
|
|
144
220
|
}
|
|
145
221
|
|
|
222
|
+
/** Basename prefix of the throwaway entry {@link bundleSource} writes for the Bun backend. */
|
|
223
|
+
const TRANSIENT_ENTRY_PREFIX = ".jx-entry-";
|
|
224
|
+
|
|
146
225
|
/**
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
226
|
+
* True for the throwaway entry a {@link bundleSource} call is bundling, under either backend.
|
|
227
|
+
*
|
|
228
|
+
* A resolver hook needs it to tell the entry's OWN import apart from the imports of whatever that
|
|
229
|
+
* import pulled in — the two want opposite answers, and only the importer distinguishes them.
|
|
230
|
+
* esbuild bundles the string from stdin and names it `<stdin>`; Bun has no stdin input and writes a
|
|
231
|
+
* real file.
|
|
150
232
|
*/
|
|
151
|
-
export
|
|
233
|
+
export function isTransientEntry(importer: string): boolean {
|
|
234
|
+
return importer === "<stdin>" || basename(importer).startsWith(TRANSIENT_ENTRY_PREFIX);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The file a bare specifier resolves to **through the bundler** — under the same target, export
|
|
239
|
+
* conditions and `NODE_ENV` define the real bundle uses.
|
|
240
|
+
*
|
|
241
|
+
* `Bun.resolveSync` and `createRequire` answer a different question. Neither applies the export
|
|
242
|
+
* conditions a browser bundle does, so both hand back the `node` or `development` build of a
|
|
243
|
+
* package a page asked for the browser build of — the bug {@link BROWSER_DEFINE} exists to prevent,
|
|
244
|
+
* reintroduced one layer up. Returns `undefined` when the specifier does not resolve at all.
|
|
245
|
+
*/
|
|
246
|
+
export async function resolveThroughBundler(
|
|
247
|
+
specifier: string,
|
|
248
|
+
resolveDir: string,
|
|
249
|
+
opts: BundleOptions,
|
|
250
|
+
): Promise<string | undefined> {
|
|
251
|
+
const loaded: string[] = [];
|
|
252
|
+
const outfile = join(tmpdir(), `jx-resolve-${process.pid}.js`);
|
|
253
|
+
try {
|
|
254
|
+
await bundleSource(
|
|
255
|
+
`export * from ${JSON.stringify(specifier)};\n`,
|
|
256
|
+
{ outfile, resolveDir },
|
|
257
|
+
{ ...opts, onFileLoaded: (path) => loaded.push(path) },
|
|
258
|
+
);
|
|
259
|
+
} catch {
|
|
260
|
+
return undefined;
|
|
261
|
+
} finally {
|
|
262
|
+
rmSync(outfile, { force: true });
|
|
263
|
+
}
|
|
264
|
+
return loaded.find((path) => !isTransientEntry(path));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Bundle a source string rather than a file, resolving its imports from `resolveDir`.
|
|
269
|
+
*
|
|
270
|
+
* The indirection buys resolution control: what a bare specifier means depends on where you stand,
|
|
271
|
+
* and the two callers stand in different places. Worker source resolves from the project root,
|
|
272
|
+
* exactly as the unbundled source would have; the client runtime resolves from the compiler's own
|
|
273
|
+
* directory, because the compiler is what depends on it.
|
|
274
|
+
*/
|
|
275
|
+
export async function bundleSource(
|
|
152
276
|
source: string,
|
|
153
|
-
{
|
|
277
|
+
{ resolveDir, outfile }: { resolveDir: string; outfile: string },
|
|
278
|
+
opts: BundleOptions,
|
|
154
279
|
): Promise<void> {
|
|
155
|
-
const opts = workerBundleOptions(adapter);
|
|
156
|
-
|
|
157
280
|
if (useNodeFallback()) {
|
|
158
281
|
const esbuild = await import("esbuild");
|
|
159
282
|
await esbuild.build({
|
|
160
283
|
bundle: true,
|
|
161
284
|
...(opts.conditions ? { conditions: opts.conditions } : {}),
|
|
285
|
+
...(opts.target === "browser" ? { define: { ...BROWSER_DEFINE } } : {}),
|
|
162
286
|
external: opts.external ?? [],
|
|
163
287
|
format: "esm",
|
|
164
288
|
logLevel: "silent",
|
|
165
289
|
minify: false,
|
|
166
290
|
outfile,
|
|
167
291
|
platform: esbuildPlatform(opts.target),
|
|
168
|
-
stdin: {
|
|
169
|
-
contents: source,
|
|
170
|
-
loader: "js",
|
|
171
|
-
resolveDir: projectRoot,
|
|
172
|
-
sourcefile: "jx-worker-entry.js",
|
|
173
|
-
},
|
|
292
|
+
stdin: { contents: source, loader: "js", resolveDir, sourcefile: "jx-entry.js" },
|
|
174
293
|
});
|
|
175
294
|
return;
|
|
176
295
|
}
|
|
177
296
|
|
|
178
|
-
// Bun.build has no stdin input — write a transient entry
|
|
179
|
-
//
|
|
180
|
-
const entryPath = join(
|
|
297
|
+
// Bun.build has no stdin input — write a transient entry in `resolveDir` so resolution matches
|
|
298
|
+
// The stdin/resolveDir behavior, and always remove it.
|
|
299
|
+
const entryPath = join(resolveDir, `${TRANSIENT_ENTRY_PREFIX}${process.pid}.mjs`);
|
|
181
300
|
writeFileSync(entryPath, source, "utf8");
|
|
182
301
|
try {
|
|
183
302
|
await bundleEntry({ entryPath, outfile }, opts);
|
|
@@ -185,3 +304,15 @@ export async function bundleWorkerSource(
|
|
|
185
304
|
rmSync(entryPath, { force: true });
|
|
186
305
|
}
|
|
187
306
|
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Bundle generated worker source into a self-contained module for the adapter's runtime. Bare and
|
|
310
|
+
* relative specifiers resolve from the project root — exactly how the unbundled source would have
|
|
311
|
+
* resolved when served from within the project.
|
|
312
|
+
*/
|
|
313
|
+
export async function bundleWorkerSource(
|
|
314
|
+
source: string,
|
|
315
|
+
{ projectRoot, outfile, adapter }: { projectRoot: string; outfile: string; adapter: string },
|
|
316
|
+
): Promise<void> {
|
|
317
|
+
await bundleSource(source, { outfile, resolveDir: projectRoot }, workerBundleOptions(adapter));
|
|
318
|
+
}
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two modules every interactive page imports, served from the site rather than from a CDN.
|
|
3
|
+
*
|
|
4
|
+
* They used to resolve to `https://esm.sh/…` in the emitted import map. That put a third party in
|
|
5
|
+
* the load path of every interactive Jx site — no integrity metadata, no availability guarantee —
|
|
6
|
+
* and it made a strict Content-Security-Policy impossible, because `default-src 'self'` would have
|
|
7
|
+
* broken every one of those pages. The old argument for a shared CDN was a shared browser cache,
|
|
8
|
+
* and that argument expired when browsers partitioned the HTTP cache by top-level site: a visitor
|
|
9
|
+
* arriving from another esm.sh site downloads it again regardless.
|
|
10
|
+
*
|
|
11
|
+
* Bundled, not copied, and resolved from **this package's** directory rather than the project's:
|
|
12
|
+
* `@jxsuite/compiler` is what depends on these versions, so a project that never installed them
|
|
13
|
+
* still gets the runtime its compiled output was built against.
|
|
14
|
+
*
|
|
15
|
+
* @docs framework/build
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { createRequire } from "node:module";
|
|
20
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
21
|
+
import type { BundleResolver } from "./bundler.ts";
|
|
22
|
+
import {
|
|
23
|
+
bundleSource,
|
|
24
|
+
isTransientEntry,
|
|
25
|
+
resolveThroughBundler,
|
|
26
|
+
useNodeFallback,
|
|
27
|
+
} from "./bundler.ts";
|
|
28
|
+
import { DEFAULT_LIT_HTML_SRC, DEFAULT_REACTIVITY_SRC } from "../shared.ts";
|
|
29
|
+
|
|
30
|
+
/** One import-map entry the build can satisfy itself. */
|
|
31
|
+
interface RuntimeModule {
|
|
32
|
+
specifier: string;
|
|
33
|
+
assetPath: string;
|
|
34
|
+
/** Where the entry points when this package cannot be resolved at all. */
|
|
35
|
+
fallback: string;
|
|
36
|
+
/**
|
|
37
|
+
* Directory this package's SUBPATHS are served from, and the value of its import-map prefix key.
|
|
38
|
+
*
|
|
39
|
+
* A third-party component does not import only `lit-html`; it imports
|
|
40
|
+
* `lit-html/directives/class-map.js` too. Those stay external in its bundle — a package-name
|
|
41
|
+
* external covers the package's subpaths as well — so without a prefix key the page dies on
|
|
42
|
+
* `Failed to resolve module specifier`. Trailing slashes on both sides are required: that is what
|
|
43
|
+
* makes it a prefix mapping rather than an exact one.
|
|
44
|
+
*/
|
|
45
|
+
assetDir: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const CLIENT_RUNTIME_MODULES: readonly RuntimeModule[] = [
|
|
49
|
+
{
|
|
50
|
+
assetDir: "/assets/@vue/reactivity/",
|
|
51
|
+
assetPath: "/assets/vue-reactivity.js",
|
|
52
|
+
fallback: DEFAULT_REACTIVITY_SRC,
|
|
53
|
+
specifier: "@vue/reactivity",
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
assetDir: "/assets/lit-html/",
|
|
57
|
+
assetPath: "/assets/lit-html.js",
|
|
58
|
+
fallback: DEFAULT_LIT_HTML_SRC,
|
|
59
|
+
specifier: "lit-html",
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
/** An import-map prefix key — `"lit-html/"` — as opposed to an exact specifier. */
|
|
64
|
+
export function isPrefixSpecifier(specifier: string): boolean {
|
|
65
|
+
return specifier.endsWith("/");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ClientRuntime {
|
|
69
|
+
/** The import map's `imports` object. */
|
|
70
|
+
imports: Record<string, string>;
|
|
71
|
+
/** Warnings for any module that fell back to the CDN. */
|
|
72
|
+
warnings: string[];
|
|
73
|
+
/** Site-relative asset paths this build must write. Empty when everything fell back. */
|
|
74
|
+
assetPaths: string[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve where each runtime module will be served from, without writing anything yet.
|
|
79
|
+
*
|
|
80
|
+
* Split from the write so the import map can be built before the first page is compiled while the
|
|
81
|
+
* bundles are only produced if some page actually emitted one — the same shape the sidecar bundler
|
|
82
|
+
* uses.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} [resolveDir] - Directory bare specifiers resolve from; defaults to this package
|
|
85
|
+
* @returns {ClientRuntime}
|
|
86
|
+
*/
|
|
87
|
+
export function resolveClientRuntime(resolveDir: string = import.meta.dir): ClientRuntime {
|
|
88
|
+
const imports: Record<string, string> = {};
|
|
89
|
+
const warnings: string[] = [];
|
|
90
|
+
const assetPaths: string[] = [];
|
|
91
|
+
|
|
92
|
+
for (const mod of CLIENT_RUNTIME_MODULES) {
|
|
93
|
+
if (canResolve(mod.specifier, resolveDir)) {
|
|
94
|
+
imports[mod.specifier] = mod.assetPath;
|
|
95
|
+
/*
|
|
96
|
+
* The prefix key is emitted whether or not any subpath turns out to be used. It costs two
|
|
97
|
+
* lines of JSON, and the alternative is knowing the subpath set before the first page is
|
|
98
|
+
* compiled — which is impossible, since the set is discovered from the bundles themselves.
|
|
99
|
+
*/
|
|
100
|
+
imports[`${mod.specifier}/`] = mod.assetDir;
|
|
101
|
+
assetPaths.push(mod.assetPath);
|
|
102
|
+
} else {
|
|
103
|
+
imports[mod.specifier] = mod.fallback;
|
|
104
|
+
warnings.push(
|
|
105
|
+
`Could not resolve "${mod.specifier}" — the import map falls back to ${mod.fallback}. ` +
|
|
106
|
+
"Interactive pages will load it from a third party, and a `default-src 'self'` policy " +
|
|
107
|
+
"will block them.",
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { assetPaths, imports, warnings };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Write the runtime bundles this build resolved locally.
|
|
116
|
+
*
|
|
117
|
+
* @param {ClientRuntime} runtime
|
|
118
|
+
* @param {string} outDir
|
|
119
|
+
* @param {string} [resolveDir]
|
|
120
|
+
* @returns {Promise<{ written: number; errors: string[] }>}
|
|
121
|
+
*/
|
|
122
|
+
export async function writeClientRuntime(
|
|
123
|
+
runtime: ClientRuntime,
|
|
124
|
+
outDir: string,
|
|
125
|
+
resolveDir: string = import.meta.dir,
|
|
126
|
+
): Promise<{ written: number; errors: string[] }> {
|
|
127
|
+
const errors: string[] = [];
|
|
128
|
+
let written = 0;
|
|
129
|
+
|
|
130
|
+
for (const mod of CLIENT_RUNTIME_MODULES) {
|
|
131
|
+
if (!runtime.assetPaths.includes(mod.assetPath)) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const outfile = resolve(outDir, mod.assetPath.replace(/^\//, ""));
|
|
135
|
+
mkdirSync(dirname(outfile), { recursive: true });
|
|
136
|
+
try {
|
|
137
|
+
/*
|
|
138
|
+
* Re-export rather than copy: the published file may be the `node` condition of a package
|
|
139
|
+
* with several, and only a bundler standing in the right place with the right conditions
|
|
140
|
+
* picks the browser one.
|
|
141
|
+
*/
|
|
142
|
+
await bundleSource(
|
|
143
|
+
`export * from ${JSON.stringify(mod.specifier)};\n`,
|
|
144
|
+
{ outfile, resolveDir },
|
|
145
|
+
{ target: "browser" },
|
|
146
|
+
);
|
|
147
|
+
written += 1;
|
|
148
|
+
} catch (error) {
|
|
149
|
+
errors.push(`Bundling client runtime "${mod.specifier}": ${(error as Error).message}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { errors, written };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Render the `<script type="importmap">` block for a resolved runtime. */
|
|
156
|
+
export function renderImportMap(imports: Record<string, string>): string {
|
|
157
|
+
const entries = Object.entries(imports)
|
|
158
|
+
.map(([name, url]) => ` ${JSON.stringify(name)}: ${JSON.stringify(url)}`)
|
|
159
|
+
.join(",\n");
|
|
160
|
+
return `<script type="importmap">\n {\n "imports": {\n${entries}\n }\n }\n </script>`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* True when a bare specifier resolves from `resolveDir` under either backend.
|
|
165
|
+
*
|
|
166
|
+
* The backend choice is `bundler.ts`'s to make, not a second inline `typeof Bun` check: resolution
|
|
167
|
+
* and bundling must agree about which one they are on, or the import map promises a module the
|
|
168
|
+
* bundle then fails to produce.
|
|
169
|
+
*/
|
|
170
|
+
function canResolve(specifier: string, resolveDir: string): boolean {
|
|
171
|
+
try {
|
|
172
|
+
if (useNodeFallback()) {
|
|
173
|
+
return typeof createRequire(join(resolveDir, "x.js")).resolve(specifier) === "string";
|
|
174
|
+
}
|
|
175
|
+
return typeof Bun.resolveSync(specifier, resolveDir) === "string";
|
|
176
|
+
} catch {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** How many discovery passes may run before the scan is declared cyclic rather than deep. */
|
|
182
|
+
const MAX_SUBPATH_PASSES = 10;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Emit the package subpaths the built bundles actually import, so the prefix keys resolve.
|
|
186
|
+
*
|
|
187
|
+
* The subpath set cannot be known before the build: it is a property of whichever third-party
|
|
188
|
+
* components a page happens to register. So it is discovered _from the output_ — every emitted
|
|
189
|
+
* `.js` asset is scanned for an import of `<runtime package>/<subpath>`, each is bundled to the
|
|
190
|
+
* directory its prefix key names, and the scan repeats over what that produced. Bundling can
|
|
191
|
+
* introduce new subpaths (a directive importing another directive), so the pass runs to closure
|
|
192
|
+
* rather than once.
|
|
193
|
+
*
|
|
194
|
+
* **The subpath itself must be bundled, and only the core kept external.** A package-name external
|
|
195
|
+
* covers the package's subpaths too, so listing `lit-html` in `external` externalises the entry —
|
|
196
|
+
* and the emitted asset is then a re-export of the very specifier the import map points back at it.
|
|
197
|
+
* That file imports itself: `export * from "lit-html/directives/class-map.js"` served AS
|
|
198
|
+
* `/assets/lit-html/directives/class-map.js`. A self-referential module has an empty namespace, so
|
|
199
|
+
* every page using a directive died on an undefined import while the build reported success. The
|
|
200
|
+
* externals are therefore decided by a resolver hook that can see the importer, not by a name
|
|
201
|
+
* list.
|
|
202
|
+
*
|
|
203
|
+
* The core stays shared, which is the part that must not regress: two copies of lit on a page is a
|
|
204
|
+
* documented breakage, not a waste. A package imports its own core by RELATIVE path from inside
|
|
205
|
+
* itself (`../lit-html.js`), and a bundler keeps an external's specifier exactly as the source
|
|
206
|
+
* wrote it — so the shared copy is reached by emitting a stub at the place that relative path lands
|
|
207
|
+
* in the OUTPUT tree, re-exporting the bare specifier the import map already resolves.
|
|
208
|
+
*
|
|
209
|
+
* @param {string} outDir - Build output root
|
|
210
|
+
* @param {string} [resolveDir] - Directory bare specifiers resolve from
|
|
211
|
+
* @returns {Promise<{ written: number; errors: string[] }>}
|
|
212
|
+
*/
|
|
213
|
+
export async function writeRuntimeSubpaths(
|
|
214
|
+
outDir: string,
|
|
215
|
+
resolveDir: string = import.meta.dir,
|
|
216
|
+
): Promise<{ written: number; errors: string[] }> {
|
|
217
|
+
const errors: string[] = [];
|
|
218
|
+
const done = new Set<string>();
|
|
219
|
+
/** Output path of a core re-export stub → the bare specifier it must re-export. */
|
|
220
|
+
const mirrors = new Map<string, string>();
|
|
221
|
+
/** Runtime package specifier → the core file the bundler resolves it to. */
|
|
222
|
+
const cores = new Map<string, string | undefined>();
|
|
223
|
+
const assetsDir = resolve(outDir, "assets");
|
|
224
|
+
let written = 0;
|
|
225
|
+
|
|
226
|
+
const coreOf = async (mod: RuntimeModule): Promise<string | undefined> => {
|
|
227
|
+
if (!cores.has(mod.specifier)) {
|
|
228
|
+
cores.set(
|
|
229
|
+
mod.specifier,
|
|
230
|
+
await resolveThroughBundler(mod.specifier, resolveDir, { target: "browser" }),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
return cores.get(mod.specifier);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/** `from "lit-html/directives/class-map.js"` — the import forms a bundler emits. */
|
|
237
|
+
const importedSpecifiers = async (file: string): Promise<string[]> => {
|
|
238
|
+
const text = await Bun.file(file).text();
|
|
239
|
+
return [...text.matchAll(/(?:from|import)\s*["']([^"']+)["']/g)].map((m) => m[1] as string);
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
for (let pass = 0; pass < MAX_SUBPATH_PASSES; pass++) {
|
|
243
|
+
let discovered = 0;
|
|
244
|
+
let files: string[] = [];
|
|
245
|
+
try {
|
|
246
|
+
files = await Array.fromAsync(
|
|
247
|
+
new Bun.Glob("**/*.js").scan({ absolute: true, cwd: assetsDir }),
|
|
248
|
+
);
|
|
249
|
+
} catch {
|
|
250
|
+
return finish(outDir, mirrors, errors, written);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
for (const file of files) {
|
|
254
|
+
let specifiers: string[] = [];
|
|
255
|
+
try {
|
|
256
|
+
specifiers = await importedSpecifiers(file);
|
|
257
|
+
} catch {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
for (const specifier of specifiers) {
|
|
261
|
+
const mod = CLIENT_RUNTIME_MODULES.find((m) => specifier.startsWith(`${m.specifier}/`));
|
|
262
|
+
if (!mod || done.has(specifier)) {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
done.add(specifier);
|
|
266
|
+
const subpath = specifier.slice(mod.specifier.length + 1);
|
|
267
|
+
const outfile = resolve(outDir, `${mod.assetDir.replace(/^\//, "")}${subpath}`);
|
|
268
|
+
mkdirSync(dirname(outfile), { recursive: true });
|
|
269
|
+
try {
|
|
270
|
+
await bundleSource(
|
|
271
|
+
`export * from ${JSON.stringify(specifier)};\n`,
|
|
272
|
+
{ outfile, resolveDir },
|
|
273
|
+
{
|
|
274
|
+
resolver: shareRuntimeCore(mod, outfile, await coreOf(mod), mirrors),
|
|
275
|
+
target: "browser",
|
|
276
|
+
},
|
|
277
|
+
);
|
|
278
|
+
written += 1;
|
|
279
|
+
discovered += 1;
|
|
280
|
+
} catch (error) {
|
|
281
|
+
errors.push(`Bundling runtime subpath "${specifier}": ${(error as Error).message}`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (discovered === 0) {
|
|
286
|
+
return finish(outDir, mirrors, errors, written);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
errors.push(
|
|
290
|
+
`Runtime subpath resolution did not settle in ${MAX_SUBPATH_PASSES} passes — a package subpath ` +
|
|
291
|
+
"graph this deep is more likely a cycle in the scan than a real dependency chain.",
|
|
292
|
+
);
|
|
293
|
+
return finish(outDir, mirrors, errors, written);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Keep a runtime package's core out of the subpath bundle, and note where the shared copy has to
|
|
298
|
+
* appear for the specifier the bundler will leave behind to reach it.
|
|
299
|
+
*
|
|
300
|
+
* @param {RuntimeModule} owner - The runtime package this subpath belongs to
|
|
301
|
+
* @param {string} outfile - Where this subpath's bundle is being written
|
|
302
|
+
* @param {string | undefined} core - The package's core file, or undefined if it did not resolve
|
|
303
|
+
* @param {Map<string, string>} mirrors - Collects the core re-export stubs the build must write
|
|
304
|
+
* @returns {BundleResolver}
|
|
305
|
+
*/
|
|
306
|
+
function shareRuntimeCore(
|
|
307
|
+
owner: RuntimeModule,
|
|
308
|
+
outfile: string,
|
|
309
|
+
core: string | undefined,
|
|
310
|
+
mirrors: Map<string, string>,
|
|
311
|
+
): BundleResolver {
|
|
312
|
+
return {
|
|
313
|
+
filter: /.*/,
|
|
314
|
+
resolve: ({ importer, path }) => {
|
|
315
|
+
/*
|
|
316
|
+
* The entry's own import is the subpath being bundled. It must resolve — externalising it
|
|
317
|
+
* is what made the emitted asset import itself.
|
|
318
|
+
*/
|
|
319
|
+
if (isTransientEntry(importer)) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const runtime = CLIENT_RUNTIME_MODULES.find(
|
|
323
|
+
(mod) => path === mod.specifier || path.startsWith(`${mod.specifier}/`),
|
|
324
|
+
);
|
|
325
|
+
if (runtime !== undefined) {
|
|
326
|
+
// Another runtime package, or another of this one's subpaths: the next pass emits it.
|
|
327
|
+
return { external: true, path };
|
|
328
|
+
}
|
|
329
|
+
if (core === undefined || !path.startsWith(".")) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
/*
|
|
333
|
+
* Package-internal relative imports are resolved by path rather than by asking a backend:
|
|
334
|
+
* the runtime packages are ESM and write their extensions, and the hook has to give the same
|
|
335
|
+
* answer under Bun and under esbuild.
|
|
336
|
+
*/
|
|
337
|
+
if (resolve(dirname(importer), path) !== core) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
mirrors.set(resolve(dirname(outfile), path), owner.specifier);
|
|
341
|
+
return { external: true, path };
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Write the core re-export stubs the emitted subpaths point at, and report the totals.
|
|
348
|
+
*
|
|
349
|
+
* A stub is skipped when a real bundle already occupies its path — that file is a subpath the build
|
|
350
|
+
* was asked for, and the request outranks the shim.
|
|
351
|
+
*/
|
|
352
|
+
function finish(
|
|
353
|
+
outDir: string,
|
|
354
|
+
mirrors: Map<string, string>,
|
|
355
|
+
errors: string[],
|
|
356
|
+
written: number,
|
|
357
|
+
): { written: number; errors: string[] } {
|
|
358
|
+
let total = written;
|
|
359
|
+
for (const [path, specifier] of mirrors) {
|
|
360
|
+
if (existsSync(path)) {
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
try {
|
|
364
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
365
|
+
writeFileSync(path, `export * from ${JSON.stringify(specifier)};\n`, "utf8");
|
|
366
|
+
total += 1;
|
|
367
|
+
} catch (error) {
|
|
368
|
+
errors.push(
|
|
369
|
+
`Writing shared core stub ${relative(outDir, path)}: ${(error as Error).message}`,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return { errors, written: total };
|
|
374
|
+
}
|