@junejs/server 0.0.8 → 0.0.10

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";
@@ -24,13 +25,17 @@ import { pathToFileURL } from "node:url";
24
25
 
25
26
  import { loadJuneConfig } from "./config-loader";
26
27
  import { resolveAgent, resolveSpeculationRules } from "@junejs/core/config";
27
- import { isRouteDefinition, type BrandedRoute } from "@junejs/core/route";
28
+ import { buildLinkHeader } from "@junejs/core/discovery";
29
+ import { routeFromModule, type BrandedRoute } from "@junejs/core/route";
30
+ import { workers, type JuneAdapter, type ResourcePlan } from "./adapter";
28
31
  import type { DocumentConfig } from "@junejs/core/document";
29
32
  import { collection } from "./content";
30
33
  import { createWorker, type WorkerManifest } from "./worker";
31
34
  import { findExtraFile } from "./router";
32
- import type { ExtraHandler, LayoutComponent } from "./pipeline";
35
+ import type { ExtraHandler, LayoutComponent, LoadingComponent } from "./pipeline";
33
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";
34
39
 
35
40
  export type BuildResult = {
36
41
  outFile: string;
@@ -42,7 +47,13 @@ export type BuildResult = {
42
47
 
43
48
  // The segment layout CHAIN root→leaf: every directory level (route groups
44
49
  // included) may contribute a layout.* that wraps routes below it.
45
- 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
+ };
46
57
 
47
58
  const PAGE_BASENAMES = new Set(["page", "index"]);
48
59
  const ROUTE_EXTS = [".tsx", ".jsx", ".ts", ".js"];
@@ -61,15 +72,17 @@ export async function scanRoutes(
61
72
  dir = appDir,
62
73
  layouts: string[] = [],
63
74
  out: RouteEntry[] = [],
75
+ loading?: string,
64
76
  ): Promise<RouteEntry[]> {
65
77
  const ownLayout = segmentFile(dir, "layout");
66
78
  const chain = ownLayout ? [...layouts, ownLayout] : layouts;
79
+ const nearestLoading = segmentFile(dir, "loading") ?? loading;
67
80
  const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
68
81
  for (const e of entries) {
69
82
  if (e.name.startsWith("_") || e.name.startsWith(".")) continue;
70
83
  const full = join(dir, e.name);
71
84
  if (e.isDirectory()) {
72
- await scanRoutes(appDir, full, chain, out);
85
+ await scanRoutes(appDir, full, chain, out, nearestLoading);
73
86
  continue;
74
87
  }
75
88
  const ext = e.name.match(/\.[^.]+$/)?.[0] ?? "";
@@ -78,7 +91,13 @@ export async function scanRoutes(
78
91
  const relDir = relative(appDir, dir);
79
92
  const segments = relDir === "" ? [] : relDir.split(sep).filter((s) => !isRouteGroup(s));
80
93
  const path = "/" + segments.join("/");
81
- 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
+ });
82
101
  }
83
102
  return out;
84
103
  }
