@junejs/server 0.0.28 → 0.0.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junejs/server",
3
- "version": "0.0.28",
3
+ "version": "0.0.31",
4
4
  "description": "June host adapters + dev server. Host-coupled; depends on the pure @junejs/core contract layer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,20 +30,24 @@
30
30
  "typecheck": "tsc --noEmit"
31
31
  },
32
32
  "dependencies": {
33
- "@junejs/core": "0.0.28",
33
+ "@junejs/core": "0.0.32",
34
34
  "@junejs/db": "0.0.28",
35
+ "lightningcss": "^1.27.0",
35
36
  "marked": "^18.0.5",
36
- "rolldown": "^1.1.0",
37
- "lightningcss": "^1.27.0"
37
+ "rolldown": "^1.1.0"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "react": "^19.2.0",
41
41
  "react-dom": "^19.2.0",
42
+ "react-server-dom-webpack": "19.2.7",
42
43
  "pg": "^8.0.0",
43
44
  "mysql2": "^3.0.0",
44
45
  "@libsql/client": "^0.14.0 || ^0.15.0 || ^0.16.0 || ^0.17.0"
45
46
  },
46
47
  "peerDependenciesMeta": {
48
+ "react-server-dom-webpack": {
49
+ "optional": true
50
+ },
47
51
  "pg": {
48
52
  "optional": true
49
53
  },
@@ -62,6 +66,7 @@
62
66
  "@libsql/client": "^0.17.3",
63
67
  "@types/pg": "^8.20.0",
64
68
  "mysql2": "^3.22.5",
65
- "pg": "^8.21.0"
69
+ "pg": "^8.21.0",
70
+ "react-server-dom-webpack": "19.2.7"
66
71
  }
67
72
  }
package/src/app.ts CHANGED
@@ -14,7 +14,12 @@ import { pathToFileURL } from "node:url";
14
14
  import type { ComponentType } from "react";
15
15
 
16
16
  import { routeFromModule } from "@junejs/core/route";
17
- import { resolveAgent, resolveSpeculationRules, type JuneConfig } from "@junejs/core/config";
17
+ import {
18
+ resolveAgent,
19
+ resolveClientRouter,
20
+ resolveSpeculationRules,
21
+ type JuneConfig,
22
+ } from "@junejs/core/config";
18
23
  import type { DocumentConfig } from "@junejs/core/document";
19
24
  import { runWithTrace, type RequestTrace } from "@junejs/core/instrumentation";
20
25
 
@@ -22,8 +27,14 @@ import { findMiddlewareFile, isResourceFile, listRoutes, matchRouteTree, resolve
22
27
  import { createPipeline, type ExtraHandler, type LayoutComponent, type Pipeline, type Resolved, type ResourceHandler } from "./pipeline";
23
28
  import { resolveBoundary } from "./segment";
24
29
  import { memoizeResources } from "./resources";
25
- import { findClientEntry, bundleClientToString, CLIENT_SCRIPT_URL } from "./client-bundle";
26
- import { findGlobalCss, processCssCached, STYLES_URL } from "./css";
30
+ import {
31
+ findClientEntry,
32
+ bundleClientSplit,
33
+ CLIENT_SCRIPT_URL,
34
+ type ClientChunks,
35
+ } from "./client-bundle";
36
+ import { generateIslandRegistry } from "./island-registry";
37
+ import { findGlobalCss, globalCssUsesTailwind, processCssCached, STYLES_URL } from "./css";
27
38
  import { buildModuleCss, registerCssModules, MODULE_STYLES_URL, type ModuleMaps } from "./css-modules";
28
39
 
29
40
  export type CreateAppOptions = {
@@ -107,7 +118,9 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
107
118
  speculationRules: resolveSpeculationRules(speculation ?? undefined),
108
119
  speculationDelivery: speculation ? speculation.delivery ?? "inline" : "inline",
109
120
  viewTransitions: config.viewTransitions ?? true,
110
- clientRouter: config.clientRouter ?? false,
121
+ // Default the baseline reset OFF when the app uses Tailwind (its Preflight is the reset).
122
+ cssReset: config.cssReset ?? !globalCssUsesTailwind(appDir),
123
+ clientRouter: resolveClientRouter(config.clientRouter),
111
124
  clientScript: clientEntry ? CLIENT_SCRIPT_URL : null,
112
125
  styles: cssEntry ? STYLES_URL : null,
113
126
  };
@@ -117,13 +130,17 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
117
130
  let moduleCssPromise: Promise<{ maps: ModuleMaps; css: string | null }> | undefined;
118
131
  const getModuleCss = () => (moduleCssPromise ??= buildModuleCss(appDir, dirname(appDir)));
119
132
 
120
- let clientBundle: Promise<string> | undefined;
121
- const serveClient = (): Promise<string> =>
122
- // cwd = the app ROOT (appDir's parent) so rolldown resolves node_modules
123
- // from the project, exactly like the build does. Islands may import .module.css.
124
- (clientBundle ??= getModuleCss().then(({ maps }) =>
125
- bundleClientToString(clientEntry!, dirname(appDir), maps),
126
- ));
133
+ // Split build: one chunk per island + a shared React chunk, all keyed by file
134
+ // name. The entry is "client.js"; island chunks load on demand. cwd = the app
135
+ // ROOT so rolldown resolves node_modules from the project, like the build does.
136
+ let clientBundle: Promise<ClientChunks> | undefined;
137
+ const serveClient = (): Promise<ClientChunks> =>
138
+ (clientBundle ??= getModuleCss().then(({ maps }) => {
139
+ // Regenerate app/_islands.gen.ts (the auto lazy registry) right before
140
+ // bundling, so a freshly added island() module is wired without a restart.
141
+ generateIslandRegistry(appDir);
142
+ return bundleClientSplit(clientEntry!, dirname(appDir), "development", maps);
143
+ }));
127
144
 
128
145
  const routePaths = () => listRoutes(appDir, { pageConvention: true });
129
146
 
@@ -226,15 +243,27 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
226
243
  new Response(css ?? "", { headers: { "content-type": "text/css; charset=utf-8" } }),
227
244
  );
228
245
  }
229
- // Dev serves the islands runtime itself (build ships it as a static
230
- // asset); bundled on first hit, memoized after.
231
- if (clientEntry && new URL(request.url).pathname === CLIENT_SCRIPT_URL) {
232
- return serveClient().then(
233
- (code) =>
234
- new Response(code, {
235
- headers: { "content-type": "text/javascript; charset=utf-8" },
236
- }),
237
- (err: unknown) => {
246
+ // Dev serves the islands runtime itself (build ships it as static assets);
247
+ // bundled on first hit, memoized after. Split build → many chunks under
248
+ // /_june/: the entry is /_june/client.js, islands are /_june/<name>.js,
249
+ // loaded on demand by the lazy runtime.
250
+ {
251
+ const pathname = new URL(request.url).pathname;
252
+ if (clientEntry && pathname.startsWith("/_june/") && pathname.endsWith(".js")) {
253
+ const file = pathname.slice("/_june/".length);
254
+ return serveClient().then(
255
+ (chunks) => {
256
+ const code = chunks.get(file);
257
+ if (code === undefined)
258
+ return new Response(`unknown client chunk: ${file}\n`, {
259
+ status: 404,
260
+ headers: { "content-type": "text/plain; charset=utf-8" },
261
+ });
262
+ return new Response(code, {
263
+ headers: { "content-type": "text/javascript; charset=utf-8" },
264
+ });
265
+ },
266
+ (err: unknown) => {
238
267
  // A rejected bundle must answer, not hang the request (Bun.serve
239
268
  // times the handler out) — and must not be memoized, or the error
240
269
  // outlives its cause.
@@ -244,8 +273,9 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
244
273
  status: 500,
245
274
  headers: { "content-type": "text/plain; charset=utf-8" },
246
275
  });
247
- },
248
- );
276
+ },
277
+ );
278
+ }
249
279
  }
250
280
  return runWithTrace(newTrace(), async () => (await getPipeline()).fetch(request));
251
281
  },
package/src/build.ts CHANGED
@@ -24,7 +24,7 @@ import { basename, dirname, join, relative, sep } from "node:path";
24
24
  import { pathToFileURL } from "node:url";
