@junejs/server 0.0.9 → 0.0.11

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/src/build.ts CHANGED
@@ -17,6 +17,7 @@
17
17
  // content freeze (content/*.md → app/_content.ts) is what removes fs from the
18
18
  // dynamic route's graph; the worker reads frozen data, never the filesystem.
19
19
 
20
+ import { createHash } from "node:crypto";
20
21
  import { existsSync } from "node:fs";
21
22
  import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
22
23
  import { basename, dirname, join, relative, sep } from "node:path";
@@ -26,12 +27,15 @@ import { loadJuneConfig } from "./config-loader";
26
27
  import { resolveAgent, resolveSpeculationRules } from "@junejs/core/config";
27
28
  import { buildLinkHeader } from "@junejs/core/discovery";
28
29
  import { routeFromModule, type BrandedRoute } from "@junejs/core/route";
30
+ import { workers, type JuneAdapter, type ResourcePlan } from "./adapter";
29
31
  import type { DocumentConfig } from "@junejs/core/document";
30
32
  import { collection } from "./content";
31
33
  import { createWorker, type WorkerManifest } from "./worker";
32
34
  import { findExtraFile } from "./router";
33
- import type { ExtraHandler, LayoutComponent } from "./pipeline";
35
+ import type { ExtraHandler, LayoutComponent, LoadingComponent } from "./pipeline";
34
36
  import { findClientEntry, bundleClientToFile, CLIENT_SCRIPT_URL } from "./client-bundle";
37
+ import { findGlobalCss, minifyCss, processCss, STYLES_URL } from "./css";
38
+ import { buildModuleCss, rolldownCssModulesPlugin, registerCssModules } from "./css-modules";
35
39
 