@@ -128,13 +147,16 @@ export async function freezeConfig(appRoot: string): Promise<{
128
147
  // which re-freezes through buildManifest — sets the SAME clientScript, keeping
129
148
  // prerendered pages byte-equivalent to the live worker (parity).
130
149
  const hasClient = findClientEntry(join(appRoot, "app")) !== undefined;
150
+ const hasCss = findGlobalCss(join(appRoot, "app")) !== null;
131
151
  return {
132
152
  document: {
133
153
  site: cfg.site ?? {},
134
154
  speculationRules: resolveSpeculationRules(cfg.speculation ?? undefined),
135
155
  speculationDelivery: "inline",
136
156
  viewTransitions: cfg.viewTransitions ?? true,
157
+ clientRouter: cfg.clientRouter ?? false,
137
158
  clientScript: hasClient ? CLIENT_SCRIPT_URL : null,
159
+ styles: hasCss ? STYLES_URL : null,
138
160
  },
139
161
  agent: resolveAgent(cfg.agent),
140
162
  earlyHints: cfg.earlyHints ?? [],
@@ -166,21 +188,32 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
166
188
  return chain;
167
189
  };
168
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
+
169
198
  const routes: Record<string, BrandedRoute> = {};
170
199
  const dynamicRoutes: Array<{ pattern: string; def: BrandedRoute }> = [];
171
200
  const layoutChains: Record<string, LayoutComponent[]> = {};
201
+ const loadings: Record<string, LoadingComponent> = {};
172
202
 
173
203
  for (const r of scanned) {
174
- const mod = (await import(pathToFileURL(r.file).href)) as { default?: unknown };
175
- if (!isRouteDefinition(mod.default)) continue;
204
+ const mod = await import(pathToFileURL(r.file).href);
205
+ const def = routeFromModule(mod);
206
+ if (!def) continue;
176
207
  const chain = await componentsFor(r.layouts);
208
+ const loading = await loadingFor(r.loading);
177
209
  if (r.dynamic) {
178
- dynamicRoutes.push({ pattern: r.path, def: mod.default });
210
+ dynamicRoutes.push({ pattern: r.path, def });
179
211
  layoutChains[r.path] = chain;
180
212
  } else {
181
- routes[r.path] = mod.default;
213
+ routes[r.path] = def;
182
214
  layoutChains[r.path] = chain;
183
215
  }
216
+ if (loading) loadings[r.path] = loading;
184
217
  }
185
218
 
186
219
  let extra: ExtraHandler | undefined;
@@ -194,6 +227,7 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
194
227
  routes,
195
228
  dynamicRoutes,
196
229
  layoutChains,
230
+ loadings,
197
231
  document: frozen.document,
198
232
  agent: frozen.agent,
199
233
  earlyHints: frozen.earlyHints,
@@ -222,9 +256,75 @@ export async function juneBuild(
222
256
  if (routes.length === 0) throw new Error(`no page.* routes found under ${appDir}`);
223
257
 
224
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);
225
319
 
226
320
  // ---- generated entry -----------------------------------------------------
227
- const imports: string[] = [`import { createWorker } from "@junejs/server/worker";`];
321
+ // Routes are namespace-imported and adapted with routeFromModule, so the
322
+ // multi-export page shape (default view + named loader/json/md) and the legacy
323
+ // route({}) default export both work.
324
+ const imports: string[] = [
325
+ `import { createWorker } from "@junejs/server/worker";`,
326
+ `import { routeFromModule } from "@junejs/core/route";`,
327
+ ];
228
328
  const statics: string[] = [];
229
329
  const dynamics: string[] = [];
230
330
  const layoutIds = new Map<string, string>();
@@ -237,12 +337,24 @@ export async function juneBuild(
237
337
  }
238
338
  return id;
239
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
+ };
240
350
  const chains: string[] = [];
351
+ const loadings: string[] = [];
241
352
  routes.forEach((r, i) => {
242
- imports.push(`import r${i} from ${JSON.stringify(importPath(genDir, r.file))};`);
243
- if (r.dynamic) dynamics.push(` { pattern: ${JSON.stringify(r.path)}, def: r${i} },`);
244
- else statics.push(` ${JSON.stringify(r.path)}: r${i},`);
353
+ imports.push(`import * as r${i} from ${JSON.stringify(importPath(genDir, r.file))};`);
354
+ if (r.dynamic) dynamics.push(` { pattern: ${JSON.stringify(r.path)}, def: routeFromModule(r${i}) },`);
355
+ else statics.push(` ${JSON.stringify(r.path)}: routeFromModule(r${i}),`);
245
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)},`);
246
358
  });
247
359
 
248
360
  const builtExtraFile = findExtraFile(appDir);
@@ -250,10 +362,33 @@ export async function juneBuild(
250
362
  imports.push(`import extra from ${JSON.stringify(importPath(genDir, builtExtraFile))};`);
251
363
  }
252
364
 
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).
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
+ : "";
253
387
  const entry = `// AUTO-GENERATED by \`june build\` — do not edit. Regenerate: june build .
388
+ ${adapterEntry.imports.join("\n")}
254
389
  ${imports.join("\n")}
255
390
 