25
25
 
26
26
  import { loadJuneConfig } from "./config-loader";
27
- import { resolveAgent, resolveSpeculationRules } from "@junejs/core/config";
27
+ import { resolveAgent, resolveClientRouter, resolveSpeculationRules } from "@junejs/core/config";
28
28
  import { buildLinkHeader } from "@junejs/core/discovery";
29
29
  import { routeFromModule, type BrandedRoute } from "@junejs/core/route";
30
30
  import { workers, type JuneAdapter, type ResourcePlan } from "./adapter";
@@ -35,7 +35,9 @@ import { findMiddlewareFile } from "./router";
35
35
  import { resolveBoundary } from "./segment";
36
36
  import type { ExtraHandler, LayoutComponent, LoadingComponent, ResourceHandler } from "./pipeline";
37
37
  import { findClientEntry, bundleClientToFile, CLIENT_SCRIPT_URL } from "./client-bundle";
38
- import { cssTargets, findGlobalCss, minifyCss, processCss, STYLES_URL } from "./css";
38
+ import { generateIslandRegistry } from "./island-registry";
39
+ import { buildRsc, findRscRoutes } from "./rsc-build";
40
+ import { cssTargets, findGlobalCss, globalCssUsesTailwind, minifyCss, processCss, STYLES_URL } from "./css";
39
41
  import { buildModuleCss, rolldownCssModulesPlugin, registerCssModules } from "./css-modules";
40
42
 