36
40
  export type BuildResult = {
37
41
  outFile: string;
@@ -43,7 +47,13 @@ export type BuildResult = {
43
47
 
44
48
  // The segment layout CHAIN root→leaf: every directory level (route groups
45
49
  // included) may contribute a layout.* that wraps routes below it.
46
- type RouteEntry = { path: string; file: string; dynamic: boolean; layouts: string[] };
50
+ type RouteEntry = {
51
+ path: string;
52
+ file: string;
53
+ dynamic: boolean;
54
+ layouts: string[];
55
+ loading?: string; // nearest loading.tsx up the tree → streaming Suspense fallback
56
+ };
47
57
 
48
58
  const PAGE_BASENAMES = new Set(["page", "index"]);
49
59
  const ROUTE_EXTS = [".tsx", ".jsx", ".ts", ".js"];
@@ -62,15 +72,17 @@ export async function scanRoutes(
62
72
  dir = appDir,
63
73
  layouts: string[] = [],
64
74
  out: RouteEntry[] = [],
75
+ loading?: string,
65
76
  ): Promise<RouteEntry[]> {
66
77
  const ownLayout = segmentFile(dir, "layout");
67
78
  const chain = ownLayout ? [...layouts, ownLayout] : layouts;
79
+ const nearestLoading = segmentFile(dir, "loading") ?? loading;
68
80
  const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
69
81
  for (const e of entries) {
70
82
  if (e.name.startsWith("_") || e.name.startsWith(".")) continue;
71
83
  const full = join(dir, e.name);
72
84
  if (e.isDirectory()) {
73
- await scanRoutes(appDir, full, chain, out);
85
+ await scanRoutes(appDir, full, chain, out, nearestLoading);
74
86
  continue;
75
87
  }
76
88
  const ext = e.name.match(/\.[^.]+$/)?.[0] ?? "";
@@ -79,7 +91,13 @@ export async function scanRoutes(
79
91
  const relDir = relative(appDir, dir);
80
92
  const segments = relDir === "" ? [] : relDir.split(sep).filter((s) => !isRouteGroup(s));
81
93
  const path = "/" + segments.join("/");
82
- out.push({ path: path === "/" ? "/" : path, file: full, dynamic: /\[.+\]/.test(path), layouts: chain });
94
+ out.push({
95
+ path: path === "/" ? "/" : path,
96
+ file: full,
97
+ dynamic: /\[.+\]/.test(path),
98
+ layouts: chain,
99
+ loading: nearestLoading,
100
+ });
83
101
  }
84
102
  return out;
85
103
  }
@@ -129,13 +147,16 @@ export async function freezeConfig(appRoot: string): Promise<{
129
147
  // which re-freezes through buildManifest — sets the SAME clientScript, keeping
130
148
  // prerendered pages byte-equivalent to the live worker (parity).
131
149
  const hasClient = findClientEntry(join(appRoot, "app")) !== undefined;
150
+ const hasCss = findGlobalCss(join(appRoot, "app")) !== null;
132
151
  return {
133
152
  document: {
134
153
  site: cfg.site ?? {},
135
154
  speculationRules: resolveSpeculationRules(cfg.speculation ?? undefined),
136
155
  speculationDelivery: "inline",
137
156
  viewTransitions: cfg.viewTransitions ?? true,
157
+ clientRouter: cfg.clientRouter ?? false,
138
158
  clientScript: hasClient ? CLIENT_SCRIPT_URL : null,
159
+ styles: hasCss ? STYLES_URL : null,
139
160
  },
140
161
  agent: resolveAgent(cfg.agent),
141
162
  earlyHints: cfg.earlyHints ?? [],
@@ -167,15 +188,24 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
167
188
  return chain;
168
189
  };
169
190
 
191
+ const loadingCache = new Map<string, LoadingComponent | null>();
192
+ const loadingFor = async (file?: string): Promise<LoadingComponent | undefined> => {
193
+ if (!file) return undefined;
194
+ if (!loadingCache.has(file)) loadingCache.set(file, await importLayout(file) as LoadingComponent | null);
195
+ return loadingCache.get(file) ?? undefined;
196
+ };
197
+
170
198
  const routes: Record<string, BrandedRoute> = {};
171
199
  const dynamicRoutes: Array<{ pattern: string; def: BrandedRoute }> = [];
172
200
  const layoutChains: Record<string, LayoutComponent[]> = {};
201
+ const loadings: Record<string, LoadingComponent> = {};
173
202
 
174
203
  for (const r of scanned) {
175
204
  const mod = await import(pathToFileURL(r.file).href);
176
205
  const def = routeFromModule(mod);
177
206
  if (!def) continue;
178
207
  const chain = await componentsFor(r.layouts);
208
+ const loading = await loadingFor(r.loading);
179
209
  if (r.dynamic) {
180
210
  dynamicRoutes.push({ pattern: r.path, def });
181
211
  layoutChains[r.path] = chain;
@@ -183,6 +213,7 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
183
213
  routes[r.path] = def;
184
214
  layoutChains[r.path] = chain;
185
215
  }
216
+ if (loading) loadings[r.path] = loading;
186
217
  }
187
218
 
188
219
  let extra: ExtraHandler | undefined;
@@ -196,6 +227,7 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
196
227
  routes,
197
228
  dynamicRoutes,
198
229
  layoutChains,
230
+ loadings,
199
231
  document: frozen.document,
200
232
  agent: frozen.agent,
201
233
  earlyHints: frozen.earlyHints,
@@ -224,6 +256,66 @@ export async function juneBuild(
224
256
  if (routes.length === 0) throw new Error(`no page.* routes found under ${appDir}`);
225
257
 
226
258
  const frozen = await freezeConfig(appRoot);
259
+ // The deploy adapter packages the portable build for its target (default:
260
+ // built-in workers()). It contributes the entry's export wrapper + emits the
261
+ // deploy config.
262
+ const fullConfig = await loadJuneConfig(appRoot);
263
+ const adapter = (fullConfig.deploy?.adapter as JuneAdapter | undefined) ?? workers();
264
+
265
+ // Fail fast on a config the target can't honor (e.g. Vercel has no D1) BEFORE
266
+ // the expensive bundle/prerender. The adapter only needs to know which
267
+ // resources are declared, so a presence-only plan suffices here.
268
+ adapter.validate?.({
269
+ plan: { db: fullConfig.resources?.db ? { binding: "DB", databaseName: "" } : undefined },
270
+ config: fullConfig,
271
+ });
272
+
273
+ // Compile the global stylesheet ONCE and content-hash it: the built worker and
274
+ // prerendered HTML link `/global.<hash>.css`, served immutable (cache forever,
275
+ // a content change ships a new URL → no revalidation, no stale window). Dev
276
+ // keeps the stable /global.css; only the asset HREF diverges dev↔built, never
277
+ // render semantics. freezeConfig + buildManifest both default styles to the
278
+ // stable URL — override both with the hashed one.
279
+ const cssOut = await processCss(appDir, { minify: true });
280
+ let cssAsset: string | null = null;
281
+ if (cssOut !== null) {
282
+ const hash = createHash("sha256").update(cssOut).digest("hex").slice(0, 8);
283
+ cssAsset = `_june/global.${hash}.css`; // under the reserved /_june/ prefix
284
+ frozen.document.styles = `/${cssAsset}`;
285
+ }
286
+
287
+ // CSS Modules: glob + transform app/**/*.module.css ONCE → the per-file class
288
+ // maps (the bundlers + dev loaders look these up) AND the collected stylesheet,
289
+ // which is content-hashed + emitted + linked just like global.css.
290
+ const { maps: cssModuleMaps, css: rawModuleCss } = await buildModuleCss(appDir, appRoot);
291
+ // Minify for build (dev serves it readable). Scoped class names are untouched,
292
+ // so the hashed sheet still matches the maps the bundlers/loaders hand out.
293
+ const moduleCss = rawModuleCss === null ? null : await minifyCss(rawModuleCss, "modules.css");
294
+ let moduleCssAsset: string | null = null;
295
+ if (moduleCss !== null) {
296
+ const hash = createHash("sha256").update(moduleCss).digest("hex").slice(0, 8);
297
+ moduleCssAsset = `_june/modules.${hash}.css`;
298
+ frozen.document.moduleStyles = `/${moduleCssAsset}`;
299
+ }
300
+
301
+ // Same for the client islands bundle: build + content-hash it NOW (before the
302
+ // entry codegen + prerender) so both freeze the hashed /_june/client.<hash>.js
303
+ // and it can be served immutable. The asset is written here. The client may
304
+ // import .module.css too, so it gets the same module maps.
305
+ const assetsDir = join(outDir, "assets");
306
+ const clientEntry = findClientEntry(appDir);
307
+ let clientAsset: string | null = null;
308
+ if (clientEntry) {
309
+ clientAsset = await bundleClientToFile(clientEntry, appRoot, assetsDir, cssModuleMaps);
310
+ frozen.document.clientScript = `/${clientAsset}`;
311
+ }
312
+
313
+ // Declared resources become two things: a build-time plan (→ platform bindings
314
+ // the adapter emits) and a runtime provider wired into the generated entry.
315
+ // A resource-less app imports no config and emits no bindings, so its output
316
+ // is byte-identical to before — the parity guarantee holds.
317
+ const resourcesCfg = fullConfig.resources;
318
+ const hasResources = !!(resourcesCfg?.db || resourcesCfg?.kv || resourcesCfg?.blob);
227
319
 
228
320
  // ---- generated entry -----------------------------------------------------
229
321
  // Routes are namespace-imported and adapted with routeFromModule, so the
@@ -245,12 +337,24 @@ export async function juneBuild(
245
337
  }
246
338
  return id;
247
339
  };
340
+ const loadingIds = new Map<string, string>();
341
+ const loadingId = (file: string) => {
342
+ let id = loadingIds.get(file);
343
+ if (!id) {
344
+ id = `Ld${loadingIds.size}`;
345
+ loadingIds.set(file, id);
346
+ imports.push(`import ${id} from ${JSON.stringify(importPath(genDir, file))};`);
347
+ }
348
+ return id;
349
+ };
248
350
  const chains: string[] = [];
351
+ const loadings: string[] = [];
249
352
  routes.forEach((r, i) => {
250
353
  imports.push(`import * as r${i} from ${JSON.stringify(importPath(genDir, r.file))};`);
251
354
  if (r.dynamic) dynamics.push(` { pattern: ${JSON.stringify(r.path)}, def: routeFromModule(r${i}) },`);
252
355
  else statics.push(` ${JSON.stringify(r.path)}: routeFromModule(r${i}),`);
253
356
  chains.push(` ${JSON.stringify(r.path)}: [${r.layouts.map(layoutId).join(", ")}],`);
357
+ if (r.loading) loadings.push(` ${JSON.stringify(r.path)}: ${loadingId(r.loading)},`);
254
358
  });
255
359
 
256
360
  const builtExtraFile = findExtraFile(appDir);
@@ -258,14 +362,30 @@ export async function juneBuild(
258
362
  imports.push(`import extra from ${JSON.stringify(importPath(genDir, builtExtraFile))};`);
259
363
  }
260
364
 
261
- // The deployed worker runs first (wrangler assets.run_worker_first) and wraps
262
- // the pipeline with withAssets — so prerendered pages, served from the ASSETS
263
- // binding, still get the Link header + Accept:markdown negotiation the asset
264
- // layer can't do. The Link header is frozen here from the same builder the
265
- // pipeline uses, so the static and dynamic surfaces advertise identically.
365
+ // The Link header is frozen here from the same builder the pipeline uses, so
366
+ // the static and dynamic surfaces advertise identically. The adapter wraps the
367
+ // portable pipeline for its target (workers() → withAssets).
266
368
  const linkHeader = buildLinkHeader(frozen.agent);
369
+ const adapterEntry = adapter.entry({ linkHeader });
370
+
371
+ // Resources (when declared) are bound from the worker's env (env.DB → D1) by
372
+ // an env-aware provider. We bake a pure FLAGS descriptor — never importing the
373
+ // user's config, which would drag the host-only sqlite()/dev server into the
374
+ // workerd bundle. Only emitted when something is declared, so resource-less
375
+ // bundles stay byte-identical (and host code never enters the graph).
376
+ const resourceFlags = {
377
+ db: !!resourcesCfg?.db,
378
+ kv: !!resourcesCfg?.kv,
379
+ blob: !!resourcesCfg?.blob,
380
+ };
381
+ if (hasResources) {
382
+ imports.push(`import { bindWorkerResources } from "@junejs/server/resources";`);
383
+ }
384
+ const resourcesField = hasResources
385
+ ? `\n resources: bindWorkerResources(${JSON.stringify(resourceFlags)}),`
386
+ : "";
267
387
  const entry = `// AUTO-GENERATED by \`june build\` — do not edit. Regenerate: june build .
268
- import { withAssets } from "@junejs/server/worker";
388
+ ${adapterEntry.imports.join("\n")}
269
389
  ${imports.join("\n")}
270
390
 
271
391
  const pipeline = createWorker({
@@ -277,13 +397,16 @@ ${dynamics.join("\n")}
277
397
  ],
278
398
  layoutChains: {
279
399
  ${chains.join("\n")}
400
+ },
401
+ loadings: {
402
+ ${loadings.join("\n")}
280
403
  },
281
404
  document: ${JSON.stringify(frozen.document, null, 2).replace(/\n/g, "\n ")},
282
405
  agent: ${JSON.stringify(frozen.agent)},
283
- earlyHints: ${JSON.stringify(frozen.earlyHints)},${builtExtraFile ? "\n extra," : ""}
406
+ earlyHints: ${JSON.stringify(frozen.earlyHints)},${builtExtraFile ? "\n extra," : ""}${resourcesField}
284
407
  });
285
408
 
286
- export default withAssets(pipeline, { link: ${JSON.stringify(linkHeader)} });
409
+ ${adapterEntry.wrap("pipeline")}
287
410
  `;
288
411
  const entryFile = join(genDir, "worker-entry.tsx");
289
412
  await writeFile(entryFile, entry);
@@ -294,6 +417,7 @@ export default withAssets(pipeline, { link: ${JSON.stringify(linkHeader)} });
294
417
  input: entryFile,
295
418
  cwd: appRoot,
296
419
  platform: "browser", // workerd's surface is web-standard; no node:* in the graph
420
+ plugins: [rolldownCssModulesPlugin(cssModuleMaps)], // .module.css → scoped class map
297
421
  external: (id: string) => {
298
422
  // Binary assets stay external — wrangler's CompiledWasm/Data rules own them.
299
423
  if (/\.(wasm|ttf|otf|woff2?|png|jpe?g|avif|webp)$/.test(id)) return true;
@@ -303,8 +427,9 @@ export default withAssets(pipeline, { link: ${JSON.stringify(linkHeader)} });
303
427
  return list.some((e) => id === e || id.startsWith(`${e}/`));
304
428
  },
305
429
  resolve: {
306
- // Conditions BAKED at build (workerd has no runtime conditions, reminder #3).
307
- conditionNames: ["workerd", "edge", "import", "default"],
430
+ // Conditions BAKED at build (the target has no runtime conditions, reminder
431
+ // #3). Adapter-owned: workers → workerd, vercel → edge-light.
432
+ conditionNames: adapter.conditions,
308
433
  },
309
434
  });
310
435
  const result = await bundle.write({ dir: outDir, format: "esm", entryFileNames: "worker.js" });
@@ -318,9 +443,14 @@ export default withAssets(pipeline, { link: ${JSON.stringify(linkHeader)} });
318
443
  // Same render path as the bundle (createWorker over the frozen manifest), so
319
444
  // what ships is what the parity test verified.
320
445
  const prerendered: string[] = [];
446
+ // Prerender imports route modules in-process, so the runtime CSS-Modules
447
+ // interceptor must be active for any route that imports a .module.css.
448
+ await registerCssModules(cssModuleMaps);
321
449
  const manifest = await buildManifest(appRoot);
450
+ if (cssAsset) manifest.document.styles = `/${cssAsset}`; // prerendered HTML links the hashed sheet
451
+ if (clientAsset) manifest.document.clientScript = `/${clientAsset}`;
452
+ if (moduleCssAsset) manifest.document.moduleStyles = `/${moduleCssAsset}`;
322
453
  const worker = createWorker(manifest);
323
- const assetsDir = join(outDir, "assets");
324
454
  let hasAssets = false;
325
455
 
326
456
  for (const r of routes.filter((x) => !x.dynamic)) {
@@ -347,49 +477,42 @@ export default withAssets(pipeline, { link: ${JSON.stringify(linkHeader)} });
347
477
  hasAssets = true;
348
478
  }
349
479
 
350
- // ---- client islands bundle: app/_client.* → assets/client.js -------------
351
- // Served at /client.js by the assets binding; the frozen document (freezeConfig)
352
- // already points <script src> at it. No entry → no bundle, page ships zero JS.
353
- const clientEntry = findClientEntry(appDir);
354
- if (clientEntry) {
355
- await bundleClientToFile(clientEntry, appRoot, assetsDir);
480
+ // ---- client islands bundle: built + content-hashed earlier (assets/_june/
481
+ // client.<hash>.js, frozen into the document). No entry → page ships zero JS.
482
+ if (clientAsset) hasAssets = true;
483
+
484
+ // ---- global stylesheet: app/global.css → assets/global.css ---------------
485
+ // Served at /global.css; the frozen document already <link>s it. Compiled
486
+ // (Tailwind) or passed through (plain CSS). No file → no asset.
487
+ if (cssOut !== null && cssAsset) {
488
+ const dest = join(assetsDir, cssAsset);
489
+ await mkdir(dirname(dest), { recursive: true }); // cssAsset includes the _june/ subdir
490
+ await writeFile(dest, cssOut);
491
+ hasAssets = true;
492
+ }
493
+
494
+ // ---- collected CSS Modules: app/**/*.module.css → assets/_june/modules.<hash>.css
495
+ if (moduleCss !== null && moduleCssAsset) {
496
+ const dest = join(assetsDir, moduleCssAsset);
497
+ await mkdir(dirname(dest), { recursive: true });
498
+ await writeFile(dest, moduleCss);
356
499
  hasAssets = true;
357
500
  }
358
501
 
359
- // ---- wrangler config (only when the app doesn't manage its own) ----------
360
- if (!existsSync(join(appRoot, "wrangler.toml")) && !existsSync(join(appRoot, "wrangler.jsonc"))) {
361
- const pkgPath = join(appRoot, "package.json");
362
- const pkg = existsSync(pkgPath)
363
- ? (JSON.parse(await Bun.file(pkgPath).text()) as { name?: string })
364
- : {};
365
- const deployCfg = (await loadJuneConfig(appRoot)).deploy;
366
- await writeFile(
367
- join(outDir, "wrangler.jsonc"),
368
- JSON.stringify(
369
- {
370
- name:
371
- deployCfg?.name ??
372
- (pkg.name ?? basename(appRoot)).replace(/[^a-z0-9-]/gi, "-").toLowerCase(),
373
- main: "./worker.js",
374
- compatibility_date: "2025-01-01",
375
- compatibility_flags: ["nodejs_compat"],
376
- // run_worker_first: the worker wraps asset serving (withAssets) so
377
- // prerendered pages still get Link headers + Accept:markdown
378
- // negotiation. The ASSETS binding lets the worker serve the static
379
- // files itself.
380
- ...(hasAssets
381
- ? { assets: { directory: "./assets", binding: "ASSETS", run_worker_first: true } }
382
- : {}),
383
- // config deploy.domain → a Workers custom domain; without it the
384
- // generated file regenerating on every build would silently drop a
385
- // hand-attached domain.
386
- ...(deployCfg?.domain
387
- ? { routes: [{ pattern: deployCfg.domain, custom_domain: true }] }
388
- : {}),
389
- },
390
- null,
391
- 2,
392
- ) + "\n",
502
+ // ---- deploy structure (adapter-owned: wrangler.jsonc for workers) --------
503
+ const pkgPath = join(appRoot, "package.json");
504
+ const pkgName = existsSync(pkgPath)
505
+ ? (JSON.parse(await Bun.file(pkgPath).text()) as { name?: string }).name
506
+ : undefined;
507
+ const defaultName = (pkgName ?? basename(appRoot)).replace(/[^a-z0-9-]/gi, "-").toLowerCase();
508
+ // A declared `db` → a D1 binding named DB (the name bindWorkerResources reads).
509
+ const plan: ResourcePlan = resourcesCfg?.db
510
+ ? { db: { binding: "DB", databaseName: `${defaultName}-db` } }
511
+ : {};
512
+ await adapter.emit({ appRoot, outDir, hasAssets, linkHeader, config: fullConfig, plan, defaultName });
513
+ if (plan.db) {
514
+ console.log(
515
+ ` ↳ d1 binding "${plan.db.binding}" emitted — set database_id in wrangler.jsonc (wrangler d1 create ${plan.db.databaseName})`,
393
516
  );
394
517
  }
395
518
 
@@ -18,13 +18,16 @@
18
18
  // Host-coupled (node:fs + Rolldown), so it lives in @junejs/server, never the pure
19
19
  // contract layer.
20
20
 
21
+ import { createHash } from "node:crypto";
21
22
  import { existsSync } from "node:fs";
22
- import { mkdir } from "node:fs/promises";
23
- import { join } from "node:path";
23
+ import { mkdir, writeFile } from "node:fs/promises";
24
+ import { dirname, join } from "node:path";
25
+
26
+ import { rolldownCssModulesPlugin, type ModuleMaps } from "./css-modules";
24
27
 
25
28
  // The URL the document loads + the asset path the bundle is written to. Single
26
29
  // source of truth so build (freeze) and dev (live) agree.
27
- export const CLIENT_SCRIPT_URL = "/client.js";
30
+ export const CLIENT_SCRIPT_URL = "/_june/client.js";
28
31
  const CLIENT_BASENAME = "_client";
29
32
  const CLIENT_EXTS = [".tsx", ".ts", ".jsx", ".js"];
30
33
 
@@ -39,7 +42,7 @@ export function findClientEntry(appDir: string): string | undefined {
39
42
 
40
43
  type BundleMode = "development" | "production";
41
44
 
42
- async function bundleClient(entryFile: string, cwd: string, mode: BundleMode) {
45
+ async function bundleClient(entryFile: string, cwd: string, mode: BundleMode, maps: ModuleMaps = {}) {
43
46
  const { rolldown } = await import("rolldown");
44
47
  const bundle = await rolldown({
45
48
  input: entryFile,
@@ -47,6 +50,7 @@ async function bundleClient(entryFile: string, cwd: string, mode: BundleMode) {
47
50
  // The client graph is plain web — browser conditions, and React's dev/prod
48
51
  // branch resolved at build (no `process` in the browser to read it at runtime).
49
52
  platform: "browser",
53
+ plugins: [rolldownCssModulesPlugin(maps)], // islands may import .module.css
50
54
  transform: { define: { "process.env.NODE_ENV": JSON.stringify(mode) } },
51
55
  resolve: { conditionNames: ["browser", "import", "default"] },
52
56
  });
@@ -54,18 +58,27 @@ async function bundleClient(entryFile: string, cwd: string, mode: BundleMode) {
54
58
  }
55
59
 
56
60
  // Build the client entry to a single `client.js` string (dev serves this live).
57
- export async function bundleClientToString(entryFile: string, cwd: string): Promise<string> {
58
- const bundle = await bundleClient(entryFile, cwd, "development");
61
+ export async function bundleClientToString(entryFile: string, cwd: string, maps: ModuleMaps = {}): Promise<string> {
62
+ const bundle = await bundleClient(entryFile, cwd, "development", maps);
59
63
  const { output } = await bundle.generate({ format: "esm", entryFileNames: "client.js" });
60
64
  await bundle.close();
61
65
  const entry = output.find((o) => o.type === "chunk" && o.isEntry);
62
66
  return entry && entry.type === "chunk" ? entry.code : "";
63
67
  }
64
68
 
65
- // Build the client entry to `<destDir>/client.js` (build freezes it as an asset).
66
- export async function bundleClientToFile(entryFile: string, cwd: string, destDir: string): Promise<void> {
67
- const bundle = await bundleClient(entryFile, cwd, "production");
68
- await mkdir(destDir, { recursive: true });
69
- await bundle.write({ dir: destDir, format: "esm", entryFileNames: "client.js" });
69
+ // Build the client entry, content-hash it, and write `<destDir>/_june/client.<hash>.js`
70
+ // (immutable-cacheable, like the hashed CSS). Returns the asset filename
71
+ // (`_june/client.<hash>.js`) so the build can freeze its URL into the document.
72
+ export async function bundleClientToFile(entryFile: string, cwd: string, destDir: string, maps: ModuleMaps = {}): Promise<string> {
73
+ const bundle = await bundleClient(entryFile, cwd, "production", maps);
74
+ const { output } = await bundle.generate({ format: "esm", entryFileNames: "client.js" });
70
75
  await bundle.close();
76
+ const entry = output.find((o) => o.type === "chunk" && o.isEntry);
77
+ const code = entry && entry.type === "chunk" ? entry.code : "";
78
+ const hash = createHash("sha256").update(code).digest("hex").slice(0, 8);
79
+ const fileName = `_june/client.${hash}.js`;
80
+ const dest = join(destDir, fileName);
81
+ await mkdir(dirname(dest), { recursive: true });
82
+ await writeFile(dest, code);
83
+ return fileName;
71
84
  }
@@ -0,0 +1,9 @@
1
+ // Ambient types for CSS Modules: `import styles from "./X.module.css"` resolves
2
+ // to a map of local class name → scoped name. June ships this so app code (and
3
+ // these tests/fixtures) typecheck; the create-june template references an
4
+ // equivalent june-env.d.ts. (No top-level import/export → this is a GLOBAL
5
+ // declaration, so the ambient module applies everywhere.)
6
+ declare module "*.module.css" {
7
+ const classes: Record<string, string>;
8
+ export default classes;
9
+ }
@@ -0,0 +1,7 @@
1
+ // Types for the Node ESM CSS-Modules loader (a runtime hook, .mjs).
2
+ export function initialize(data?: { maps?: Record<string, Record<string, string>> }): void;
3
+ export function load(
4
+ url: string,
5
+ context: unknown,
6
+ next: (url: string, context: unknown) => unknown,
7
+ ): Promise<{ format: string; source: string; shortCircuit: boolean } | unknown>;
@@ -0,0 +1,20 @@
1
+ // Node ESM loader for CSS Modules (dev). `module.register` runs this in its own
2
+ // context, so it must be a plain, self-contained .mjs — it does NOT transform;
3
+ // it just looks up the precomputed class map (handed in via init data) and
4
+ // returns it as a JS module. The maps come from css-modules.ts's single glob
5
+ // pass, so the served CSS and these maps agree by construction.
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ let MAPS = {};
9
+
10
+ export function initialize(data) {
11
+ MAPS = (data && data.maps) || {};
12
+ }
13
+
14
+ export async function load(url, context, next) {
15
+ if (url.endsWith(".module.css")) {
16
+ const map = MAPS[fileURLToPath(url)] || {};
17
+ return { format: "module", source: "export default " + JSON.stringify(map) + ";", shortCircuit: true };
18
+ }
19
+ return next(url, context);
20
+ }