@junejs/server 0.0.30 → 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.30",
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.29",
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,7 +27,13 @@ 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";
30
+ import {
31
+ findClientEntry,
32
+ bundleClientSplit,
33
+ CLIENT_SCRIPT_URL,
34
+ type ClientChunks,
35
+ } from "./client-bundle";
36
+ import { generateIslandRegistry } from "./island-registry";
26
37
  import { findGlobalCss, globalCssUsesTailwind, processCssCached, STYLES_URL } from "./css";
27
38
  import { buildModuleCss, registerCssModules, MODULE_STYLES_URL, type ModuleMaps } from "./css-modules";
28
39
 
@@ -109,7 +120,7 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
109
120
  viewTransitions: config.viewTransitions ?? true,
110
121
  // Default the baseline reset OFF when the app uses Tailwind (its Preflight is the reset).
111
122
  cssReset: config.cssReset ?? !globalCssUsesTailwind(appDir),
112
- clientRouter: config.clientRouter ?? false,
123
+ clientRouter: resolveClientRouter(config.clientRouter),
113
124
  clientScript: clientEntry ? CLIENT_SCRIPT_URL : null,
114
125
  styles: cssEntry ? STYLES_URL : null,
115
126
  };
@@ -119,13 +130,17 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
119
130
  let moduleCssPromise: Promise<{ maps: ModuleMaps; css: string | null }> | undefined;
120
131
  const getModuleCss = () => (moduleCssPromise ??= buildModuleCss(appDir, dirname(appDir)));
121
132
 
122
- let clientBundle: Promise<string> | undefined;
123
- const serveClient = (): Promise<string> =>
124
- // cwd = the app ROOT (appDir's parent) so rolldown resolves node_modules
125
- // from the project, exactly like the build does. Islands may import .module.css.
126
- (clientBundle ??= getModuleCss().then(({ maps }) =>
127
- bundleClientToString(clientEntry!, dirname(appDir), maps),
128
- ));
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
+ }));
129
144
 
130
145
  const routePaths = () => listRoutes(appDir, { pageConvention: true });
131
146
 
@@ -228,15 +243,27 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
228
243
  new Response(css ?? "", { headers: { "content-type": "text/css; charset=utf-8" } }),
229
244
  );
230
245
  }
231
- // Dev serves the islands runtime itself (build ships it as a static
232
- // asset); bundled on first hit, memoized after.
233
- if (clientEntry && new URL(request.url).pathname === CLIENT_SCRIPT_URL) {
234
- return serveClient().then(
235
- (code) =>
236
- new Response(code, {
237
- headers: { "content-type": "text/javascript; charset=utf-8" },
238
- }),
239
- (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) => {
240
267
  // A rejected bundle must answer, not hang the request (Bun.serve
241
268
  // times the handler out) — and must not be memoized, or the error
242
269
  // outlives its cause.
@@ -246,8 +273,9 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
246
273
  status: 500,
247
274
  headers: { "content-type": "text/plain; charset=utf-8" },
248
275
  });
249
- },
250
- );
276
+ },
277
+ );
278
+ }
251
279
  }
252
280
  return runWithTrace(newTrace(), async () => (await getPipeline()).fetch(request));
253
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,6 +35,8 @@ 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 { generateIslandRegistry } from "./island-registry";
39
+ import { buildRsc, findRscRoutes } from "./rsc-build";
38
40
  import { cssTargets, findGlobalCss, globalCssUsesTailwind, minifyCss, processCss, STYLES_URL } from "./css";
39
41
  import { buildModuleCss, rolldownCssModulesPlugin, registerCssModules } from "./css-modules";
40
42
 
@@ -150,7 +152,7 @@ export async function freezeConfig(appRoot: string): Promise<{
150
152
  viewTransitions: cfg.viewTransitions ?? true,
151
153
  // Default the baseline reset OFF when the app uses Tailwind (its Preflight is the reset).
152
154
  cssReset: cfg.cssReset ?? !globalCssUsesTailwind(join(appRoot, "app")),
153
- clientRouter: cfg.clientRouter ?? false,
155
+ clientRouter: resolveClientRouter(cfg.clientRouter),
154
156
  clientScript: hasClient ? CLIENT_SCRIPT_URL : null,
155
157
  styles: hasCss ? STYLES_URL : null,
156
158
  },
@@ -345,10 +347,19 @@ export async function juneBuild(
345
347
  const clientEntry = findClientEntry(appDir);
346
348
  let clientAsset: string | null = null;
347
349
  if (clientEntry) {
350
+ // Regenerate the auto lazy island registry before bundling (same as dev).
351
+ generateIslandRegistry(appDir);
348
352
  clientAsset = await bundleClientToFile(clientEntry, appRoot, assetsDir, cssModuleMaps);
349
353
  frozen.document.clientScript = `/${clientAsset}`;
350
354
  }
351
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
+
352
363
  // Declared resources become two things: a build-time plan (→ platform bindings
353
364
  // the adapter emits) and a runtime provider wired into the generated entry.
354
365
  // A resource-less app imports no config and emits no bindings, so its output
@@ -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
  }
@@ -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
+ }