41
43
  export type BuildResult = {
@@ -62,6 +64,12 @@ const ROUTE_EXTS = [".tsx", ".jsx", ".ts", ".js"];
62
64
 
63
65
  const isRouteGroup = (name: string) => /^\(.+\)$/.test(name);
64
66
 
67
+ // Bun built-ins (`bun`, `bun:sqlite`, …) exist only at the Bun runtime and must never enter the
68
+ // workerd graph. Marking them external keeps rolldown from constant-folding the `const x = "bun";
69
+ // import(x)` runtime guard (in @junejs/core's cache.ts) and warning UNRESOLVED_IMPORT. Exported so
70
+ // the build keeps externalizing them — see test/build-externals.test.ts.
71
+ export const isBunSpecifier = (id: string): boolean => id === "bun" || id.startsWith("bun:");
72
+
65
73
  function segmentFile(dir: string, base: string): string | undefined {
66
74
  return ROUTE_EXTS.map((e) => join(dir, `${base}${e}`)).find(existsSync);
67
75
  }
@@ -142,7 +150,9 @@ export async function freezeConfig(appRoot: string): Promise<{
142
150
  speculationRules: resolveSpeculationRules(cfg.speculation ?? undefined),
143
151
  speculationDelivery: "inline",
144
152
  viewTransitions: cfg.viewTransitions ?? true,
145
- clientRouter: cfg.clientRouter ?? false,
153
+ // Default the baseline reset OFF when the app uses Tailwind (its Preflight is the reset).
154
+ cssReset: cfg.cssReset ?? !globalCssUsesTailwind(join(appRoot, "app")),
155
+ clientRouter: resolveClientRouter(cfg.clientRouter),
146
156
  clientScript: hasClient ? CLIENT_SCRIPT_URL : null,
147
157
  styles: hasCss ? STYLES_URL : null,
148
158
  },
@@ -337,10 +347,19 @@ export async function juneBuild(
337
347
  const clientEntry = findClientEntry(appDir);
338
348
  let clientAsset: string | null = null;
339
349
  if (clientEntry) {
350
+ // Regenerate the auto lazy island registry before bundling (same as dev).
351
+ generateIslandRegistry(appDir);
340
352
  clientAsset = await bundleClientToFile(clientEntry, appRoot, assetsDir, cssModuleMaps);
341
353
  frozen.document.clientScript = `/${clientAsset}`;
342
354
  }
343
355
 
356
+ // Opt-in PER-ROUTE RSC build (page.rsc.tsx routes): emit the server + SSR-worker
357
+ // graphs under <outDir>/rsc/. Gated on RSC routes existing, so apps without any
358
+ // are byte-identical to before. Coexists with the SSR pipeline via a dispatcher.
359
+ if (findRscRoutes(appDir).length > 0) {
360
+ await buildRsc(appRoot, outDir, frozen.document);
361
+ }
362
+
344
363
  // Declared resources become two things: a build-time plan (→ platform bindings
345
364
  // the adapter emits) and a runtime provider wired into the generated entry.
346
365
  // A resource-less app imports no config and emits no bindings, so its output
@@ -516,10 +535,8 @@ ${adapterEntry.wrap("pipeline")}
516
535
  transform: { define: { "process.env.NODE_ENV": JSON.stringify("production") } },
517
536
  plugins: [rolldownCssModulesPlugin(cssModuleMaps)], // .module.css → scoped class map
518
537
  external: (id: string) => {
519
- // Bun built-ins (bun, bun:sqlite, …) exist only at Bun runtime, never in the workerd graph.
520
- // Declaring them external silences rolldown's UNRESOLVED_IMPORT warning (it constant-folds the
521
- // `const x = "bun"; import(x)` guard and tries to resolve "bun" before falling back to external).
522
- if (id === "bun" || id.startsWith("bun:")) return true;
538
+ // Bun built-ins exist only at Bun runtime, never in the workerd graph (see isBunSpecifier).
539
+ if (isBunSpecifier(id)) return true;
523
540
  // Binary assets stay external — wrangler's CompiledWasm/Data rules own them.
524
541
  if (/\.(wasm|ttf|otf|woff2?|png|jpe?g|avif|webp)$/.test(id)) return true;
525
542
  // config build.external: packages wrangler must bundle itself (its own
@@ -18,7 +18,6 @@
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";
22
21
  import { existsSync } from "node:fs";
23
22
  import { mkdir, writeFile } from "node:fs/promises";
24
23
  import { dirname, join } from "node:path";
@@ -57,6 +56,33 @@ async function bundleClient(entryFile: string, cwd: string, mode: BundleMode, ma
57
56
  return bundle;
58
57
  }
59
58
 
59
+ // PoC: split build. With a lazy registry (`{ name: () => import("./Island") }`)
60
+ // the entry's dynamic imports make rolldown emit ONE chunk per island plus a
61
+ // shared chunk for React — so a page downloads only the islands it actually
62
+ // renders, not the whole app's union. Returns every chunk keyed by file name
63
+ // (the entry is "client.js"); the dev server serves each under /_june/<name>.
64
+ export type ClientChunks = Map<string, string>;
65
+
66
+ export async function bundleClientSplit(
67
+ entryFile: string,
68
+ cwd: string,
69
+ mode: BundleMode,
70
+ maps: ModuleMaps = {},
71
+ ): Promise<ClientChunks> {
72
+ const bundle = await bundleClient(entryFile, cwd, mode, maps);
73
+ const { output } = await bundle.generate({
74
+ format: "esm",
75
+ entryFileNames: "client.js",
76
+ // [name] (no hash) in dev so the relative `import("./Counter.js")` baked into
77
+ // the entry resolves to a path the dev server can serve by name.
78
+ chunkFileNames: "[name].js",
79
+ });
80
+ await bundle.close();
81
+ const chunks: ClientChunks = new Map();
82
+ for (const o of output) if (o.type === "chunk") chunks.set(o.fileName, o.code);
83
+ return chunks;
84
+ }
85
+
60
86
  // Build the client entry to a single `client.js` string (dev serves this live).
61
87
  export async function bundleClientToString(entryFile: string, cwd: string, maps: ModuleMaps = {}): Promise<string> {
62
88
  const bundle = await bundleClient(entryFile, cwd, "development", maps);
@@ -66,19 +92,39 @@ export async function bundleClientToString(entryFile: string, cwd: string, maps:
66
92
  return entry && entry.type === "chunk" ? entry.code : "";
67
93
  }
68
94
 
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.
95
+ // Build the client entry and write EVERY emitted chunk/asset under
96
+ // `<destDir>/_june/`, each content-hashed by rolldown (immutable-cacheable). With
97
+ // a lazy registry (`{ name: () => import("./Island") }`) rolldown code-splits one
98
+ // chunk per island + a shared React chunk, and the entry's relative `import()`s
99
+ // already point at the hashed siblings — so they must ALL ship, not just the
100
+ // entry (writing only the entry leaves those imports 404ing in production).
101
+ // Returns the ENTRY asset filename (`_june/client.<hash>.js`) for the document.
72
102
  export async function bundleClientToFile(entryFile: string, cwd: string, destDir: string, maps: ModuleMaps = {}): Promise<string> {
73
103
  const bundle = await bundleClient(entryFile, cwd, "production", maps);
74
- const { output } = await bundle.generate({ format: "esm", entryFileNames: "client.js" });
104
+ // Hash entry + split chunks; all under _june/ so the entry's relative imports
105
+ // (`./Counter-<hash>.js`) resolve there when served from /_june/client.<hash>.js.
106
+ const { output } = await bundle.generate({
107
+ format: "esm",
108
+ // hex hashes keep the asset names in the historical `client.<8hex>.js` shape
109
+ // (lowercase hex) the document + parity tests expect, now that there can be
110
+ // sibling chunks too.
111
+ hashCharacters: "hex",
112
+ entryFileNames: "_june/client.[hash].js",
113
+ chunkFileNames: "_june/[name]-[hash].js",
114
+ assetFileNames: "_june/[name]-[hash][extname]",
115
+ });
75
116
  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;
117
+
118
+ let entryFileName = "";
119
+ for (const o of output) {
120
+ const dest = join(destDir, o.fileName);
121
+ await mkdir(dirname(dest), { recursive: true });
122
+ if (o.type === "chunk") {
123
+ await writeFile(dest, o.code);
124
+ if (o.isEntry) entryFileName = o.fileName;
125
+ } else {
126
+ await writeFile(dest, o.source);
127
+ }
128
+ }
129
+ return entryFileName;
84
130
  }
package/src/content.ts CHANGED
@@ -79,7 +79,10 @@ function loadEntry(file: string, slug: string, locale?: string): ContentEntry {
79
79
  }
80
80
 
81
81
  const isMd = (f: string) => f.endsWith(".md") || f.endsWith(".mdx");
82
- const slugOf = (f: string) => f.replace(/\.(md|mdx)$/, "");
82
+ // Strip the extension, then collapse a directory index file to the directory itself, so it owns the
83
+ // folder's URL (Next.js / GitBook / Fumadocs convention): `a/b/index.md` → `a/b`, `a/b/README.md` →
84
+ // `a/b`, and a root `index`/`README` → "". A folder with an index becomes a page (not a buried child).
85
+ const slugOf = (f: string) => f.replace(/\.(md|mdx)$/, "").replace(/(^|\/)(index|README)$/i, "");
83
86
  const byDate = (a: ContentEntry, b: ContentEntry) =>
84
87
  String(b.data.date ?? "").localeCompare(String(a.data.date ?? "")) ||
85
88
  a.slug.localeCompare(b.slug);
@@ -104,25 +107,44 @@ function isLocaleDir(name: string, known?: readonly string[]): boolean {
104
107
  return known ? known.includes(name) : LOCALE_DIR.test(name);
105
108
  }
106
109
 
110
+ // Recursively collect markdown under `root`; slug = the path relative to `root`, POSIX, no extension
111
+ // (`guides/install.md` → `guides/install`) so routes and the sidebar can nest. `skipTopLocales` skips
112
+ // locale buckets at the TOP level only (scanned separately) — so a top-level `ja-JP/` is a locale, but
113
+ // a nested `guides/` (or `guides/ja-JP/`) is plain content.
114
+ function collectMd(
115
+ root: string,
116
+ knownLocales: readonly string[] | undefined,
117
+ skipTopLocales: boolean,
118
+ sub = "",
119
+ ): { file: string; slug: string }[] {
120
+ const out: { file: string; slug: string }[] = [];
121
+ for (const ent of readdirSync(join(root, sub), { withFileTypes: true })) {
122
+ const rel = sub ? `${sub}/${ent.name}` : ent.name;
123
+ if (ent.isDirectory()) {
124
+ if (skipTopLocales && sub === "" && isLocaleDir(ent.name, knownLocales)) continue;
125
+ out.push(...collectMd(root, knownLocales, skipTopLocales, rel));
126
+ } else if (isMd(ent.name)) {
127
+ out.push({ file: join(root, rel), slug: slugOf(rel) });
128
+ }
129
+ }
130
+ return out;
131
+ }
132
+
107
133
  export function scanCollection(dir: string, knownLocales?: readonly string[]): ScannedCollection {
108
- const def: ContentEntry[] = [];
134
+ // Default-locale entries: every .md under `dir` except inside top-level locale buckets. Flat files
135
+ // keep flat slugs (zero migration); subdirs become slug paths.
136
+ const def = collectMd(dir, knownLocales, true)
137
+ .map(({ file, slug }) => loadEntry(file, slug))
138
+ .sort(byDate);
109
139
  const byLocale: Record<string, ContentEntry[]> = {};
110
140
  for (const ent of readdirSync(dir, { withFileTypes: true })) {
111
- if (ent.isDirectory()) {
112
- // Only a configured locale (or, without a list, a BCP-47-shaped name) is a
113
- // locale bucket — a stray content/<col>/images/ is NOT a phantom "images".
114
- if (!isLocaleDir(ent.name, knownLocales)) continue;
141
+ if (ent.isDirectory() && isLocaleDir(ent.name, knownLocales)) {
115
142
  const locale = ent.name;
116
- const sub = join(dir, locale);
117
- byLocale[locale] = readdirSync(sub)
118
- .filter(isMd)
119
- .map((f) => loadEntry(join(sub, f), slugOf(f), locale))
143
+ byLocale[locale] = collectMd(join(dir, locale), knownLocales, false)
144
+ .map(({ file, slug }) => loadEntry(file, slug, locale))
120
145
  .sort(byDate);
121
- } else if (isMd(ent.name)) {
122
- def.push(loadEntry(join(dir, ent.name), slugOf(ent.name)));
123
146
  }
124
147
  }
125
- def.sort(byDate);
126
148
  return { default: def, byLocale };
127
149
  }
128
150
 
@@ -158,8 +180,9 @@ export function entry(
158
180
  locale?: string,
159
181
  opts?: { fallback?: boolean },
160
182
  ): ContentEntry | null {
161
- // Guard the slug — it comes from the URL.
162
- if (!/^[A-Za-z0-9._-]+$/.test(slug)) return null;
183
+ // Guard the slug — it comes from the URL. Allow "/" so nested content (`guides/install`) resolves,
184
+ // but reject path traversal and absolute/empty segments so the slug can't escape `dir`.
185
+ if (!/^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/.test(slug) || slug.includes("..")) return null;
163
186
  if (!locale) return probe(dir, slug);
164
187
  const variant = probe(join(dir, locale), slug, locale);
165
188
  if (variant) return variant;
@@ -207,6 +230,9 @@ export function generateContentModule(
207
230
  body += `export const ${CONST}: ContentEntry[] = ${JSON.stringify(def.map(strip), null, 2)};\n`;
208
231
  if (Object.keys(byLocale).length === 0) {
209
232
  body += `export const ${finder} = (slug: string): ContentEntry | null => ${CONST}.find((p) => p.slug === slug) ?? null;\n`;
233
+ // Always export the locale lister (a no-op for single-locale collections) so consumers can
234
+ // import `${d.name}` unconditionally — adding/removing locales never changes the module's shape.
235
+ body += `export const ${d.name} = (_locale?: string): ContentEntry[] => ${CONST};\n`;
210
236
  } else {
211
237
  anyLocale = true;
212
238
  const map: Record<string, Record<string, unknown>> = {};
package/src/css.ts CHANGED
@@ -4,15 +4,16 @@
4
4
  // (served immutable). Only the asset HREF diverges dev↔built, never the rendered
5
5
  // structure — so dev/built parity (the agent surface) holds.
6
6
  //
7
- // Floor: plain CSS, zero deps. Blessed: if global.css opts into Tailwind
8
- // (`@import "tailwindcss"`), June compiles it via the app's own Tailwind v4
9
- // (resolved from the app, not bundled into June) — Tailwind auto-detects the
10
- // content it scans. CSS never touches the agent projections (.md/.json/mcp).
7
+ // Floor: plain CSS, zero deps — Lightning CSS (the SAME Rust engine Tailwind v4 uses) handles
8
+ // nesting/prefix/minify. Blessed: if global.css opts into Tailwind (`@import "tailwindcss"`), June
9
+ // compiles it via the app's own Tailwind v4 ENGINE DIRECTLY — @tailwindcss/node + the @tailwindcss/oxide
10
+ // Rust scanner (the first-party Vite-plugin path, not PostCSS) — resolved from the app, napi-portable
11
+ // across Bun and Node. CSS never touches the agent projections (.md/.json/mcp).
11
12
 
12
- import { existsSync } from "node:fs";
13
+ import { existsSync, readFileSync } from "node:fs";
13
14
  import { readFile } from "node:fs/promises";
14
15
  import { createRequire } from "node:module";
15
- import { join } from "node:path";
16
+ import { dirname, join } from "node:path";
16
17
  import { pathToFileURL } from "node:url";
17
18
 
18
19
  import type { Targets } from "lightningcss"; // type-only — never enters the graph
@@ -31,6 +32,18 @@ export function findGlobalCss(appDir: string): string | null {
31
32
 
32
33
  const usesTailwind = (css: string) => /@import\s+["']tailwindcss["']|@tailwind\b/.test(css);
33
34
 
35
+ // True when app/global.css opts into Tailwind. Sync (build-time, tiny file) so the document config —
36
+ // built synchronously in dev (app.ts) and the build (build.ts) — can default `cssReset` off when
37
+ // Tailwind is present: its Preflight IS the reset, so June need not add its own baseline reset.
38
+ export function globalCssUsesTailwind(appDir: string): boolean {
39
+ const p = join(appDir, GLOBAL_CSS);
40
+ try {
41
+ return existsSync(p) && usesTailwind(readFileSync(p, "utf8"));
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
34
47
  // Resolve a tool from the APP's node_modules (Tailwind/postcss are the app's
35
48
  // deps, not June's). Returns null when absent.
36
49
  function resolveFromApp(specifier: string, appDir: string): string | null {
@@ -67,23 +80,40 @@ export async function cssTargets(appDir: string): Promise<Targets> {
67
80
  return targets;
68
81
  }
69
82
 
83
+ // Minimal type surface of the bits of Tailwind v4's engine we drive directly.
84
+ type TwSource = { base: string; pattern: string; negated: boolean };
85
+ type TwCompiler = { build(candidates: string[]): string; sources: TwSource[]; features: number };
86
+ type TwNode = {
87
+ compile(css: string, opts: { base: string; onDependency: (p: string) => void }): Promise<TwCompiler>;
88
+ optimize(css: string, opts: { minify: boolean }): { code: string };
89
+ Features: { Utilities: number };
90
+ };
91
+ type TwOxide = { Scanner: new (o: { sources: TwSource[] }) => { scan(): string[] } };
92
+
93
+ // Compile Tailwind v4 via its programmatic engine — @tailwindcss/node (compile + Lightning CSS optimize)
94
+ // + @tailwindcss/oxide (the Rust content Scanner) — the SAME direct path the first-party Vite plugin
95
+ // uses, NOT PostCSS. No postcss dependency or extra parse pass; the Oxide scanner (Rust) finds class
96
+ // candidates and the compiler emits only those (tree-shaken). Engine resolved from the APP (its dep,
97
+ // not June's); napi works identically on Bun and Node. Returns null when Tailwind isn't installed.
70
98
  async function compileTailwind(
71
99
  css: string,
72
100
  file: string,
73
101
  appDir: string,
74
102
  minify: boolean,
75
103
  ): Promise<string | null> {
76
- const postcssPath = resolveFromApp("postcss", appDir);
77
- const tailwindPath = resolveFromApp("@tailwindcss/postcss", appDir);
78
- if (!postcssPath || !tailwindPath) return null;
79
- const postcss = ((await import(postcssPath)) as { default: (p: unknown[]) => { process(css: string, opts: { from: string }): Promise<{ css: string }> } }).default;
80
- const tailwind = ((await import(tailwindPath)) as { default: (opts?: unknown) => unknown }).default;
81
- // Tailwind v4 only emits the classes the project actually uses (content
82
- // detection = tree-shaking). For build we ALSO minify via its Lightning CSS
83
- // optimizer; dev stays unminified for readability.
84
- const plugin = minify ? tailwind({ optimize: { minify: true } }) : tailwind();
85
- const result = await postcss([plugin]).process(css, { from: file });
86
- return result.css;
104
+ const nodePath = resolveFromApp("@tailwindcss/node", appDir);
105
+ const oxidePath = resolveFromApp("@tailwindcss/oxide", appDir);
106
+ if (!nodePath || !oxidePath) return null;
107
+ const { compile, optimize, Features } = (await import(nodePath)) as TwNode;
108
+ const { Scanner } = (await import(oxidePath)) as TwOxide;
109
+ const base = dirname(file); // the CSS's dir — resolves @import / @source / auto-detection
110
+ const compiler = await compile(css, { base, onDependency: () => {} });
111
+ // Scan the project (app root) plus whatever the compiler declares; Oxide skips node_modules/.gitignore.
112
+ const sources: TwSource[] = [{ base: dirname(appDir), pattern: "**/*", negated: false }, ...compiler.sources];
113
+ const candidates = compiler.features & Features.Utilities ? new Scanner({ sources }).scan() : [];
114
+ const out = compiler.build(candidates);
115
+ // Lightning CSS optimize for build (minify + prefix/lower); dev stays readable.
116
+ return minify ? optimize(out, { minify: true }).code : out;
87
117
  }
88
118
 
89
119
  // Dev-only memo. The dev server re-fetches /global.css on EVERY navigation, but
@@ -138,7 +168,7 @@ export async function processCss(
138
168
  });
139
169
  if (compiled !== null) return compiled; // Tailwind already minified when asked
140
170
  console.warn(
141
- "[june] global.css uses Tailwind but @tailwindcss/postcss isn't installed — serving raw CSS.",
171
+ "[june] global.css uses Tailwind but @tailwindcss/node + @tailwindcss/oxide aren't installed — serving raw CSS.",
142
172
  );
143
173
  }
144
174
  // Plain CSS (or the Tailwind fallback): minify + autoprefix/lower for the app's
package/src/dev-reload.ts CHANGED
@@ -38,10 +38,13 @@ const RELOAD_JS = `// june dev live-reload: reconnect-after-drop → reload; "cs
38
38
  es.addEventListener("open", () => {
39
39
  if (dropped) {
40
40
  // push-HMR: morph the freshly-restarted page in place (state survives);
41
- // hard-reload only if there's no morph hook or it can't apply.
41
+ // hard-reload only if there's no morph hook or it can't apply. On a
42
+ // successful morph also swap the stylesheet: a .tsx edit can change the
43
+ // class set, so the Tailwind-compiled /_june/global.css must refresh too
44
+ // (cache-busted) — otherwise new utility classes render unstyled.
42
45
  const hot = window.__juneLiveReload;
43
46
  (hot ? hot() : Promise.resolve(false)).then(
44
- (ok) => { if (!ok) location.reload(); },
47
+ (ok) => { if (ok) swapCss(); else location.reload(); },
45
48
  () => location.reload(),
46
49
  );
47
50
  }
@@ -0,0 +1,95 @@
1
+ // Auto-generate the island registry by scanning "use client" island() modules,
2
+ // so the author never hand-writes the `{ name: () => import("./Island") }` map.
3
+ //
4
+ // Scans app/** for modules that (a) start with the "use client" directive AND
5
+ // (b) use `island()` from @junejs/core/islands, and writes app/_islands.gen.ts:
6
+ //
7
+ // export const ISLAND_LOADERS = {
8
+ // "Counter": () => import("./poc/Counter"),
9
+ // "Tabs": () => import("./poc/Tabs"),
10
+ // };
11
+ //
12
+ // The KEY is the module's EXPORT name; importing the module runs its `island()`
13
+ // call, which self-registers the component under its runtime name. Those must
14
+ // agree — the marker the server stamps (the island's name) is what the client
15
+ // looks up. So the convention is: an island's name == its export name (the React
16
+ // default — `export const Counter = island(function Counter() {…})` satisfies it
17
+ // for free; otherwise pass `island(C, { name })`).
18
+ //
19
+ // Over-inclusion is harmless: a loader whose name no marker references is never
20
+ // called, so no chunk for an unused export is ever fetched.
21
+ //
22
+ // Host-coupled (node:fs), so it lives in @junejs/server, never the contract layer.
23
+ import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
24
+ import { join, relative } from "node:path";
25
+
26
+ export const ISLAND_REGISTRY_FILE = "_islands.gen.ts";
27
+
28
+ // Shared with rsc-manifest.ts (the RSC client-reference codegen).
29
+ export function walk(dir: string): string[] {
30
+ return readdirSync(dir).flatMap((name) => {
31
+ const p = join(dir, name);
32
+ return statSync(p).isDirectory() ? walk(p) : [p];
33
+ });
34
+ }
35
+
36
+ // True iff the first real statement (skipping blanks + line comments) is the
37
+ // directive — the same rule the bundler uses to treat a module as client.
38
+ export function firstStatementIsDirective(src: string, directive: string): boolean {
39
+ for (const line of src.split("\n")) {
40
+ const t = line.trim();
41
+ if (!t || t.startsWith("//")) continue;
42
+ return t.startsWith(`"${directive}"`) || t.startsWith(`'${directive}'`);
43
+ }
44
+ return false;
45
+ }
46
+
47
+ // The named exports of a module (skipping `export default` and `export type`).
48
+ export function exportNames(src: string): string[] {
49
+ const names: string[] = [];
50
+ for (const line of src.split("\n")) {
51
+ const t = line.trim();
52
+ const decl = t.match(/^export\s+(?:async\s+)?(?:function|const|let|var|class)\s+([A-Za-z0-9_$]+)/);
53
+ if (decl) names.push(decl[1]!);
54
+ const named = t.match(/^export\s*\{([^}]*)\}/); // `export type { … }` won't match (the `type` breaks it)
55
+ if (named) {
56
+ for (const part of named[1]!.split(",")) {
57
+ const e = part.split(" as ").pop()?.trim();
58
+ if (e) names.push(e);
59
+ }
60
+ }
61
+ }
62
+ return names;
63
+ }
64
+
65
+ // Write app/_islands.gen.ts. Returns the number of island loaders emitted.
66
+ export function generateIslandRegistry(appDir: string): number {
67
+ const mods = walk(appDir)
68
+ .filter((f) => /\.(tsx|ts|jsx)$/.test(f) && !f.endsWith(ISLAND_REGISTRY_FILE))
69
+ .map((f) => ({ f, src: readFileSync(f, "utf8") }))
70
+ .filter(
71
+ ({ src }) =>
72
+ firstStatementIsDirective(src, "use client") &&
73
+ /@junejs\/core\/islands/.test(src) &&
74
+ /\bisland\s*\(/.test(src),
75
+ );
76
+
77
+ const entries: string[] = [];
78
+ for (const { f, src } of mods) {
79
+ const rel = "./" + relative(appDir, f).replace(/\.(tsx|ts|jsx)$/, "");
80
+ for (const name of exportNames(src)) {
81
+ if (name === "default") continue; // islands are named exports
82
+ entries.push(` ${JSON.stringify(name)}: () => import(${JSON.stringify(rel)}),`);
83
+ }
84
+ }
85
+
86
+ const body = entries.length ? entries.join("\n") + "\n" : "";
87
+ const out =
88
+ "// AUTO-GENERATED by June — do not edit. Lazy loaders for island() modules.\n" +
89
+ "// Maps each island name to a lazy chunk loader; hydrateIslandsLazy consumes it.\n" +
90
+ "export const ISLAND_LOADERS: Record<string, () => Promise<unknown>> = {\n" +
91
+ body +
92
+ "};\n";
93
+ writeFileSync(join(appDir, ISLAND_REGISTRY_FILE), out);
94
+ return entries.length;
95
+ }
@@ -0,0 +1,121 @@
1
+ // RSC build orchestrator — PER-ROUTE, opt-in via the `page.rsc.tsx` convention.
2
+ // A route with a page.rsc.tsx is rendered through RSC (server graph → Flight →
3
+ // <Document> → HTML); every other route stays on the existing SSR/island pipeline.
4
+ // The two coexist behind a path dispatcher (createRscDispatch), so the SSR path is
5
+ // byte-unchanged when no RSC route exists.
6
+ //
7
+ // "use client" 分流 (the dual-semantics resolution): the SAME directive is read
8
+ // per ROUTE MODE. In an RSC route's server graph the client-references plugin turns
9
+ // "use client" into a true client reference (React-spec boundary); in an SSR/island
10
+ // route it stays an island scan marker. The two render paths are separate bundles,
11
+ // so one component file can serve both with no conflict.
12
+ import { existsSync } from "node:fs";
13
+ import { mkdir, writeFile } from "node:fs/promises";
14
+ import { dirname, join, relative } from "node:path";
15
+
16
+ import type { DocumentConfig } from "@junejs/core/document";
17
+
18
+ import { bundleServerGraph, bundleSsrGraph } from "./rsc-bundle";
19
+ import { generateRscManifests, RSC_CLIENT_MANIFEST_FILE } from "./rsc-manifest";
20
+ import { walk } from "./island-registry";
21
+
22
+ const RSC_ROUTES_FILE = "_rsc-routes.gen.tsx";
23
+ const RSC_PAGE_RE = /(?:^|\/)page\.rsc\.(tsx|jsx)$/;
24
+
25
+ export type RscRoute = { path: string; file: string };
26
+
27
+ // Scan app/** for page.rsc.tsx files → the RSC routes. Path is the route dir
28
+ // relative to app/ (route groups "(name)" stripped), "/" for the app root.
29
+ export function findRscRoutes(appDir: string): RscRoute[] {
30
+ if (!existsSync(appDir)) return [];
31
+ return walk(appDir)
32
+ .filter((f) => RSC_PAGE_RE.test(f))
33
+ .map((file) => {
34
+ const dir = dirname(relative(appDir, file));
35
+ const segs = dir === "." ? [] : dir.split("/").filter((s) => !/^\(.*\)$/.test(s));
36
+ return { path: "/" + segs.join("/"), file };
37
+ })
38
+ .map((r) => ({ ...r, path: r.path === "/" ? "/" : r.path.replace(/\/$/, "") }))
39
+ .sort((a, b) => a.path.localeCompare(b.path)); // deterministic
40
+ }
41
+
42
+ function runtimeEntry(name: string): string {
43
+ return join(dirname(new URL(import.meta.url).pathname), "rsc-runtime", name);
44
+ }
45
+
46
+ export type RscBuildResult = { worker: string; server: string; paths: string[] } | null;
47
+
48
+ // Build all RSC routes for `appRoot` into `<outDir>/rsc/`. Returns the emitted
49
+ // files + the owned paths (for the dispatcher), or null if no RSC route exists.
50
+ export async function buildRsc(
51
+ appRoot: string,
52
+ outDir: string,
53
+ docConfig: DocumentConfig,
54
+ ): Promise<RscBuildResult> {
55
+ const appDir = join(appRoot, "app");
56
+ const routes = findRscRoutes(appDir);
57
+ if (routes.length === 0) return null;
58
+
59
+ // 1. Client-reference manifests from the "use client" modules.
60
+ generateRscManifests(appDir);
61
+
62
+ // 2. The route map the server graph renders against (path → view default export).
63
+ let routesGen = "// AUTO-GENERATED by June — do not edit. RSC route map.\n";
64
+ routesGen += 'import type React from "react";\n';
65
+ routes.forEach((r, i) => {
66
+ const rel = "./" + relative(appDir, r.file).replace(/\.(tsx|jsx)$/, "");
67
+ routesGen += `import V${i} from ${JSON.stringify(rel)};\n`;
68
+ });
69
+ routesGen += 'export { CLIENT_MANIFEST as clientManifest } from "./_rsc-server.gen";\n';
70
+ routesGen += "export const ROUTES: Record<string, React.ComponentType> = {\n";
71
+ routes.forEach((r, i) => (routesGen += ` ${JSON.stringify(r.path)}: V${i},\n`));
72
+ routesGen += "};\n";
73
+ await writeFile(join(appDir, RSC_ROUTES_FILE), routesGen);
74
+
75
+ // 3. The doc-config module the worker imports (aliased june:rsc-config).
76
+ const configFile = join(appDir, "_rsc-config.gen.ts");
77
+ await writeFile(
78
+ configFile,
79
+ "// AUTO-GENERATED by June — do not edit. RSC document config.\n" +
80
+ `export const DOC_CONFIG = ${JSON.stringify(docConfig)} as const;\n`,
81
+ );
82
+
83
+ const dest = join(outDir, "rsc");
84
+ await mkdir(dest, { recursive: true });
85
+
86
+ // 4. Server graph: route views → Flight (react-server condition + the
87
+ // client-references plugin auto-wires "use client" islands).
88
+ const serverCode = await bundleServerGraph(
89
+ runtimeEntry("flight-render-routes.tsx"),
90
+ appRoot,
91
+ { "june:rsc-routes": join(appDir, RSC_ROUTES_FILE) },
92
+ appDir,
93
+ );
94
+ await writeFile(join(dest, "server.js"), serverCode);
95
+
96
+ // 5. SSR worker: Flight → <Document> → HTML (normal React), per request path.
97
+ const workerCode = await bundleSsrGraph(runtimeEntry("rsc-worker.tsx"), appRoot, {
98
+ "june:rsc-client": join(appDir, RSC_CLIENT_MANIFEST_FILE),
99
+ "june:rsc-config": configFile,
100
+ });
101
+ await writeFile(join(dest, "worker.js"), workerCode);
102
+
103
+ return { worker: "rsc/worker.js", server: "rsc/server.js", paths: routes.map((r) => r.path) };
104
+ }
105
+
106
+ type Fetcher = (request: Request) => Promise<Response>;
107
+
108
+ // The 分流: route RSC-owned paths to the RSC worker, everything else to the SSR
109
+ // pipeline. Exact-path match (RSC routes are explicit). This is what lets the two
110
+ // render models coexist behind one fetch handler.
111
+ export function createRscDispatch(
112
+ ssrFetch: Fetcher,
113
+ rscFetch: Fetcher,
114
+ rscPaths: Iterable<string>,
115
+ ): Fetcher {
116
+ const owned = new Set(rscPaths);
117
+ return (request: Request) => {
118
+ const path = new URL(request.url).pathname;
119
+ return owned.has(path) ? rscFetch(request) : ssrFetch(request);
120
+ };
121
+ }
@@ -0,0 +1,78 @@
1
+ // RSC build pipeline — the dual React graph, bundled for STANDARD targets
2
+ // (Cloudflare Workers / Vercel edge), with NO dependency on June's native runtime.
3
+ //
4
+ // The whole trick is resolve CONDITIONS:
5
+ // - server graph: ["react-server", "workerd", …] → `react` and
6
+ // `react-server-dom-webpack/server` resolve to their server-graph + edge
7
+ // (no node:*) builds. This graph renders components to a Flight stream.
8
+ // - ssr graph: ["workerd", …] (NO react-server) → normal `react` +
9
+ // `react-server-dom-webpack/client` + `react-dom/server.edge`. This graph
10
+ // consumes a Flight stream and renders it to HTML.
11
+ //
12
+ // Both bundles are worker-safe (the edge builds avoid node:*), so the worker /
13
+ // serverless function runs them directly. rolldown does the resolution — same
14
+ // engine the client bundle already uses.
15
+ import type { Plugin } from "rolldown";
16
+
17
+ import { rscClientReferencesPlugin } from "./rsc-manifest";
18
+
19
+ export type RscGraph = "server" | "ssr";
20
+
21
+ // edge-first conditions shared by both graphs; the server graph prepends
22
+ // "react-server" so React resolves to the server build.
23
+ const EDGE_CONDITIONS = ["workerd", "edge-light", "import", "default"];
24
+
25
+ // Generic-entry virtuals the build resolves to real app modules (so one runtime
26
+ // entry serves any app): "june:app" (the app root), "june:rsc-client" (the
27
+ // generated consumer manifest), "june:rsc-config" (the document config).
28
+ export type RscAliases = Record<string, string>;
29
+
30
+ async function bundleGraph(
31
+ entryFile: string,
32
+ cwd: string,
33
+ graph: RscGraph,
34
+ aliases: RscAliases = {},
35
+ plugins: Plugin[] = [],
36
+ ): Promise<string> {
37
+ const { rolldown } = await import("rolldown");
38
+ const conditionNames = graph === "server" ? ["react-server", ...EDGE_CONDITIONS] : EDGE_CONDITIONS;
39
+ const bundle = await rolldown({
40
+ input: entryFile,
41
+ cwd,
42
+ platform: "neutral",
43
+ plugins,
44
+ transform: { define: { "process.env.NODE_ENV": JSON.stringify("production") } },
45
+ resolve: {
46
+ conditionNames,
47
+ ...(Object.keys(aliases).length ? { alias: aliases } : {}),
48
+ },
49
+ });
50
+ const { output } = await bundle.generate({ format: "esm" });
51
+ await bundle.close();
52
+ const entry = output.find((o) => o.type === "chunk" && o.isEntry);
53
+ return entry && entry.type === "chunk" ? entry.code : "";
54
+ }
55
+
56
+ // Bundle the server (react-server) graph that renders an app to Flight. When
57
+ // `appDir` is given, the client-references plugin rewrites that app's "use client"
58
+ // modules into client references automatically (no hand-written registration).
59
+ export function bundleServerGraph(
60
+ entryFile: string,
61
+ cwd: string,
62
+ aliases?: RscAliases,
63
+ appDir?: string,
64
+ ): Promise<string> {
65
+ const plugins = appDir ? [rscClientReferencesPlugin(appDir)] : [];
66
+ return bundleGraph(entryFile, cwd, "server", aliases, plugins);
67
+ }
68
+
69
+ // Bundle the SSR (normal-react) graph that turns a Flight stream into HTML.
70
+ export function bundleSsrGraph(entryFile: string, cwd: string, aliases?: RscAliases): Promise<string> {
71
+ return bundleGraph(entryFile, cwd, "ssr", aliases);
72
+ }
73
+
74
+ // True iff bundled code references node:* — the worker-safety invariant. The RSC
75
+ // edge builds must never pull node:* (Cloudflare/Vercel edge would reject it).
76
+ export function referencesNodeBuiltins(code: string): boolean {
77
+ return /from\s*["']node:|require\(\s*["']node:/.test(code);
78
+ }
@@ -0,0 +1,111 @@
1
+ // Auto-generate the RSC client-reference wiring by scanning "use client" modules,
2
+ // so an app NEVER hand-writes registerClientReference / __webpack_require__ / the
3
+ // module maps. Two halves, both keyed by the SAME id (`<relPath>#<export>`):
4
+ //
5
+ // 1. SERVER graph (Flight render): a rolldown plugin replaces each "use client"
6
+ // module's body with registerClientReference proxies — so `<Counter/>` in a
7
+ // server component serializes as a client REFERENCE, not its impl.
8
+ // 2. SSR / BROWSER graphs (Flight consume): generated _rsc-client.gen.ts wires
9
+ // __webpack_require__ → the real modules + the moduleMap, and _rsc-server.gen.ts
10
+ // holds the render-side manifest (the webpackMap renderToReadableStream needs).
11
+ //
12
+ // Host-coupled (node:fs + rolldown types). Reuses the same "use client" scan as
13
+ // the island registry.
14
+ import { writeFileSync, readFileSync } from "node:fs";
15
+ import { join, relative } from "node:path";
16
+ import type { Plugin } from "rolldown";
17
+
18
+ import { walk, firstStatementIsDirective, exportNames } from "./island-registry";
19
+
20
+ export const RSC_SERVER_MANIFEST_FILE = "_rsc-server.gen.ts";
21
+ export const RSC_CLIENT_MANIFEST_FILE = "_rsc-client.gen.ts";
22
+
23
+ // Stable, per-export reference id shared by every graph: "<relPath no ext>#<name>".
24
+ function refId(appDir: string, absPath: string, name: string): string {
25
+ const rel = relative(appDir, absPath).replace(/\.(tsx|ts|jsx)$/, "");
26
+ return `${rel}#${name}`;
27
+ }
28
+
29
+ type ClientModule = { rel: string; abs: string; names: string[] };
30
+
31
+ function clientModules(appDir: string): ClientModule[] {
32
+ return walk(appDir)
33
+ .filter(
34
+ (f) =>
35
+ /\.(tsx|ts|jsx)$/.test(f) &&
36
+ !f.endsWith(RSC_SERVER_MANIFEST_FILE) &&
37
+ !f.endsWith(RSC_CLIENT_MANIFEST_FILE),
38
+ )
39
+ .map((abs) => ({ abs, src: readFileSync(abs, "utf8") }))
40
+ .filter(({ src }) => firstStatementIsDirective(src, "use client"))
41
+ .map(({ abs, src }) => ({
42
+ abs,
43
+ rel: "./" + relative(appDir, abs).replace(/\.(tsx|ts|jsx)$/, ""),
44
+ names: exportNames(src).filter((n) => n !== "default"),
45
+ }))
46
+ .filter((m) => m.names.length > 0)
47
+ .sort((a, b) => a.rel.localeCompare(b.rel)); // deterministic output
48
+ }
49
+
50
+ // SERVER-graph plugin: rewrite every "use client" module under appDir into
51
+ // registerClientReference proxies. Pass it ONLY to the server (react-server)
52
+ // bundle — the SSR/browser graphs load the real modules.
53
+ export function rscClientReferencesPlugin(appDir: string): Plugin {
54
+ return {
55
+ name: "june:rsc-client-references",
56
+ transform(code, id) {
57
+ if (!id.startsWith(appDir)) return null;
58
+ if (!firstStatementIsDirective(code, "use client")) return null;
59
+ const names = exportNames(code).filter((n) => n !== "default");
60
+ let out = `import { registerClientReference } from "react-server-dom-webpack/server";\n`;
61
+ for (const name of names) {
62
+ const rid = refId(appDir, id, name);
63
+ out += `export const ${name} = registerClientReference({}, ${JSON.stringify(rid)}, ${JSON.stringify(name)});\n`;
64
+ }
65
+ return { code: out, map: null };
66
+ },
67
+ };
68
+ }
69
+
70
+ // Write both manifest files. Returns the number of client modules wired.
71
+ export function generateRscManifests(appDir: string): number {
72
+ const mods = clientModules(appDir);
73
+
74
+ // Render-side manifest (flat by id) — the webpackMap for renderToReadableStream.
75
+ let server = "// AUTO-GENERATED by June — do not edit. RSC render manifest.\n";
76
+ server += "export const CLIENT_MANIFEST: Record<string, { id: string; chunks: string[]; name: string }> = {\n";
77
+ for (const m of mods) {
78
+ for (const name of m.names) {
79
+ const rid = `${m.rel.slice(2)}#${name}`; // strip leading "./" → matches refId()
80
+ server += ` ${JSON.stringify(rid)}: { id: ${JSON.stringify(rid)}, chunks: [], name: ${JSON.stringify(name)} },\n`;
81
+ }
82
+ }
83
+ server += "};\n";
84
+ writeFileSync(join(appDir, RSC_SERVER_MANIFEST_FILE), server);
85
+
86
+ // Consumer side (SSR + browser): real modules + moduleMap, installs the webpack
87
+ // shim. moduleMap is NESTED by export name (what client.edge resolves against).
88
+ let client = "// AUTO-GENERATED by June — do not edit. RSC consumer manifest.\n";
89
+ mods.forEach((m, i) => (client += `import * as m${i} from ${JSON.stringify(m.rel)};\n`));
90
+ client += "\nconst CLIENT_MODULES: Record<string, unknown> = {\n";
91
+ mods.forEach((m, i) => {
92
+ for (const name of m.names) client += ` ${JSON.stringify(`${m.rel.slice(2)}#${name}`)}: m${i},\n`;
93
+ });
94
+ client += "};\n";
95
+ client += "export const MODULE_MAP: Record<string, Record<string, { id: string; chunks: string[]; name: string }>> = {\n";
96
+ for (const m of mods) {
97
+ for (const name of m.names) {
98
+ const rid = `${m.rel.slice(2)}#${name}`;
99
+ client += ` ${JSON.stringify(rid)}: { ${JSON.stringify(name)}: { id: ${JSON.stringify(rid)}, chunks: [], name: ${JSON.stringify(name)} } },\n`;
100
+ }
101
+ }
102
+ client += "};\n\n";
103
+ client +=
104
+ "const g = globalThis as Record<string, unknown>;\n" +
105
+ "g.__webpack_require__ = (id: string) => CLIENT_MODULES[id];\n" +
106
+ "g.__webpack_chunk_load__ = () => Promise.resolve();\n" +
107
+ "export { CLIENT_MODULES };\n";
108
+ writeFileSync(join(appDir, RSC_CLIENT_MANIFEST_FILE), client);
109
+
110
+ return mods.length;
111
+ }
@@ -0,0 +1,33 @@
1
+ // Server-graph entry for PER-ROUTE RSC: render the matched route's view to Flight.
2
+ // The route map (path → server-component view) + client manifest are generated and
3
+ // injected as "june:rsc-routes". Worker-safe (server.edge via the react-server
4
+ // condition).
5
+ import { renderToReadableStream } from "react-server-dom-webpack/server";
6
+
7
+ import { ROUTES, clientManifest } from "june:rsc-routes";
8
+
9
+ async function streamToString(stream: ReadableStream<Uint8Array>): Promise<string> {
10
+ const reader = stream.getReader();
11
+ const dec = new TextDecoder();
12
+ let out = "";
13
+ for (;;) {
14
+ const { done, value } = await reader.read();
15
+ if (done) break;
16
+ out += dec.decode(value, { stream: true });
17
+ }
18
+ out += dec.decode();
19
+ return out;
20
+ }
21
+
22
+ // Render the view registered for `path` to a Flight payload, or null if no RSC
23
+ // route owns that path (the dispatcher then leaves it to the SSR pipeline).
24
+ export async function renderFlight(path: string): Promise<string | null> {
25
+ const View = ROUTES[path];
26
+ if (!View) return null;
27
+ return streamToString(renderToReadableStream(<View />, clientManifest ?? {}));
28
+ }
29
+
30
+ // The paths this RSC bundle owns — the dispatcher routes these here.
31
+ export function rscPaths(): string[] {
32
+ return Object.keys(ROUTES);
33
+ }
@@ -0,0 +1,27 @@
1
+ // Server-graph entry (bundled with the react-server condition): render the app to
2
+ // a React Flight stream. Worker-safe — uses server.edge under the hood. The app
3
+ // is injected as "june:app" by the build's alias.
4
+ import { renderToReadableStream } from "react-server-dom-webpack/server";
5
+
6
+ import { App, clientManifest } from "june:app";
7
+
8
+ async function streamToString(stream: ReadableStream<Uint8Array>): Promise<string> {
9
+ const reader = stream.getReader();
10
+ const dec = new TextDecoder();
11
+ let out = "";
12
+ for (;;) {
13
+ const { done, value } = await reader.read();
14
+ if (done) break;
15
+ out += dec.decode(value, { stream: true });
16
+ }
17
+ out += dec.decode();
18
+ return out;
19
+ }
20
+
21
+ // Render the app to a Flight payload string. (A real server would stream the
22
+ // ReadableStream straight to the response; the string form is for tests + the
23
+ // SSR step.)
24
+ export async function renderFlight(): Promise<string> {
25
+ const stream = renderToReadableStream(<App />, clientManifest ?? {});
26
+ return streamToString(stream);
27
+ }
@@ -0,0 +1,45 @@
1
+ // SSR-graph entry (bundled WITHOUT the react-server condition → normal React):
2
+ // consume a Flight stream and render it to HTML. Worker-safe — react-dom's edge
3
+ // server build + the client.edge Flight reader, no node:*. This is the first-load
4
+ // step: the worker turns Flight into the HTML the browser paints.
5
+ import { use } from "react";
6
+ import { createFromReadableStream } from "react-server-dom-webpack/client";
7
+ import { renderToReadableStream } from "react-dom/server.edge";
8
+
9
+ // The Flight payload deserializes to a thenable React tree; `use` unwraps it
10
+ // inside the render so react-dom can stream it to HTML.
11
+ function Root({ tree }: { tree: Promise<React.ReactNode> }): React.ReactNode {
12
+ return use(tree);
13
+ }
14
+
15
+ async function streamToString(stream: ReadableStream<Uint8Array>): Promise<string> {
16
+ const reader = stream.getReader();
17
+ const dec = new TextDecoder();
18
+ let out = "";
19
+ for (;;) {
20
+ const { done, value } = await reader.read();
21
+ if (done) break;
22
+ out += dec.decode(value, { stream: true });
23
+ }
24
+ out += dec.decode();
25
+ return out;
26
+ }
27
+
28
+ // Turn a Flight payload string into an HTML string. `moduleMap` resolves client
29
+ // references to their real components so islands SSR too; an empty map is fine for
30
+ // server-only trees (client.edge still requires the manifest object to exist).
31
+ export async function flightToHtml(
32
+ flight: string,
33
+ moduleMap: Record<string, unknown> = {},
34
+ ): Promise<string> {
35
+ const flightStream = new Response(flight).body as ReadableStream<Uint8Array>;
36
+ const tree = createFromReadableStream(flightStream, {
37
+ serverConsumerManifest: { moduleMap, serverModuleMap: null, moduleLoading: null },
38
+ }) as unknown as Promise<React.ReactNode>;
39
+ const html = await renderToReadableStream(<Root tree={tree} />);
40
+ // Non-streaming: wait for the whole document.
41
+ if (typeof (html as { allReady?: Promise<void> }).allReady?.then === "function") {
42
+ await (html as unknown as { allReady: Promise<void> }).allReady;
43
+ }
44
+ return streamToString(html as unknown as ReadableStream<Uint8Array>);
45
+ }
@@ -0,0 +1,70 @@
1
+ // The RSC worker entry (SSR graph, normal React) — the fetch handler that serves
2
+ // an RSC app on a STANDARD target (Cloudflare Workers / Vercel edge), no native
3
+ // runtime. Per request it chains the two graphs:
4
+ // 1. the server-graph bundle (react-server) renders the app → Flight,
5
+ // 2. this graph deserializes the Flight + resolves client references to real
6
+ // modules (the generated consumer manifest) → a React tree,
7
+ // 3. wraps it in June's <Document> and streams HTML.
8
+ //
9
+ // The server bundle is a SEPARATE file (its own react-server React); it is loaded
10
+ // at runtime via a computed URL so this (normal-React) bundle never re-bundles it.
11
+ import { use } from "react";
12
+ import { createFromReadableStream } from "react-server-dom-webpack/client";
13
+ import { renderToReadableStream } from "react-dom/server.edge";
14
+ import { Document } from "@junejs/core/document";
15
+
16
+ // Generated per app, aliased at build time:
17
+ import { MODULE_MAP } from "june:rsc-client"; // installs __webpack_require__ (side effect) + the moduleMap
18
+ import { DOC_CONFIG } from "june:rsc-config"; // the frozen DocumentConfig
19
+
20
+ type ServerBundle = { renderFlight: (path: string) => Promise<string | null> };
21
+
22
+ function Root({ tree }: { tree: Promise<React.ReactNode> }): React.ReactNode {
23
+ return use(tree);
24
+ }
25
+
26
+ async function streamToString(stream: ReadableStream<Uint8Array>): Promise<string> {
27
+ const reader = stream.getReader();
28
+ const dec = new TextDecoder();
29
+ let out = "";
30
+ for (;;) {
31
+ const { done, value } = await reader.read();
32
+ if (done) break;
33
+ out += dec.decode(value, { stream: true });
34
+ }
35
+ out += dec.decode();
36
+ return out;
37
+ }
38
+
39
+ async function renderDocument(path: string): Promise<string | null> {
40
+ // Non-literal specifier → rolldown leaves it as a runtime import, so the
41
+ // react-server server bundle is loaded (not re-bundled) from beside this file.
42
+ const serverUrl = new URL("./server.js", import.meta.url).href;
43
+ const server = (await import(serverUrl)) as ServerBundle;
44
+ const flight = await server.renderFlight(path);
45
+ if (flight === null) return null; // no RSC route here
46
+
47
+ const flightStream = new Response(flight).body as ReadableStream<Uint8Array>;
48
+ const tree = createFromReadableStream(flightStream, {
49
+ serverConsumerManifest: { moduleMap: MODULE_MAP, serverModuleMap: null, moduleLoading: null },
50
+ }) as unknown as Promise<React.ReactNode>;
51
+
52
+ const html = await renderToReadableStream(
53
+ <Document config={DOC_CONFIG}>
54
+ <Root tree={tree} />
55
+ </Document>,
56
+ );
57
+ if (typeof (html as { allReady?: Promise<void> }).allReady?.then === "function") {
58
+ await (html as unknown as { allReady: Promise<void> }).allReady;
59
+ }
60
+ return "<!DOCTYPE html>" + (await streamToString(html as unknown as ReadableStream<Uint8Array>));
61
+ }
62
+
63
+ export default {
64
+ async fetch(request: Request): Promise<Response> {
65
+ const path = new URL(request.url).pathname;
66
+ const body = await renderDocument(path);
67
+ if (body === null) return new Response("Not Found", { status: 404 });
68
+ return new Response(body, { headers: { "content-type": "text/html; charset=utf-8" } });
69
+ },
70
+ };
@@ -0,0 +1,53 @@
1
+ // Ambient declarations for react-server-dom-webpack (it ships no .d.ts) and the
2
+ // "june:app" virtual the RSC runtime entries import (aliased at bundle time).
3
+ // Minimal — only the surface the runtime entries use.
4
+ declare module "react-server-dom-webpack/server" {
5
+ export function renderToReadableStream(
6
+ model: unknown,
7
+ webpackMap?: unknown,
8
+ options?: unknown,
9
+ ): ReadableStream<Uint8Array>;
10
+ export function registerClientReference(
11
+ proxy: object,
12
+ id: string,
13
+ exportName: string,
14
+ ): unknown;
15
+ // "use server" — register a server function so it serializes as a server
16
+ // reference and can be invoked from a decoded client request.
17
+ export function registerServerReference<F>(fn: F, id: string, exportName: string): F;
18
+ export function decodeReply(body: string | FormData, serverManifest?: unknown, options?: unknown): Promise<unknown[]>;
19
+ }
20
+
21
+ declare module "react-server-dom-webpack/client" {
22
+ export function createFromReadableStream(
23
+ stream: ReadableStream<Uint8Array>,
24
+ options?: unknown,
25
+ ): Promise<React.ReactNode> & React.ReactNode;
26
+ // "use server" client half — encode args for a server-function POST.
27
+ export function encodeReply(value: unknown): Promise<string | FormData>;
28
+ }
29
+
30
+ // The app under render, injected by the build (resolve.alias "june:app" → app).
31
+ declare module "june:app" {
32
+ import type React from "react";
33
+ export const App: React.ComponentType;
34
+ export const clientManifest: Record<string, unknown> | undefined;
35
+ }
36
+
37
+ // The generated per-route map (resolve.alias "june:rsc-routes" → _rsc-routes.gen.tsx).
38
+ declare module "june:rsc-routes" {
39
+ import type React from "react";
40
+ export const ROUTES: Record<string, React.ComponentType>;
41
+ export const clientManifest: Record<string, unknown> | undefined;
42
+ }
43
+
44
+ // The generated consumer manifest (resolve.alias "june:rsc-client" → app/_rsc-client.gen.ts).
45
+ declare module "june:rsc-client" {
46
+ export const MODULE_MAP: Record<string, Record<string, { id: string; chunks: string[]; name: string }>>;
47
+ }
48
+
49
+ // The frozen document config (resolve.alias "june:rsc-config" → generated).
50
+ declare module "june:rsc-config" {
51
+ import type { DocumentConfig } from "@junejs/core/document";
52
+ export const DOC_CONFIG: DocumentConfig;
53
+ }