@junejs/server 0.0.28 → 0.0.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +10 -5
- package/src/app.ts +52 -22
- package/src/build.ts +24 -7
- package/src/client-bundle.ts +59 -13
- package/src/content.ts +41 -15
- package/src/css.ts +48 -18
- package/src/dev-reload.ts +5 -2
- 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,8 +27,14 @@ import { findMiddlewareFile, isResourceFile, listRoutes, matchRouteTree, resolve
|
|
|
22
27
|
import { createPipeline, type ExtraHandler, type LayoutComponent, type Pipeline, type Resolved, type ResourceHandler } from "./pipeline";
|
|
23
28
|
import { resolveBoundary } from "./segment";
|
|
24
29
|
import { memoizeResources } from "./resources";
|
|
25
|
-
import {
|
|
26
|
-
|
|
30
|
+
import {
|
|
31
|
+
findClientEntry,
|
|
32
|
+
bundleClientSplit,
|
|
33
|
+
CLIENT_SCRIPT_URL,
|
|
34
|
+
type ClientChunks,
|
|
35
|
+
} from "./client-bundle";
|
|
36
|
+
import { generateIslandRegistry } from "./island-registry";
|
|
37
|
+
import { findGlobalCss, globalCssUsesTailwind, processCssCached, STYLES_URL } from "./css";
|
|
27
38
|
import { buildModuleCss, registerCssModules, MODULE_STYLES_URL, type ModuleMaps } from "./css-modules";
|
|
28
39
|
|
|
29
40
|
export type CreateAppOptions = {
|
|
@@ -107,7 +118,9 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
|
|
|
107
118
|
speculationRules: resolveSpeculationRules(speculation ?? undefined),
|
|
108
119
|
speculationDelivery: speculation ? speculation.delivery ?? "inline" : "inline",
|
|
109
120
|
viewTransitions: config.viewTransitions ?? true,
|
|
110
|
-
|
|
121
|
+
// Default the baseline reset OFF when the app uses Tailwind (its Preflight is the reset).
|
|
122
|
+
cssReset: config.cssReset ?? !globalCssUsesTailwind(appDir),
|
|
123
|
+
clientRouter: resolveClientRouter(config.clientRouter),
|
|
111
124
|
clientScript: clientEntry ? CLIENT_SCRIPT_URL : null,
|
|
112
125
|
styles: cssEntry ? STYLES_URL : null,
|
|
113
126
|
};
|
|
@@ -117,13 +130,17 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
|
|
|
117
130
|
let moduleCssPromise: Promise<{ maps: ModuleMaps; css: string | null }> | undefined;
|
|
118
131
|
const getModuleCss = () => (moduleCssPromise ??= buildModuleCss(appDir, dirname(appDir)));
|
|
119
132
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
133
|
+
// Split build: one chunk per island + a shared React chunk, all keyed by file
|
|
134
|
+
// name. The entry is "client.js"; island chunks load on demand. cwd = the app
|
|
135
|
+
// ROOT so rolldown resolves node_modules from the project, like the build does.
|
|
136
|
+
let clientBundle: Promise<ClientChunks> | undefined;
|
|
137
|
+
const serveClient = (): Promise<ClientChunks> =>
|
|
138
|
+
(clientBundle ??= getModuleCss().then(({ maps }) => {
|
|
139
|
+
// Regenerate app/_islands.gen.ts (the auto lazy registry) right before
|
|
140
|
+
// bundling, so a freshly added island() module is wired without a restart.
|
|
141
|
+
generateIslandRegistry(appDir);
|
|
142
|
+
return bundleClientSplit(clientEntry!, dirname(appDir), "development", maps);
|
|
143
|
+
}));
|
|
127
144
|
|
|
128
145
|
const routePaths = () => listRoutes(appDir, { pageConvention: true });
|
|
129
146
|
|
|
@@ -226,15 +243,27 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
|
|
|
226
243
|
new Response(css ?? "", { headers: { "content-type": "text/css; charset=utf-8" } }),
|
|
227
244
|
);
|
|
228
245
|
}
|
|
229
|
-
// Dev serves the islands runtime itself (build ships it as
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
(
|
|
246
|
+
// Dev serves the islands runtime itself (build ships it as static assets);
|
|
247
|
+
// bundled on first hit, memoized after. Split build → many chunks under
|
|
248
|
+
// /_june/: the entry is /_june/client.js, islands are /_june/<name>.js,
|
|
249
|
+
// loaded on demand by the lazy runtime.
|
|
250
|
+
{
|
|
251
|
+
const pathname = new URL(request.url).pathname;
|
|
252
|
+
if (clientEntry && pathname.startsWith("/_june/") && pathname.endsWith(".js")) {
|
|
253
|
+
const file = pathname.slice("/_june/".length);
|
|
254
|
+
return serveClient().then(
|
|
255
|
+
(chunks) => {
|
|
256
|
+
const code = chunks.get(file);
|
|
257
|
+
if (code === undefined)
|
|
258
|
+
return new Response(`unknown client chunk: ${file}\n`, {
|
|
259
|
+
status: 404,
|
|
260
|
+
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
261
|
+
});
|
|
262
|
+
return new Response(code, {
|
|
263
|
+
headers: { "content-type": "text/javascript; charset=utf-8" },
|
|
264
|
+
});
|
|
265
|
+
},
|
|
266
|
+
(err: unknown) => {
|
|
238
267
|
// A rejected bundle must answer, not hang the request (Bun.serve
|
|
239
268
|
// times the handler out) — and must not be memoized, or the error
|
|
240
269
|
// outlives its cause.
|
|
@@ -244,8 +273,9 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
|
|
|
244
273
|
status: 500,
|
|
245
274
|
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
246
275
|
});
|
|
247
|
-
|
|
248
|
-
|
|
276
|
+
},
|
|
277
|
+
);
|
|
278
|
+
}
|
|
249
279
|
}
|
|
250
280
|
return runWithTrace(newTrace(), async () => (await getPipeline()).fetch(request));
|
|
251
281
|
},
|
package/src/build.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { basename, dirname, join, relative, sep } from "node:path";
|
|
|
24
24
|
import { pathToFileURL } from "node:url";
|
|
25
25
|
|
|
26
26
|
import { loadJuneConfig } from "./config-loader";
|
|
27
|
-
import { resolveAgent, resolveSpeculationRules } from "@junejs/core/config";
|
|
27
|
+
import { resolveAgent, resolveClientRouter, resolveSpeculationRules } from "@junejs/core/config";
|
|
28
28
|
import { buildLinkHeader } from "@junejs/core/discovery";
|
|
29
29
|
import { routeFromModule, type BrandedRoute } from "@junejs/core/route";
|
|
30
30
|
import { workers, type JuneAdapter, type ResourcePlan } from "./adapter";
|
|
@@ -35,7 +35,9 @@ import { findMiddlewareFile } from "./router";
|
|
|
35
35
|
import { resolveBoundary } from "./segment";
|
|
36
36
|
import type { ExtraHandler, LayoutComponent, LoadingComponent, ResourceHandler } from "./pipeline";
|
|
37
37
|
import { findClientEntry, bundleClientToFile, CLIENT_SCRIPT_URL } from "./client-bundle";
|
|
38
|
-
import {
|
|
38
|
+
import { generateIslandRegistry } from "./island-registry";
|
|
39
|
+
import { buildRsc, findRscRoutes } from "./rsc-build";
|
|
40
|
+
import { cssTargets, findGlobalCss, globalCssUsesTailwind, minifyCss, processCss, STYLES_URL } from "./css";
|
|
39
41
|
import { buildModuleCss, rolldownCssModulesPlugin, registerCssModules } from "./css-modules";
|
|
40
42
|
|
|
41
43
|
export type BuildResult = {
|
|
@@ -62,6 +64,12 @@ const ROUTE_EXTS = [".tsx", ".jsx", ".ts", ".js"];
|
|
|
62
64
|
|
|
63
65
|
const isRouteGroup = (name: string) => /^\(.+\)$/.test(name);
|
|
64
66
|
|
|
67
|
+
// Bun built-ins (`bun`, `bun:sqlite`, …) exist only at the Bun runtime and must never enter the
|
|
68
|
+
// workerd graph. Marking them external keeps rolldown from constant-folding the `const x = "bun";
|
|
69
|
+
// import(x)` runtime guard (in @junejs/core's cache.ts) and warning UNRESOLVED_IMPORT. Exported so
|
|
70
|
+
// the build keeps externalizing them — see test/build-externals.test.ts.
|
|
71
|
+
export const isBunSpecifier = (id: string): boolean => id === "bun" || id.startsWith("bun:");
|
|
72
|
+
|
|
65
73
|
function segmentFile(dir: string, base: string): string | undefined {
|
|
66
74
|
return ROUTE_EXTS.map((e) => join(dir, `${base}${e}`)).find(existsSync);
|
|
67
75
|
}
|
|
@@ -142,7 +150,9 @@ export async function freezeConfig(appRoot: string): Promise<{
|
|
|
142
150
|
speculationRules: resolveSpeculationRules(cfg.speculation ?? undefined),
|
|
143
151
|
speculationDelivery: "inline",
|
|
144
152
|
viewTransitions: cfg.viewTransitions ?? true,
|
|
145
|
-
|
|
153
|
+
// Default the baseline reset OFF when the app uses Tailwind (its Preflight is the reset).
|
|
154
|
+
cssReset: cfg.cssReset ?? !globalCssUsesTailwind(join(appRoot, "app")),
|
|
155
|
+
clientRouter: resolveClientRouter(cfg.clientRouter),
|
|
146
156
|
clientScript: hasClient ? CLIENT_SCRIPT_URL : null,
|
|
147
157
|
styles: hasCss ? STYLES_URL : null,
|
|
148
158
|
},
|
|
@@ -337,10 +347,19 @@ export async function juneBuild(
|
|
|
337
347
|
const clientEntry = findClientEntry(appDir);
|
|
338
348
|
let clientAsset: string | null = null;
|
|
339
349
|
if (clientEntry) {
|
|
350
|
+
// Regenerate the auto lazy island registry before bundling (same as dev).
|
|
351
|
+
generateIslandRegistry(appDir);
|
|
340
352
|
clientAsset = await bundleClientToFile(clientEntry, appRoot, assetsDir, cssModuleMaps);
|
|
341
353
|
frozen.document.clientScript = `/${clientAsset}`;
|
|
342
354
|
}
|
|
343
355
|
|
|
356
|
+
// Opt-in PER-ROUTE RSC build (page.rsc.tsx routes): emit the server + SSR-worker
|
|
357
|
+
// graphs under <outDir>/rsc/. Gated on RSC routes existing, so apps without any
|
|
358
|
+
// are byte-identical to before. Coexists with the SSR pipeline via a dispatcher.
|
|
359
|
+
if (findRscRoutes(appDir).length > 0) {
|
|
360
|
+
await buildRsc(appRoot, outDir, frozen.document);
|
|
361
|
+
}
|
|
362
|
+
|
|
344
363
|
// Declared resources become two things: a build-time plan (→ platform bindings
|
|
345
364
|
// the adapter emits) and a runtime provider wired into the generated entry.
|
|
346
365
|
// A resource-less app imports no config and emits no bindings, so its output
|
|
@@ -516,10 +535,8 @@ ${adapterEntry.wrap("pipeline")}
|
|
|
516
535
|
transform: { define: { "process.env.NODE_ENV": JSON.stringify("production") } },
|
|
517
536
|
plugins: [rolldownCssModulesPlugin(cssModuleMaps)], // .module.css → scoped class map
|
|
518
537
|
external: (id: string) => {
|
|
519
|
-
// Bun built-ins
|
|
520
|
-
|
|
521
|
-
// `const x = "bun"; import(x)` guard and tries to resolve "bun" before falling back to external).
|
|
522
|
-
if (id === "bun" || id.startsWith("bun:")) return true;
|
|
538
|
+
// Bun built-ins exist only at Bun runtime, never in the workerd graph (see isBunSpecifier).
|
|
539
|
+
if (isBunSpecifier(id)) return true;
|
|
523
540
|
// Binary assets stay external — wrangler's CompiledWasm/Data rules own them.
|
|
524
541
|
if (/\.(wasm|ttf|otf|woff2?|png|jpe?g|avif|webp)$/.test(id)) return true;
|
|
525
542
|
// config build.external: packages wrangler must bundle itself (its own
|
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
|
}
|
package/src/content.ts
CHANGED
|
@@ -79,7 +79,10 @@ function loadEntry(file: string, slug: string, locale?: string): ContentEntry {
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
const isMd = (f: string) => f.endsWith(".md") || f.endsWith(".mdx");
|
|
82
|
-
|
|
82
|
+
// Strip the extension, then collapse a directory index file to the directory itself, so it owns the
|
|
83
|
+
// folder's URL (Next.js / GitBook / Fumadocs convention): `a/b/index.md` → `a/b`, `a/b/README.md` →
|
|
84
|
+
// `a/b`, and a root `index`/`README` → "". A folder with an index becomes a page (not a buried child).
|
|
85
|
+
const slugOf = (f: string) => f.replace(/\.(md|mdx)$/, "").replace(/(^|\/)(index|README)$/i, "");
|
|
83
86
|
const byDate = (a: ContentEntry, b: ContentEntry) =>
|
|
84
87
|
String(b.data.date ?? "").localeCompare(String(a.data.date ?? "")) ||
|
|
85
88
|
a.slug.localeCompare(b.slug);
|
|
@@ -104,25 +107,44 @@ function isLocaleDir(name: string, known?: readonly string[]): boolean {
|
|
|
104
107
|
return known ? known.includes(name) : LOCALE_DIR.test(name);
|
|
105
108
|
}
|
|
106
109
|
|
|
110
|
+
// Recursively collect markdown under `root`; slug = the path relative to `root`, POSIX, no extension
|
|
111
|
+
// (`guides/install.md` → `guides/install`) so routes and the sidebar can nest. `skipTopLocales` skips
|
|
112
|
+
// locale buckets at the TOP level only (scanned separately) — so a top-level `ja-JP/` is a locale, but
|
|
113
|
+
// a nested `guides/` (or `guides/ja-JP/`) is plain content.
|
|
114
|
+
function collectMd(
|
|
115
|
+
root: string,
|
|
116
|
+
knownLocales: readonly string[] | undefined,
|
|
117
|
+
skipTopLocales: boolean,
|
|
118
|
+
sub = "",
|
|
119
|
+
): { file: string; slug: string }[] {
|
|
120
|
+
const out: { file: string; slug: string }[] = [];
|
|
121
|
+
for (const ent of readdirSync(join(root, sub), { withFileTypes: true })) {
|
|
122
|
+
const rel = sub ? `${sub}/${ent.name}` : ent.name;
|
|
123
|
+
if (ent.isDirectory()) {
|
|
124
|
+
if (skipTopLocales && sub === "" && isLocaleDir(ent.name, knownLocales)) continue;
|
|
125
|
+
out.push(...collectMd(root, knownLocales, skipTopLocales, rel));
|
|
126
|
+
} else if (isMd(ent.name)) {
|
|
127
|
+
out.push({ file: join(root, rel), slug: slugOf(rel) });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
107
133
|
export function scanCollection(dir: string, knownLocales?: readonly string[]): ScannedCollection {
|
|
108
|
-
|
|
134
|
+
// Default-locale entries: every .md under `dir` except inside top-level locale buckets. Flat files
|
|
135
|
+
// keep flat slugs (zero migration); subdirs become slug paths.
|
|
136
|
+
const def = collectMd(dir, knownLocales, true)
|
|
137
|
+
.map(({ file, slug }) => loadEntry(file, slug))
|
|
138
|
+
.sort(byDate);
|
|
109
139
|
const byLocale: Record<string, ContentEntry[]> = {};
|
|
110
140
|
for (const ent of readdirSync(dir, { withFileTypes: true })) {
|
|
111
|
-
if (ent.isDirectory()) {
|
|
112
|
-
// Only a configured locale (or, without a list, a BCP-47-shaped name) is a
|
|
113
|
-
// locale bucket — a stray content/<col>/images/ is NOT a phantom "images".
|
|
114
|
-
if (!isLocaleDir(ent.name, knownLocales)) continue;
|
|
141
|
+
if (ent.isDirectory() && isLocaleDir(ent.name, knownLocales)) {
|
|
115
142
|
const locale = ent.name;
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
.filter(isMd)
|
|
119
|
-
.map((f) => loadEntry(join(sub, f), slugOf(f), locale))
|
|
143
|
+
byLocale[locale] = collectMd(join(dir, locale), knownLocales, false)
|
|
144
|
+
.map(({ file, slug }) => loadEntry(file, slug, locale))
|
|
120
145
|
.sort(byDate);
|
|
121
|
-
} else if (isMd(ent.name)) {
|
|
122
|
-
def.push(loadEntry(join(dir, ent.name), slugOf(ent.name)));
|
|
123
146
|
}
|
|
124
147
|
}
|
|
125
|
-
def.sort(byDate);
|
|
126
148
|
return { default: def, byLocale };
|
|
127
149
|
}
|
|
128
150
|
|
|
@@ -158,8 +180,9 @@ export function entry(
|
|
|
158
180
|
locale?: string,
|
|
159
181
|
opts?: { fallback?: boolean },
|
|
160
182
|
): ContentEntry | null {
|
|
161
|
-
// Guard the slug — it comes from the URL.
|
|
162
|
-
|
|
183
|
+
// Guard the slug — it comes from the URL. Allow "/" so nested content (`guides/install`) resolves,
|
|
184
|
+
// but reject path traversal and absolute/empty segments so the slug can't escape `dir`.
|
|
185
|
+
if (!/^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/.test(slug) || slug.includes("..")) return null;
|
|
163
186
|
if (!locale) return probe(dir, slug);
|
|
164
187
|
const variant = probe(join(dir, locale), slug, locale);
|
|
165
188
|
if (variant) return variant;
|
|
@@ -207,6 +230,9 @@ export function generateContentModule(
|
|
|
207
230
|
body += `export const ${CONST}: ContentEntry[] = ${JSON.stringify(def.map(strip), null, 2)};\n`;
|
|
208
231
|
if (Object.keys(byLocale).length === 0) {
|
|
209
232
|
body += `export const ${finder} = (slug: string): ContentEntry | null => ${CONST}.find((p) => p.slug === slug) ?? null;\n`;
|
|
233
|
+
// Always export the locale lister (a no-op for single-locale collections) so consumers can
|
|
234
|
+
// import `${d.name}` unconditionally — adding/removing locales never changes the module's shape.
|
|
235
|
+
body += `export const ${d.name} = (_locale?: string): ContentEntry[] => ${CONST};\n`;
|
|
210
236
|
} else {
|
|
211
237
|
anyLocale = true;
|
|
212
238
|
const map: Record<string, Record<string, unknown>> = {};
|
package/src/css.ts
CHANGED
|
@@ -4,15 +4,16 @@
|
|
|
4
4
|
// (served immutable). Only the asset HREF diverges dev↔built, never the rendered
|
|
5
5
|
// structure — so dev/built parity (the agent surface) holds.
|
|
6
6
|
//
|
|
7
|
-
// Floor: plain CSS, zero deps
|
|
8
|
-
// (`@import "tailwindcss"`), June
|
|
9
|
-
//
|
|
10
|
-
//
|
|
7
|
+
// Floor: plain CSS, zero deps — Lightning CSS (the SAME Rust engine Tailwind v4 uses) handles
|
|
8
|
+
// nesting/prefix/minify. Blessed: if global.css opts into Tailwind (`@import "tailwindcss"`), June
|
|
9
|
+
// compiles it via the app's own Tailwind v4 ENGINE DIRECTLY — @tailwindcss/node + the @tailwindcss/oxide
|
|
10
|
+
// Rust scanner (the first-party Vite-plugin path, not PostCSS) — resolved from the app, napi-portable
|
|
11
|
+
// across Bun and Node. CSS never touches the agent projections (.md/.json/mcp).
|
|
11
12
|
|
|
12
|
-
import { existsSync } from "node:fs";
|
|
13
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
13
14
|
import { readFile } from "node:fs/promises";
|
|
14
15
|
import { createRequire } from "node:module";
|
|
15
|
-
import { join } from "node:path";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
16
17
|
import { pathToFileURL } from "node:url";
|
|
17
18
|
|
|
18
19
|
import type { Targets } from "lightningcss"; // type-only — never enters the graph
|
|
@@ -31,6 +32,18 @@ export function findGlobalCss(appDir: string): string | null {
|
|
|
31
32
|
|
|
32
33
|
const usesTailwind = (css: string) => /@import\s+["']tailwindcss["']|@tailwind\b/.test(css);
|
|
33
34
|
|
|
35
|
+
// True when app/global.css opts into Tailwind. Sync (build-time, tiny file) so the document config —
|
|
36
|
+
// built synchronously in dev (app.ts) and the build (build.ts) — can default `cssReset` off when
|
|
37
|
+
// Tailwind is present: its Preflight IS the reset, so June need not add its own baseline reset.
|
|
38
|
+
export function globalCssUsesTailwind(appDir: string): boolean {
|
|
39
|
+
const p = join(appDir, GLOBAL_CSS);
|
|
40
|
+
try {
|
|
41
|
+
return existsSync(p) && usesTailwind(readFileSync(p, "utf8"));
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
34
47
|
// Resolve a tool from the APP's node_modules (Tailwind/postcss are the app's
|
|
35
48
|
// deps, not June's). Returns null when absent.
|
|
36
49
|
function resolveFromApp(specifier: string, appDir: string): string | null {
|
|
@@ -67,23 +80,40 @@ export async function cssTargets(appDir: string): Promise<Targets> {
|
|
|
67
80
|
return targets;
|
|
68
81
|
}
|
|
69
82
|
|
|
83
|
+
// Minimal type surface of the bits of Tailwind v4's engine we drive directly.
|
|
84
|
+
type TwSource = { base: string; pattern: string; negated: boolean };
|
|
85
|
+
type TwCompiler = { build(candidates: string[]): string; sources: TwSource[]; features: number };
|
|
86
|
+
type TwNode = {
|
|
87
|
+
compile(css: string, opts: { base: string; onDependency: (p: string) => void }): Promise<TwCompiler>;
|
|
88
|
+
optimize(css: string, opts: { minify: boolean }): { code: string };
|
|
89
|
+
Features: { Utilities: number };
|
|
90
|
+
};
|
|
91
|
+
type TwOxide = { Scanner: new (o: { sources: TwSource[] }) => { scan(): string[] } };
|
|
92
|
+
|
|
93
|
+
// Compile Tailwind v4 via its programmatic engine — @tailwindcss/node (compile + Lightning CSS optimize)
|
|
94
|
+
// + @tailwindcss/oxide (the Rust content Scanner) — the SAME direct path the first-party Vite plugin
|
|
95
|
+
// uses, NOT PostCSS. No postcss dependency or extra parse pass; the Oxide scanner (Rust) finds class
|
|
96
|
+
// candidates and the compiler emits only those (tree-shaken). Engine resolved from the APP (its dep,
|
|
97
|
+
// not June's); napi works identically on Bun and Node. Returns null when Tailwind isn't installed.
|
|
70
98
|
async function compileTailwind(
|
|
71
99
|
css: string,
|
|
72
100
|
file: string,
|
|
73
101
|
appDir: string,
|
|
74
102
|
minify: boolean,
|
|
75
103
|
): Promise<string | null> {
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
if (!
|
|
79
|
-
const
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
//
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
|
|
104
|
+
const nodePath = resolveFromApp("@tailwindcss/node", appDir);
|
|
105
|
+
const oxidePath = resolveFromApp("@tailwindcss/oxide", appDir);
|
|
106
|
+
if (!nodePath || !oxidePath) return null;
|
|
107
|
+
const { compile, optimize, Features } = (await import(nodePath)) as TwNode;
|
|
108
|
+
const { Scanner } = (await import(oxidePath)) as TwOxide;
|
|
109
|
+
const base = dirname(file); // the CSS's dir — resolves @import / @source / auto-detection
|
|
110
|
+
const compiler = await compile(css, { base, onDependency: () => {} });
|
|
111
|
+
// Scan the project (app root) plus whatever the compiler declares; Oxide skips node_modules/.gitignore.
|
|
112
|
+
const sources: TwSource[] = [{ base: dirname(appDir), pattern: "**/*", negated: false }, ...compiler.sources];
|
|
113
|
+
const candidates = compiler.features & Features.Utilities ? new Scanner({ sources }).scan() : [];
|
|
114
|
+
const out = compiler.build(candidates);
|
|
115
|
+
// Lightning CSS optimize for build (minify + prefix/lower); dev stays readable.
|
|
116
|
+
return minify ? optimize(out, { minify: true }).code : out;
|
|
87
117
|
}
|
|
88
118
|
|
|
89
119
|
// Dev-only memo. The dev server re-fetches /global.css on EVERY navigation, but
|
|
@@ -138,7 +168,7 @@ export async function processCss(
|
|
|
138
168
|
});
|
|
139
169
|
if (compiled !== null) return compiled; // Tailwind already minified when asked
|
|
140
170
|
console.warn(
|
|
141
|
-
"[june] global.css uses Tailwind but @tailwindcss/
|
|
171
|
+
"[june] global.css uses Tailwind but @tailwindcss/node + @tailwindcss/oxide aren't installed — serving raw CSS.",
|
|
142
172
|
);
|
|
143
173
|
}
|
|
144
174
|
// Plain CSS (or the Tailwind fallback): minify + autoprefix/lower for the app's
|
package/src/dev-reload.ts
CHANGED
|
@@ -38,10 +38,13 @@ const RELOAD_JS = `// june dev live-reload: reconnect-after-drop → reload; "cs
|
|
|
38
38
|
es.addEventListener("open", () => {
|
|
39
39
|
if (dropped) {
|
|
40
40
|
// push-HMR: morph the freshly-restarted page in place (state survives);
|
|
41
|
-
// hard-reload only if there's no morph hook or it can't apply.
|
|
41
|
+
// hard-reload only if there's no morph hook or it can't apply. On a
|
|
42
|
+
// successful morph also swap the stylesheet: a .tsx edit can change the
|
|
43
|
+
// class set, so the Tailwind-compiled /_june/global.css must refresh too
|
|
44
|
+
// (cache-busted) — otherwise new utility classes render unstyled.
|
|
42
45
|
const hot = window.__juneLiveReload;
|
|
43
46
|
(hot ? hot() : Promise.resolve(false)).then(
|
|
44
|
-
(ok) => { if (
|
|
47
|
+
(ok) => { if (ok) swapCss(); else location.reload(); },
|
|
45
48
|
() => location.reload(),
|
|
46
49
|
);
|
|
47
50
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Auto-generate the island registry by scanning "use client" island() modules,
|
|
2
|
+
// so the author never hand-writes the `{ name: () => import("./Island") }` map.
|
|
3
|
+
//
|
|
4
|
+
// Scans app/** for modules that (a) start with the "use client" directive AND
|
|
5
|
+
// (b) use `island()` from @junejs/core/islands, and writes app/_islands.gen.ts:
|
|
6
|
+
//
|
|
7
|
+
// export const ISLAND_LOADERS = {
|
|
8
|
+
// "Counter": () => import("./poc/Counter"),
|
|
9
|
+
// "Tabs": () => import("./poc/Tabs"),
|
|
10
|
+
// };
|
|
11
|
+
//
|
|
12
|
+
// The KEY is the module's EXPORT name; importing the module runs its `island()`
|
|
13
|
+
// call, which self-registers the component under its runtime name. Those must
|
|
14
|
+
// agree — the marker the server stamps (the island's name) is what the client
|
|
15
|
+
// looks up. So the convention is: an island's name == its export name (the React
|
|
16
|
+
// default — `export const Counter = island(function Counter() {…})` satisfies it
|
|
17
|
+
// for free; otherwise pass `island(C, { name })`).
|
|
18
|
+
//
|
|
19
|
+
// Over-inclusion is harmless: a loader whose name no marker references is never
|
|
20
|
+
// called, so no chunk for an unused export is ever fetched.
|
|
21
|
+
//
|
|
22
|
+
// Host-coupled (node:fs), so it lives in @junejs/server, never the contract layer.
|
|
23
|
+
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { join, relative } from "node:path";
|
|
25
|
+
|
|
26
|
+
export const ISLAND_REGISTRY_FILE = "_islands.gen.ts";
|
|
27
|
+
|
|
28
|
+
// Shared with rsc-manifest.ts (the RSC client-reference codegen).
|
|
29
|
+
export function walk(dir: string): string[] {
|
|
30
|
+
return readdirSync(dir).flatMap((name) => {
|
|
31
|
+
const p = join(dir, name);
|
|
32
|
+
return statSync(p).isDirectory() ? walk(p) : [p];
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// True iff the first real statement (skipping blanks + line comments) is the
|
|
37
|
+
// directive — the same rule the bundler uses to treat a module as client.
|
|
38
|
+
export function firstStatementIsDirective(src: string, directive: string): boolean {
|
|
39
|
+
for (const line of src.split("\n")) {
|
|
40
|
+
const t = line.trim();
|
|
41
|
+
if (!t || t.startsWith("//")) continue;
|
|
42
|
+
return t.startsWith(`"${directive}"`) || t.startsWith(`'${directive}'`);
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The named exports of a module (skipping `export default` and `export type`).
|
|
48
|
+
export function exportNames(src: string): string[] {
|
|
49
|
+
const names: string[] = [];
|
|
50
|
+
for (const line of src.split("\n")) {
|
|
51
|
+
const t = line.trim();
|
|
52
|
+
const decl = t.match(/^export\s+(?:async\s+)?(?:function|const|let|var|class)\s+([A-Za-z0-9_$]+)/);
|
|
53
|
+
if (decl) names.push(decl[1]!);
|
|
54
|
+
const named = t.match(/^export\s*\{([^}]*)\}/); // `export type { … }` won't match (the `type` breaks it)
|
|
55
|
+
if (named) {
|
|
56
|
+
for (const part of named[1]!.split(",")) {
|
|
57
|
+
const e = part.split(" as ").pop()?.trim();
|
|
58
|
+
if (e) names.push(e);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return names;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Write app/_islands.gen.ts. Returns the number of island loaders emitted.
|
|
66
|
+
export function generateIslandRegistry(appDir: string): number {
|
|
67
|
+
const mods = walk(appDir)
|
|
68
|
+
.filter((f) => /\.(tsx|ts|jsx)$/.test(f) && !f.endsWith(ISLAND_REGISTRY_FILE))
|
|
69
|
+
.map((f) => ({ f, src: readFileSync(f, "utf8") }))
|
|
70
|
+
.filter(
|
|
71
|
+
({ src }) =>
|
|
72
|
+
firstStatementIsDirective(src, "use client") &&
|
|
73
|
+
/@junejs\/core\/islands/.test(src) &&
|
|
74
|
+
/\bisland\s*\(/.test(src),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const entries: string[] = [];
|
|
78
|
+
for (const { f, src } of mods) {
|
|
79
|
+
const rel = "./" + relative(appDir, f).replace(/\.(tsx|ts|jsx)$/, "");
|
|
80
|
+
for (const name of exportNames(src)) {
|
|
81
|
+
if (name === "default") continue; // islands are named exports
|
|
82
|
+
entries.push(` ${JSON.stringify(name)}: () => import(${JSON.stringify(rel)}),`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const body = entries.length ? entries.join("\n") + "\n" : "";
|
|
87
|
+
const out =
|
|
88
|
+
"// AUTO-GENERATED by June — do not edit. Lazy loaders for island() modules.\n" +
|
|
89
|
+
"// Maps each island name to a lazy chunk loader; hydrateIslandsLazy consumes it.\n" +
|
|
90
|
+
"export const ISLAND_LOADERS: Record<string, () => Promise<unknown>> = {\n" +
|
|
91
|
+
body +
|
|
92
|
+
"};\n";
|
|
93
|
+
writeFileSync(join(appDir, ISLAND_REGISTRY_FILE), out);
|
|
94
|
+
return entries.length;
|
|
95
|
+
}
|
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
|
+
}
|