@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 +10 -5
- package/src/app.ts +49 -21
- package/src/build.ts +13 -2
- package/src/client-bundle.ts +59 -13
- package/src/island-registry.ts +95 -0
- package/src/rsc-build.ts +121 -0
- package/src/rsc-bundle.ts +78 -0
- package/src/rsc-manifest.ts +111 -0
- package/src/rsc-runtime/flight-render-routes.tsx +33 -0
- package/src/rsc-runtime/flight-render.tsx +27 -0
- package/src/rsc-runtime/flight-to-html.tsx +45 -0
- package/src/rsc-runtime/rsc-worker.tsx +70 -0
- package/src/rsc-runtime/rsdw-shims.d.ts +53 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@junejs/server",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
232
|
-
//
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
(
|
|
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
|
|
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
|
package/src/client-bundle.ts
CHANGED
|
@@ -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
|
|
70
|
-
// (immutable-cacheable
|
|
71
|
-
// (`
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
+
}
|
package/src/rsc-build.ts
ADDED
|
@@ -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
|
+
}
|