256
- export default createWorker({
391
+ const pipeline = createWorker({
257
392
  routes: {
258
393
  ${statics.join("\n")}
259
394
  },
@@ -262,11 +397,16 @@ ${dynamics.join("\n")}
262
397
  ],
263
398
  layoutChains: {
264
399
  ${chains.join("\n")}
400
+ },
401
+ loadings: {
402
+ ${loadings.join("\n")}
265
403
  },
266
404
  document: ${JSON.stringify(frozen.document, null, 2).replace(/\n/g, "\n ")},
267
405
  agent: ${JSON.stringify(frozen.agent)},
268
- earlyHints: ${JSON.stringify(frozen.earlyHints)},${builtExtraFile ? "\n extra," : ""}
406
+ earlyHints: ${JSON.stringify(frozen.earlyHints)},${builtExtraFile ? "\n extra," : ""}${resourcesField}
269
407
  });
408
+
409
+ ${adapterEntry.wrap("pipeline")}
270
410
  `;
271
411
  const entryFile = join(genDir, "worker-entry.tsx");
272
412
  await writeFile(entryFile, entry);
@@ -277,6 +417,7 @@ ${chains.join("\n")}
277
417
  input: entryFile,
278
418
  cwd: appRoot,
279
419
  platform: "browser", // workerd's surface is web-standard; no node:* in the graph
420
+ plugins: [rolldownCssModulesPlugin(cssModuleMaps)], // .module.css → scoped class map
280
421
  external: (id: string) => {
281
422
  // Binary assets stay external — wrangler's CompiledWasm/Data rules own them.
282
423
  if (/\.(wasm|ttf|otf|woff2?|png|jpe?g|avif|webp)$/.test(id)) return true;
@@ -286,8 +427,9 @@ ${chains.join("\n")}
286
427
  return list.some((e) => id === e || id.startsWith(`${e}/`));
287
428
  },
288
429
  resolve: {
289
- // Conditions BAKED at build (workerd has no runtime conditions, reminder #3).
290
- 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,
291
433
  },
292
434
  });
293
435
  const result = await bundle.write({ dir: outDir, format: "esm", entryFileNames: "worker.js" });
@@ -301,20 +443,29 @@ ${chains.join("\n")}
301
443
  // Same render path as the bundle (createWorker over the frozen manifest), so
302
444
  // what ships is what the parity test verified.
303
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);
304
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}`;
305
453
  const worker = createWorker(manifest);
306
- const assetsDir = join(outDir, "assets");
307
454
  let hasAssets = false;
308
455
 
309
456
  for (const r of routes.filter((x) => !x.dynamic)) {
310
457
  const def = manifest.routes[r.path];
311
458
  if (!def || !def.prerender) continue;
312
459
  const stem = r.path === "/" ? "index" : r.path.slice(1);
313
- const targets: Array<[string, string]> = [[r.path, `${stem}.html`]];
314
- if (r.path !== "/") {
315
- targets.push([`${r.path}.md`, `${stem}.md`]);
316
- if (typeof def.json === "function") targets.push([`${r.path}.json`, `${stem}.json`]);
317
- }
460
+ // The homepage's projection requests are `/.md` / `/.json` (negotiate strips
461
+ // the ext back to `/`); withAssets serves them for `Accept: text/markdown`.
462
+ const mdReq = r.path === "/" ? "/.md" : `${r.path}.md`;
463
+ const jsonReq = r.path === "/" ? "/.json" : `${r.path}.json`;
464
+ const targets: Array<[string, string]> = [
465
+ [r.path, `${stem}.html`],
466
+ [mdReq, `${stem}.md`],
467
+ ];
468
+ if (typeof def.json === "function") targets.push([jsonReq, `${stem}.json`]);
318
469
  for (const [reqPath, file] of targets) {
319
470
  const res = await worker.fetch(new Request(`https://prerender.june${reqPath}`));
320
471
  if (!res.ok) throw new Error(`prerender ${reqPath} → ${res.status}`);
@@ -326,43 +477,42 @@ ${chains.join("\n")}
326
477
  hasAssets = true;
327
478
  }
328
479
 
329
- // ---- client islands bundle: app/_client.* → assets/client.js -------------
330
- // Served at /client.js by the assets binding; the frozen document (freezeConfig)
331
- // already points <script src> at it. No entry → no bundle, page ships zero JS.
332
- const clientEntry = findClientEntry(appDir);
333
- if (clientEntry) {
334
- 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);
335
499
  hasAssets = true;
336
500
  }
337
501
 
338
- // ---- wrangler config (only when the app doesn't manage its own) ----------
339
- if (!existsSync(join(appRoot, "wrangler.toml")) && !existsSync(join(appRoot, "wrangler.jsonc"))) {
340
- const pkgPath = join(appRoot, "package.json");
341
- const pkg = existsSync(pkgPath)
342
- ? (JSON.parse(await Bun.file(pkgPath).text()) as { name?: string })
343
- : {};
344
- const deployCfg = (await loadJuneConfig(appRoot)).deploy;
345
- await writeFile(
346
- join(outDir, "wrangler.jsonc"),
347
- JSON.stringify(
348
- {
349
- name:
350
- deployCfg?.name ??
351
- (pkg.name ?? basename(appRoot)).replace(/[^a-z0-9-]/gi, "-").toLowerCase(),
352
- main: "./worker.js",
353
- compatibility_date: "2025-01-01",
354
- compatibility_flags: ["nodejs_compat"],
355
- ...(hasAssets ? { assets: { directory: "./assets" } } : {}),
356
- // config deploy.domain → a Workers custom domain; without it the
357
- // generated file regenerating on every build would silently drop a
358
- // hand-attached domain.
359
- ...(deployCfg?.domain
360
- ? { routes: [{ pattern: deployCfg.domain, custom_domain: true }] }
361
- : {}),
362
- },
363
- null,
364
- 2,
365
- ) + "\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})`,
366
516
  );
367
517
  }
368
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
+ }