@junejs/server 0.0.22 → 0.0.24
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 +3 -3
- package/src/app.ts +34 -13
- package/src/build.ts +77 -15
- package/src/dev-reload.ts +7 -1
- package/src/index.ts +15 -0
- package/src/negotiate.ts +9 -4
- package/src/pipeline.ts +122 -14
- package/src/segment.ts +60 -0
- package/src/worker.ts +33 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@junejs/server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.24",
|
|
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,8 +30,8 @@
|
|
|
30
30
|
"typecheck": "tsc --noEmit"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@junejs/core": "0.0.
|
|
34
|
-
"@junejs/db": "0.0.
|
|
33
|
+
"@junejs/core": "0.0.24",
|
|
34
|
+
"@junejs/db": "0.0.24",
|
|
35
35
|
"marked": "^18.0.5",
|
|
36
36
|
"rolldown": "^1.1.0",
|
|
37
37
|
"lightningcss": "^1.27.0"
|
package/src/app.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { runWithTrace, type RequestTrace } from "@junejs/core/instrumentation";
|
|
|
20
20
|
|
|
21
21
|
import { findMiddlewareFile, isResourceFile, listRoutes, matchRouteTree, resolveNotFound, routeFiles, type SegmentMatch } from "./router";
|
|
22
22
|
import { createPipeline, type ExtraHandler, type LayoutComponent, type Pipeline, type Resolved, type ResourceHandler } from "./pipeline";
|
|
23
|
+
import { resolveBoundary } from "./segment";
|
|
23
24
|
import { memoizeResources } from "./resources";
|
|
24
25
|
import { findClientEntry, bundleClientToString, CLIENT_SCRIPT_URL } from "./client-bundle";
|
|
25
26
|
import { findGlobalCss, processCssCached, STYLES_URL } from "./css";
|
|
@@ -39,15 +40,25 @@ export type JuneApp = {
|
|
|
39
40
|
earlyHints(): string[];
|
|
40
41
|
};
|
|
41
42
|
|
|
42
|
-
// Import a layout file's default export as a layout component (memoized)
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
// Import a layout file's default export as a layout component (memoized), plus
|
|
44
|
+
// its static `segmentBoundary` flag — read from the module export WITHOUT
|
|
45
|
+
// rendering the component, which is what lets the server slice the chain for a
|
|
46
|
+
// segment-scoped fragment without paying the shell's render cost.
|
|
47
|
+
type LoadedLayout = { component: LayoutComponent; boundary: boolean };
|
|
48
|
+
const layoutCache = new Map<string, LoadedLayout | null>();
|
|
49
|
+
async function loadLayout(file: string): Promise<LoadedLayout | null> {
|
|
45
50
|
const cached = layoutCache.get(file);
|
|
46
|
-
if (cached) return cached;
|
|
47
|
-
const mod = (await import(pathToFileURL(file).href)) as {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
if (cached !== undefined) return cached;
|
|
52
|
+
const mod = (await import(pathToFileURL(file).href)) as {
|
|
53
|
+
default?: LayoutComponent;
|
|
54
|
+
segmentBoundary?: unknown;
|
|
55
|
+
};
|
|
56
|
+
const result: LoadedLayout | null =
|
|
57
|
+
typeof mod.default === "function"
|
|
58
|
+
? { component: mod.default, boundary: mod.segmentBoundary === true }
|
|
59
|
+
: null;
|
|
60
|
+
layoutCache.set(file, result);
|
|
61
|
+
return result;
|
|
51
62
|
}
|
|
52
63
|
|
|
53
64
|
// The nearest loading.tsx up the segment chain (deepest wins) → the streaming
|
|
@@ -64,14 +75,20 @@ async function nearestLoading(segments: SegmentMatch[]): Promise<React.Component
|
|
|
64
75
|
return mod.default;
|
|
65
76
|
}
|
|
66
77
|
|
|
67
|
-
|
|
68
|
-
|
|
78
|
+
// The layout chain (root→leaf) plus the segment boundary, via the shared
|
|
79
|
+
// resolver (the one place the deepest-wins rule + shell key live, so dev and the
|
|
80
|
+
// frozen manifest can't drift).
|
|
81
|
+
async function loadChain(
|
|
82
|
+
segments: SegmentMatch[],
|
|
83
|
+
): Promise<{ chain: LayoutComponent[]; boundaryIndex: number | null; boundaryKey: string | null }> {
|
|
84
|
+
const items = [];
|
|
69
85
|
for (const seg of segments) {
|
|
70
86
|
if (!seg.layout) continue;
|
|
71
87
|
const L = await loadLayout(seg.layout);
|
|
72
|
-
|
|
88
|
+
items.push({ file: seg.layout, entry: L?.component ?? null, boundary: !!L?.boundary });
|
|
73
89
|
}
|
|
74
|
-
|
|
90
|
+
const { chain, boundaryIndex, key } = resolveBoundary(items);
|
|
91
|
+
return { chain, boundaryIndex, boundaryKey: key };
|
|
75
92
|
}
|
|
76
93
|
|
|
77
94
|
export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions): JuneApp {
|
|
@@ -149,6 +166,7 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
|
|
|
149
166
|
extra,
|
|
150
167
|
docConfig,
|
|
151
168
|
agent,
|
|
169
|
+
i18n: config.i18n,
|
|
152
170
|
routeList: routePaths,
|
|
153
171
|
earlyHints: config.earlyHints,
|
|
154
172
|
resources,
|
|
@@ -165,10 +183,13 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
|
|
|
165
183
|
}
|
|
166
184
|
const def = routeFromModule(mod);
|
|
167
185
|
if (!def) return null;
|
|
186
|
+
const { chain, boundaryIndex, boundaryKey } = await loadChain(match.segments);
|
|
168
187
|
return {
|
|
169
188
|
def,
|
|
170
189
|
params: match.params,
|
|
171
|
-
chain
|
|
190
|
+
chain,
|
|
191
|
+
boundaryIndex,
|
|
192
|
+
boundaryKey,
|
|
172
193
|
loading: await nearestLoading(match.segments),
|
|
173
194
|
} satisfies Resolved;
|
|
174
195
|
},
|
package/src/build.ts
CHANGED
|
@@ -32,6 +32,7 @@ import type { DocumentConfig } from "@junejs/core/document";
|
|
|
32
32
|
import { collection } from "./content";
|
|
33
33
|
import { createWorker, type WorkerManifest } from "./worker";
|
|
34
34
|
import { findMiddlewareFile } from "./router";
|
|
35
|
+
import { resolveBoundary } from "./segment";
|
|
35
36
|
import type { ExtraHandler, LayoutComponent, LoadingComponent, ResourceHandler } from "./pipeline";
|
|
36
37
|
import { findClientEntry, bundleClientToFile, CLIENT_SCRIPT_URL } from "./client-bundle";
|
|
37
38
|
import { cssTargets, findGlobalCss, minifyCss, processCss, STYLES_URL } from "./css";
|
|
@@ -142,6 +143,7 @@ export async function generateContent(appRoot: string): Promise<string[]> {
|
|
|
142
143
|
export async function freezeConfig(appRoot: string): Promise<{
|
|
143
144
|
document: DocumentConfig;
|
|
144
145
|
agent: WorkerManifest["agent"];
|
|
146
|
+
i18n: WorkerManifest["i18n"];
|
|
145
147
|
earlyHints: string[];
|
|
146
148
|
buildExternal: string[];
|
|
147
149
|
}> {
|
|
@@ -163,14 +165,24 @@ export async function freezeConfig(appRoot: string): Promise<{
|
|
|
163
165
|
styles: hasCss ? STYLES_URL : null,
|
|
164
166
|
},
|
|
165
167
|
agent: resolveAgent(cfg.agent),
|
|
168
|
+
// Pass i18n through as-is: the in-process buildManifest keeps a resolveLocale
|
|
169
|
+
// hook (parity test), and the codegen JSON.stringify below drops the function
|
|
170
|
+
// (worker hook support is the codegen pass — see the manifest field comment).
|
|
171
|
+
i18n: cfg.i18n,
|
|
166
172
|
earlyHints: cfg.earlyHints ?? [],
|
|
167
173
|
buildExternal: cfg.build?.external ?? [],
|
|
168
174
|
};
|
|
169
175
|
}
|
|
170
176
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
177
|
+
type ImportedLayout = { component: LayoutComponent; boundary: boolean };
|
|
178
|
+
async function importLayout(file: string): Promise<ImportedLayout | null> {
|
|
179
|
+
const mod = (await import(pathToFileURL(file).href)) as {
|
|
180
|
+
default?: LayoutComponent;
|
|
181
|
+
segmentBoundary?: unknown;
|
|
182
|
+
};
|
|
183
|
+
return typeof mod.default === "function"
|
|
184
|
+
? { component: mod.default, boundary: mod.segmentBoundary === true }
|
|
185
|
+
: null;
|
|
174
186
|
}
|
|
175
187
|
|
|
176
188
|
// The FREEZE, in-process: import route modules + layouts, build the manifest a
|
|
@@ -181,21 +193,32 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
|
|
|
181
193
|
const frozen = await freezeConfig(appRoot);
|
|
182
194
|
const scanned = (await scanRoutes(appDir)).sort((a, b) => a.path.localeCompare(b.path));
|
|
183
195
|
|
|
184
|
-
const layoutCache = new Map<string,
|
|
185
|
-
const
|
|
186
|
-
|
|
196
|
+
const layoutCache = new Map<string, ImportedLayout | null>();
|
|
197
|
+
const loadCached = async (f: string): Promise<ImportedLayout | null> => {
|
|
198
|
+
if (!layoutCache.has(f)) layoutCache.set(f, await importLayout(f));
|
|
199
|
+
return layoutCache.get(f) ?? null;
|
|
200
|
+
};
|
|
201
|
+
// The chain (root→leaf) + boundary index + shell key, via the SHARED resolver
|
|
202
|
+
// (the one place the deepest-wins rule lives), so the frozen manifest and the
|
|
203
|
+
// dev resolver can't drift — the parity contract.
|
|
204
|
+
const componentsFor = async (
|
|
205
|
+
files: string[],
|
|
206
|
+
): Promise<{ chain: LayoutComponent[]; boundaryIndex: number | null; key: string | null }> => {
|
|
207
|
+
const items = [];
|
|
187
208
|
for (const f of files) {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if (c) chain.push(c);
|
|
209
|
+
const c = await loadCached(f);
|
|
210
|
+
items.push({ file: f, entry: c?.component ?? null, boundary: !!c?.boundary });
|
|
191
211
|
}
|
|
192
|
-
return
|
|
212
|
+
return resolveBoundary(items);
|
|
193
213
|
};
|
|
194
214
|
|
|
195
215
|
const loadingCache = new Map<string, LoadingComponent | null>();
|
|
196
216
|
const loadingFor = async (file?: string): Promise<LoadingComponent | undefined> => {
|
|
197
217
|
if (!file) return undefined;
|
|
198
|
-
if (!loadingCache.has(file))
|
|
218
|
+
if (!loadingCache.has(file)) {
|
|
219
|
+
const loaded = await importLayout(file);
|
|
220
|
+
loadingCache.set(file, loaded ? (loaded.component as LoadingComponent) : null);
|
|
221
|
+
}
|
|
199
222
|
return loadingCache.get(file) ?? undefined;
|
|
200
223
|
};
|
|
201
224
|
|
|
@@ -203,6 +226,7 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
|
|
|
203
226
|
const dynamicRoutes: Array<{ pattern: string; def: BrandedRoute }> = [];
|
|
204
227
|
const resourceRoutes: Array<{ pattern: string; handler: ResourceHandler }> = [];
|
|
205
228
|
const layoutChains: Record<string, LayoutComponent[]> = {};
|
|
229
|
+
const layoutBoundaries: Record<string, { index: number; key: string }> = {};
|
|
206
230
|
const loadings: Record<string, LoadingComponent> = {};
|
|
207
231
|
|
|
208
232
|
for (const r of scanned) {
|
|
@@ -215,7 +239,7 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
|
|
|
215
239
|
}
|
|
216
240
|
const def = routeFromModule(mod);
|
|
217
241
|
if (!def) continue;
|
|
218
|
-
const chain = await componentsFor(r.layouts);
|
|
242
|
+
const { chain, boundaryIndex, key } = await componentsFor(r.layouts);
|
|
219
243
|
const loading = await loadingFor(r.loading);
|
|
220
244
|
if (r.dynamic) {
|
|
221
245
|
dynamicRoutes.push({ pattern: r.path, def });
|
|
@@ -224,6 +248,7 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
|
|
|
224
248
|
routes[r.path] = def;
|
|
225
249
|
layoutChains[r.path] = chain;
|
|
226
250
|
}
|
|
251
|
+
if (boundaryIndex !== null && key !== null) layoutBoundaries[r.path] = { index: boundaryIndex, key };
|
|
227
252
|
if (loading) loadings[r.path] = loading;
|
|
228
253
|
}
|
|
229
254
|
|
|
@@ -239,9 +264,11 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
|
|
|
239
264
|
dynamicRoutes,
|
|
240
265
|
resourceRoutes,
|
|
241
266
|
layoutChains,
|
|
267
|
+
layoutBoundaries,
|
|
242
268
|
loadings,
|
|
243
269
|
document: frozen.document,
|
|
244
270
|
agent: frozen.agent,
|
|
271
|
+
i18n: frozen.i18n,
|
|
245
272
|
earlyHints: frozen.earlyHints,
|
|
246
273
|
extra,
|
|
247
274
|
};
|
|
@@ -268,6 +295,15 @@ export async function juneBuild(
|
|
|
268
295
|
if (routes.length === 0) throw new Error(`no page.* routes found under ${appDir}`);
|
|
269
296
|
|
|
270
297
|
const frozen = await freezeConfig(appRoot);
|
|
298
|
+
// The locales table freezes into the worker as data; a resolveLocale hook is a
|
|
299
|
+
// function and won't survive JSON codegen. URL-pinned resolution + the built-in
|
|
300
|
+
// negotiation chain still work in the worker — only the hook is dev-only for now.
|
|
301
|
+
if (frozen.i18n?.resolveLocale) {
|
|
302
|
+
console.warn(
|
|
303
|
+
"[june build] i18n.resolveLocale is not yet wired into the built worker " +
|
|
304
|
+
"(URL-pinned + built-in negotiation work; the hook runs in dev only).",
|
|
305
|
+
);
|
|
306
|
+
}
|
|
271
307
|
// The deploy adapter packages the portable build for its target (default:
|
|
272
308
|
// built-in workers()). It contributes the entry's export wrapper + emits the
|
|
273
309
|
// deploy config.
|
|
@@ -360,7 +396,17 @@ export async function juneBuild(
|
|
|
360
396
|
}
|
|
361
397
|
return id;
|
|
362
398
|
};
|
|
399
|
+
// `segmentBoundary` is a STATIC export, so read each layout module here (no
|
|
400
|
+
// render) to know its boundary flag AND whether it loads at all — codegen then
|
|
401
|
+
// FILTERS null layouts and computes the boundary index/key through the SAME
|
|
402
|
+
// shared resolver the dev/manifest paths use, so all three agree by
|
|
403
|
+
// construction (not by a fragile "indices happen to line up" assumption).
|
|
404
|
+
const layoutInfo = new Map<string, ImportedLayout | null>();
|
|
405
|
+
for (const f of new Set(routes.flatMap((r) => (r.resource ? [] : r.layouts)))) {
|
|
406
|
+
layoutInfo.set(f, await importLayout(f));
|
|
407
|
+
}
|
|
363
408
|
const chains: string[] = [];
|
|
409
|
+
const boundaries: string[] = [];
|
|
364
410
|
const loadings: string[] = [];
|
|
365
411
|
const resources: string[] = [];
|
|
366
412
|
routes.forEach((r, i) => {
|
|
@@ -374,12 +420,28 @@ export async function juneBuild(
|
|
|
374
420
|
imports.push(`import * as r${i} from ${JSON.stringify(importPath(genDir, r.file))};`);
|
|
375
421
|
if (r.dynamic) dynamics.push(` { pattern: ${JSON.stringify(r.path)}, def: routeFromModule(r${i}) },`);
|
|
376
422
|
else statics.push(` ${JSON.stringify(r.path)}: routeFromModule(r${i}),`);
|
|
377
|
-
|
|
423
|
+
// entry = the emitted layout id (null layouts filtered out, so layoutId — and
|
|
424
|
+
// its import — is only emitted for real layouts), matching the runtime chain.
|
|
425
|
+
const { chain, boundaryIndex, key } = resolveBoundary(
|
|
426
|
+
r.layouts.map((f) => {
|
|
427
|
+
const info = layoutInfo.get(f) ?? null;
|
|
428
|
+
return { file: f, entry: info ? layoutId(f) : null, boundary: !!info?.boundary };
|
|
429
|
+
}),
|
|
430
|
+
);
|
|
431
|
+
chains.push(` ${JSON.stringify(r.path)}: [${chain.join(", ")}],`);
|
|
432
|
+
if (boundaryIndex !== null && key !== null) {
|
|
433
|
+
boundaries.push(` ${JSON.stringify(r.path)}: { index: ${boundaryIndex}, key: ${JSON.stringify(key)} },`);
|
|
434
|
+
}
|
|
378
435
|
if (r.loading) loadings.push(` ${JSON.stringify(r.path)}: ${loadingId(r.loading)},`);
|
|
379
436
|
});
|
|
380
437
|
const resourceRoutesField = resources.length
|
|
381
438
|
? `\n resourceRoutes: [\n${resources.join("\n")}\n ],`
|
|
382
439
|
: "";
|
|
440
|
+
// Only emitted when some route declares a boundary, so boundary-less bundles
|
|
441
|
+
// stay byte-identical (additive manifest field, like resources).
|
|
442
|
+
const layoutBoundariesField = boundaries.length
|
|
443
|
+
? `\n layoutBoundaries: {\n${boundaries.join("\n")}\n },`
|
|
444
|
+
: "";
|
|
383
445
|
|
|
384
446
|
const builtExtraFile = findMiddlewareFile(appDir);
|
|
385
447
|
if (builtExtraFile) {
|
|
@@ -444,12 +506,12 @@ ${dynamics.join("\n")}
|
|
|
444
506
|
],${resourceRoutesField}
|
|
445
507
|
layoutChains: {
|
|
446
508
|
${chains.join("\n")}
|
|
447
|
-
}
|
|
509
|
+
},${layoutBoundariesField}
|
|
448
510
|
loadings: {
|
|
449
511
|
${loadings.join("\n")}
|
|
450
512
|
},
|
|
451
513
|
document: ${JSON.stringify(frozen.document, null, 2).replace(/\n/g, "\n ")},
|
|
452
|
-
agent: ${JSON.stringify(frozen.agent)}
|
|
514
|
+
agent: ${JSON.stringify(frozen.agent)},${frozen.i18n ? `\n i18n: ${JSON.stringify(frozen.i18n)},` : ""}
|
|
453
515
|
earlyHints: ${JSON.stringify(frozen.earlyHints)},${builtExtraFile ? "\n extra," : ""}${resourcesField}
|
|
454
516
|
});
|
|
455
517
|
|
package/src/dev-reload.ts
CHANGED
|
@@ -18,6 +18,7 @@ const SCRIPT_PATH = "/__june/reload.js";
|
|
|
18
18
|
const RELOAD_JS = `// june dev live-reload: reconnect-after-drop → reload; "css" event → hot-swap
|
|
19
19
|
(() => {
|
|
20
20
|
let dropped = false;
|
|
21
|
+
let es;
|
|
21
22
|
const swapCss = () => {
|
|
22
23
|
document.querySelectorAll('link[rel="stylesheet"]').forEach((old) => {
|
|
23
24
|
const u = new URL(old.href);
|
|
@@ -33,7 +34,7 @@ const RELOAD_JS = `// june dev live-reload: reconnect-after-drop → reload; "cs
|
|
|
33
34
|
});
|
|
34
35
|
};
|
|
35
36
|
const connect = () => {
|
|
36
|
-
|
|
37
|
+
es = new EventSource(${JSON.stringify(EVENTS_PATH)});
|
|
37
38
|
es.addEventListener("open", () => {
|
|
38
39
|
if (dropped) {
|
|
39
40
|
// push-HMR: morph the freshly-restarted page in place (state survives);
|
|
@@ -58,6 +59,11 @@ const RELOAD_JS = `// june dev live-reload: reconnect-after-drop → reload; "cs
|
|
|
58
59
|
}
|
|
59
60
|
});
|
|
60
61
|
};
|
|
62
|
+
// Release the SSE on navigation. In MPA mode every click is a full reload that
|
|
63
|
+
// opens a fresh EventSource; without this the old connection lingers and, since
|
|
64
|
+
// each held SSE consumes one of the browser's ~6 HTTP/1.1 slots per host, rapid
|
|
65
|
+
// navigation saturates them — further page loads stall (pending) / 503.
|
|
66
|
+
addEventListener("pagehide", () => { try { es && es.close(); } catch (e) {} });
|
|
61
67
|
connect();
|
|
62
68
|
})();
|
|
63
69
|
`;
|
package/src/index.ts
CHANGED
|
@@ -50,6 +50,21 @@ export {
|
|
|
50
50
|
} from "./router";
|
|
51
51
|
export { installAsyncContext } from "./instrumentation";
|
|
52
52
|
export { negotiate, type Negotiated } from "./negotiate";
|
|
53
|
+
// Locale routing (Layer 1). The pure host-resolution layer lives in @junejs/core;
|
|
54
|
+
// re-exported here so apps build locale links with `localeHref` off the ONE handle.
|
|
55
|
+
export {
|
|
56
|
+
localeHref,
|
|
57
|
+
resolveRequestLocale,
|
|
58
|
+
matchPinnedLocale,
|
|
59
|
+
negotiateLocale,
|
|
60
|
+
parseAcceptLanguage,
|
|
61
|
+
localeDir,
|
|
62
|
+
dirForLang,
|
|
63
|
+
LOCALE_COOKIE,
|
|
64
|
+
type I18nConfig,
|
|
65
|
+
type LocaleConfig,
|
|
66
|
+
type LocaleMatch,
|
|
67
|
+
} from "@junejs/core/i18n";
|
|
53
68
|
export {
|
|
54
69
|
createPipeline,
|
|
55
70
|
type Pipeline,
|
package/src/negotiate.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type { RenderTarget } from "@junejs/core/route";
|
|
|
11
11
|
// The fragment media type + title header are the client-router wire protocol —
|
|
12
12
|
// defined once in @junejs/core so the browser router and this negotiator share
|
|
13
13
|
// the exact strings. Re-exported so existing server-side importers are unaffected.
|
|
14
|
-
export { FRAGMENT_ACCEPT, TITLE_HEADER } from "@junejs/core/nav-protocol";
|
|
14
|
+
export { FRAGMENT_ACCEPT, TITLE_HEADER, SEGMENT_HEADER } from "@junejs/core/nav-protocol";
|
|
15
15
|
|
|
16
16
|
const EXT_TARGET: Record<string, RenderTarget> = {
|
|
17
17
|
".json": "json",
|
|
@@ -31,8 +31,13 @@ export type Negotiated = {
|
|
|
31
31
|
speculative: boolean;
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
// `basePath` overrides the pathname to negotiate (defaults to url.pathname). The
|
|
35
|
+
// i18n step strips the locale prefix off the FRONT of url.pathname first and
|
|
36
|
+
// passes the remainder here, so extension/Accept negotiation runs on the route
|
|
37
|
+
// path, not the locale-prefixed one. url.pathname stays the raw request path.
|
|
38
|
+
export function negotiate(url: URL, request: Request, basePath?: string): Negotiated {
|
|
39
|
+
const original = basePath ?? url.pathname;
|
|
40
|
+
let pathname = original;
|
|
36
41
|
let target: RenderTarget | null = null;
|
|
37
42
|
|
|
38
43
|
for (const [ext, t] of Object.entries(EXT_TARGET)) {
|
|
@@ -53,7 +58,7 @@ export function negotiate(url: URL, request: Request): Negotiated {
|
|
|
53
58
|
// A bare projection on the root — `/.md`, `/.json` — is NOT a real URL (the
|
|
54
59
|
// home surface is `/index.md`). Stripping the extension collapses it to "/",
|
|
55
60
|
// so steer it back to the literal path: no route matches → 404.
|
|
56
|
-
pathname =
|
|
61
|
+
pathname = original;
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
if (!target) {
|
package/src/pipeline.ts
CHANGED
|
@@ -37,9 +37,29 @@ import { mcpHandler, mcpTools } from "@junejs/core/mcp";
|
|
|
37
37
|
|
|
38
38
|
import { ensureScope, runInScope } from "@junejs/db";
|
|
39
39
|
import type { AgentConfig } from "@junejs/core/config";
|
|
40
|
+
import {
|
|
41
|
+
LOCALE_COOKIE,
|
|
42
|
+
localeDir,
|
|
43
|
+
matchPinnedLocale,
|
|
44
|
+
negotiateLocale,
|
|
45
|
+
type I18nConfig,
|
|
46
|
+
} from "@junejs/core/i18n";
|
|
40
47
|
import type { Resources } from "@junejs/core/resources";
|
|
41
48
|
|
|
42
|
-
import { negotiate, TITLE_HEADER } from "./negotiate";
|
|
49
|
+
import { negotiate, TITLE_HEADER, SEGMENT_HEADER } from "./negotiate";
|
|
50
|
+
|
|
51
|
+
// Minimal Cookie-header read for the locale negotiation chain (no dependency on
|
|
52
|
+
// an auth/cookie integration — i18n must stand alone).
|
|
53
|
+
function readCookie(request: Request, name: string): string | undefined {
|
|
54
|
+
const header = request.headers.get("cookie");
|
|
55
|
+
if (!header) return undefined;
|
|
56
|
+
for (const part of header.split(";")) {
|
|
57
|
+
const eq = part.indexOf("=");
|
|
58
|
+
if (eq === -1) continue;
|
|
59
|
+
if (part.slice(0, eq).trim() === name) return decodeURIComponent(part.slice(eq + 1).trim());
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
43
63
|
|
|
44
64
|
export type LayoutComponent = React.ComponentType<{ children: React.ReactNode }>;
|
|
45
65
|
export type LoadingComponent = React.ComponentType;
|
|
@@ -54,6 +74,17 @@ export type Resolved = {
|
|
|
54
74
|
// into streaming Suspense: the shell + this fallback flush before load()
|
|
55
75
|
// resolves, then the view streams in.
|
|
56
76
|
loading?: React.ComponentType;
|
|
77
|
+
// Segment-scoped fragments: the index in `chain` of the boundary layout (the
|
|
78
|
+
// one exporting `segmentBoundary` and rendering <JuneOutlet>), deepest wins.
|
|
79
|
+
// A soft-nav fragment then renders only chain.slice(boundaryIndex + 1) — the
|
|
80
|
+
// boundary layout's own markup (the persistent shell) is NOT re-rendered.
|
|
81
|
+
// null/undefined → whole-chain fragment (the default). Full documents always
|
|
82
|
+
// render the whole chain regardless, so a hard load is unaffected.
|
|
83
|
+
boundaryIndex?: number | null;
|
|
84
|
+
// The shell's identity key (which boundary layout owns it). Stamped on
|
|
85
|
+
// [data-june-root] (full load) and sent as the SEGMENT_HEADER (fragment) so the
|
|
86
|
+
// client morphs a fragment only into the shell it belongs to. Set iff boundaryIndex is.
|
|
87
|
+
boundaryKey?: string | null;
|
|
57
88
|
};
|
|
58
89
|
|
|
59
90
|
// A RESOURCE route (app/**/route.*): a handler returning a raw Response — binary,
|
|
@@ -73,6 +104,9 @@ export type RouteResolver = (pathname: string) => Promise<Resolved | ResolvedRes
|
|
|
73
104
|
export type PipelineConfig = {
|
|
74
105
|
docConfig: DocumentConfig;
|
|
75
106
|
agent: AgentConfig;
|
|
107
|
+
// Locale routing config (june.config.ts `i18n`). Absent → no locale handling:
|
|
108
|
+
// the resolution step below never runs and ctx.locale stays undefined.
|
|
109
|
+
i18n?: I18nConfig;
|
|
76
110
|
// The route list for discovery surfaces (sitemap / llms.txt). Async so dev can
|
|
77
111
|
// re-scan the filesystem; the worker returns a frozen array.
|
|
78
112
|
routeList: () => Promise<string[]> | string[];
|
|
@@ -171,11 +205,22 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
171
205
|
return headers;
|
|
172
206
|
}
|
|
173
207
|
|
|
208
|
+
// The document language + writing direction for one render. lang = the resolved
|
|
209
|
+
// locale (ctx.locale) when i18n is on, else the site.lang floor, else "en"; dir
|
|
210
|
+
// derives from it (a LocaleConfig.dir override wins). LTR → dir undefined, so
|
|
211
|
+
// single-locale pages stay byte-identical.
|
|
212
|
+
function langDir(locale: string | undefined): { lang: string; dir?: "ltr" | "rtl" } {
|
|
213
|
+
const lang = locale ?? docConfig.site.lang ?? "en";
|
|
214
|
+
return { lang, dir: localeDir(lang, cfg.i18n) };
|
|
215
|
+
}
|
|
216
|
+
|
|
174
217
|
async function renderDocument(
|
|
175
218
|
node: React.ReactNode,
|
|
176
219
|
metadata: Metadata | undefined,
|
|
177
220
|
status: number,
|
|
178
221
|
chain: LayoutComponent[],
|
|
222
|
+
locale?: string,
|
|
223
|
+
boundaryKey?: string | null,
|
|
179
224
|
): Promise<Response> {
|
|
180
225
|
// Wrap root→leaf: chain[0] is outermost.
|
|
181
226
|
const wrapped = chain.reduceRight<React.ReactNode>(
|
|
@@ -183,7 +228,13 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
183
228
|
node,
|
|
184
229
|
);
|
|
185
230
|
const stream = await renderToReadableStream(
|
|
186
|
-
React.createElement(Document, {
|
|
231
|
+
React.createElement(Document, {
|
|
232
|
+
config: docConfigForRender(),
|
|
233
|
+
metadata,
|
|
234
|
+
children: wrapped,
|
|
235
|
+
shellKey: boundaryKey, // stamps data-june-shell on [data-june-root]
|
|
236
|
+
...langDir(locale),
|
|
237
|
+
}),
|
|
187
238
|
);
|
|
188
239
|
await stream.allReady; // fully resolved markup (no streamed Suspense fallbacks)
|
|
189
240
|
// React 19 emits <!DOCTYPE html> itself for an <html> root — don't prepend a
|
|
@@ -201,8 +252,20 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
201
252
|
node: React.ReactNode,
|
|
202
253
|
metadata: Metadata | undefined,
|
|
203
254
|
chain: LayoutComponent[],
|
|
255
|
+
boundaryIndex?: number | null,
|
|
256
|
+
boundaryKey?: string | null,
|
|
204
257
|
): Promise<Response> {
|
|
205
|
-
|
|
258
|
+
// ONE gate: a fragment is segment-scoped iff it has both a boundary index AND
|
|
259
|
+
// a shell key. They always travel together (resolveBoundary returns both or
|
|
260
|
+
// neither), but tying the content-slice and the header to a single flag means
|
|
261
|
+
// a content-only body can never go out without its key (which would wipe the
|
|
262
|
+
// shell) nor a key without a content-only body.
|
|
263
|
+
const segmented = typeof boundaryIndex === "number" && boundaryKey != null;
|
|
264
|
+
// Segment-scoped renders ONLY the chain below the boundary layout (its
|
|
265
|
+
// children = the <JuneOutlet> contents on a full load), so the boundary
|
|
266
|
+
// layout's shell markup is never produced. Whole-chain wraps the entire chain.
|
|
267
|
+
const inside = segmented ? chain.slice(boundaryIndex! + 1) : chain;
|
|
268
|
+
const wrapped = inside.reduceRight<React.ReactNode>(
|
|
206
269
|
(acc, L) => React.createElement(L, null, acc),
|
|
207
270
|
node,
|
|
208
271
|
);
|
|
@@ -212,6 +275,9 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
212
275
|
const headers = new Headers({ "content-type": "text/html; charset=utf-8" });
|
|
213
276
|
const title = typeof metadata?.title === "string" ? metadata.title : undefined;
|
|
214
277
|
if (title) headers.set(TITLE_HEADER, title);
|
|
278
|
+
// The shell key tells the client which shell this content-only fragment is
|
|
279
|
+
// for; it morphs the outlet only when that matches the mounted shell.
|
|
280
|
+
if (segmented) headers.set(SEGMENT_HEADER, boundaryKey!);
|
|
215
281
|
return new Response(html, { status: 200, headers });
|
|
216
282
|
}
|
|
217
283
|
|
|
@@ -233,7 +299,7 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
233
299
|
loadPromise: Promise<unknown>,
|
|
234
300
|
ctx: RouteContext,
|
|
235
301
|
): Promise<Response> {
|
|
236
|
-
const { def, chain, loading: Loading } = resolved;
|
|
302
|
+
const { def, chain, loading: Loading, boundaryKey } = resolved;
|
|
237
303
|
const leaf = React.createElement(StreamedView, { loadPromise, def, ctx });
|
|
238
304
|
const boundary = React.createElement(
|
|
239
305
|
Suspense,
|
|
@@ -246,7 +312,13 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
246
312
|
);
|
|
247
313
|
const metadata = typeof def.metadata === "object" ? def.metadata : undefined;
|
|
248
314
|
const stream = await renderToReadableStream(
|
|
249
|
-
React.createElement(Document, {
|
|
315
|
+
React.createElement(Document, {
|
|
316
|
+
config: docConfigForRender(),
|
|
317
|
+
metadata,
|
|
318
|
+
children: wrapped,
|
|
319
|
+
shellKey: boundaryKey, // stamps data-june-shell on [data-june-root]
|
|
320
|
+
...langDir(ctx.locale),
|
|
321
|
+
}),
|
|
250
322
|
{ onError: (e: unknown) => console.error("[june] streaming render error:", e) },
|
|
251
323
|
);
|
|
252
324
|
// NO allReady — return the live stream (shell first). React 19 streams the
|
|
@@ -254,7 +326,11 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
254
326
|
return new Response(stream, { status: 200, headers: htmlHeaders() });
|
|
255
327
|
}
|
|
256
328
|
|
|
257
|
-
function notFoundResponse(
|
|
329
|
+
function notFoundResponse(
|
|
330
|
+
target: RenderTarget,
|
|
331
|
+
pathname: string,
|
|
332
|
+
locale?: string,
|
|
333
|
+
): Promise<Response> | Response {
|
|
258
334
|
// Data clients get a JSON 404; humans get the rendered NotFound document.
|
|
259
335
|
return target !== "view"
|
|
260
336
|
? Response.json({ error: "Not Found", path: pathname }, { status: 404 })
|
|
@@ -263,6 +339,7 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
263
339
|
{ title: "Not found", robots: "noindex" },
|
|
264
340
|
404,
|
|
265
341
|
[],
|
|
342
|
+
locale,
|
|
266
343
|
);
|
|
267
344
|
}
|
|
268
345
|
|
|
@@ -293,15 +370,15 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
293
370
|
data: unknown,
|
|
294
371
|
ctx: RouteContext,
|
|
295
372
|
): Promise<Response> {
|
|
296
|
-
const { def, chain } = resolved;
|
|
373
|
+
const { def, chain, boundaryIndex, boundaryKey } = resolved;
|
|
297
374
|
// A projection declared `false` is disabled → 404 (and absent from discovery).
|
|
298
375
|
// "fragment" isn't a declarable projection (it's the view rendered without the
|
|
299
376
|
// shell), so it's never disabled — exclude it from the check.
|
|
300
377
|
if (target !== "fragment" && def[target] === false) {
|
|
301
|
-
return notFoundResponse(target, ctx.url.pathname);
|
|
378
|
+
return notFoundResponse(target, ctx.url.pathname, ctx.locale);
|
|
302
379
|
}
|
|
303
380
|
|
|
304
|
-
const res = await renderTarget(target, def, data, ctx, chain);
|
|
381
|
+
const res = await renderTarget(target, def, data, ctx, chain, boundaryIndex, boundaryKey);
|
|
305
382
|
// Every projection here is chosen by the Accept header at the SAME url (a full
|
|
306
383
|
// page, its fragment, its .md and .json all live at the clean path). Without
|
|
307
384
|
// Vary, a shared/browser cache can hand a soft-nav fragment to a real page
|
|
@@ -317,6 +394,8 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
317
394
|
data: unknown,
|
|
318
395
|
ctx: RouteContext,
|
|
319
396
|
chain: LayoutComponent[],
|
|
397
|
+
boundaryIndex?: number | null,
|
|
398
|
+
boundaryKey?: string | null,
|
|
320
399
|
): Promise<Response> {
|
|
321
400
|
if (target === "md") return renderMarkdown(def, data, ctx);
|
|
322
401
|
if (target === "json") {
|
|
@@ -326,8 +405,11 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
326
405
|
}
|
|
327
406
|
const node = provideLoaderData(data, def.view ? def.view(data, ctx) : null);
|
|
328
407
|
const meta = resolveMeta(def, data, ctx);
|
|
329
|
-
|
|
330
|
-
|
|
408
|
+
// Only the fragment projection narrows to a segment; the full document always
|
|
409
|
+
// renders the whole chain (a hard load of the URL is never segment-scoped) —
|
|
410
|
+
// but it stamps the shell key so the client knows which shell is mounted.
|
|
411
|
+
if (target === "fragment") return renderFragment(node, meta, chain, boundaryIndex, boundaryKey);
|
|
412
|
+
return renderDocument(node, meta, 200, chain, ctx.locale, boundaryKey);
|
|
331
413
|
}
|
|
332
414
|
|
|
333
415
|
async function discovery(url: URL): Promise<Response | null> {
|
|
@@ -383,10 +465,35 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
383
465
|
return letterFavicon(docConfig.site.name);
|
|
384
466
|
}
|
|
385
467
|
|
|
468
|
+
// --- locale resolution (before routing) ------------------------------
|
|
469
|
+
// host/path → locale, stripping the locale prefix off the front so the
|
|
470
|
+
// router matches the bare route path. Only runs when `i18n` is configured.
|
|
471
|
+
let locale: string | undefined;
|
|
472
|
+
let routeBase = url.pathname;
|
|
473
|
+
if (cfg.i18n) {
|
|
474
|
+
const pinned = matchPinnedLocale(cfg.i18n, url.host, url.pathname);
|
|
475
|
+
if (pinned) {
|
|
476
|
+
locale = pinned.locale;
|
|
477
|
+
routeBase = pinned.pathname;
|
|
478
|
+
} else {
|
|
479
|
+
// Ambiguous (bare path on the default origin): the resolveLocale hook
|
|
480
|
+
// gets first say, then the built-in chain. The hook runs ONLY here —
|
|
481
|
+
// never when the URL already pinned the locale.
|
|
482
|
+
const override = cfg.i18n.resolveLocale?.({ url, headers: request.headers });
|
|
483
|
+
locale =
|
|
484
|
+
override && cfg.i18n.locales[override]
|
|
485
|
+
? override
|
|
486
|
+
: negotiateLocale(cfg.i18n, {
|
|
487
|
+
acceptLanguage: request.headers.get("accept-language"),
|
|
488
|
+
cookie: readCookie(request, LOCALE_COOKIE),
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
386
493
|
// --- routes ----------------------------------------------------------
|
|
387
|
-
const { target, pathname, speculative } = negotiate(url, request);
|
|
494
|
+
const { target, pathname, speculative } = negotiate(url, request, routeBase);
|
|
388
495
|
const resolved = await cfg.resolve(pathname);
|
|
389
|
-
if (!resolved) return notFoundResponse(target, pathname);
|
|
496
|
+
if (!resolved) return notFoundResponse(target, pathname, locale);
|
|
390
497
|
|
|
391
498
|
// ctx is identity/request only; db/kv/blob are ambient (read from the
|
|
392
499
|
// request scope this whole handler runs inside — see runInScope below).
|
|
@@ -395,6 +502,7 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
395
502
|
url,
|
|
396
503
|
params: resolved.params,
|
|
397
504
|
target,
|
|
505
|
+
locale,
|
|
398
506
|
speculative,
|
|
399
507
|
};
|
|
400
508
|
|
|
@@ -416,7 +524,7 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
416
524
|
data = resolved.def.load ? await resolved.def.load(ctx) : undefined;
|
|
417
525
|
} catch {
|
|
418
526
|
// unknown slug etc. → 404 (segment error boundaries are a later milestone)
|
|
419
|
-
return notFoundResponse(target, pathname);
|
|
527
|
+
return notFoundResponse(target, pathname, locale);
|
|
420
528
|
}
|
|
421
529
|
return renderProjection(resolved, target, data, ctx);
|
|
422
530
|
}
|
package/src/segment.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Segment-boundary resolution — the ONE place the "deepest segmentBoundary wins"
|
|
2
|
+
// rule lives, shared by the dev resolver (app.ts), the build manifest, and the
|
|
3
|
+
// codegen (build.ts). Single-sourcing it keeps dev / frozen-manifest / deployed
|
|
4
|
+
// worker from drifting on the exact invariant the feature depends on.
|
|
5
|
+
//
|
|
6
|
+
// HOST-ONLY (uses node:crypto): runs in the dev server and at build time, never
|
|
7
|
+
// in the worker request path — the worker reads the frozen key from the manifest.
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
|
|
10
|
+
export const boundaryWarning = (file: string): string =>
|
|
11
|
+
`[june] multiple segmentBoundary layouts in one chain (${file}); using the deepest.`;
|
|
12
|
+
|
|
13
|
+
// A stable key identifying a boundary layout (its persistent shell). Same file →
|
|
14
|
+
// same key (a shell shared across routes); different file → different key (a
|
|
15
|
+
// distinct shell), so the client can tell whether a soft-nav fragment belongs to
|
|
16
|
+
// the shell currently mounted and hard-navigate when it doesn't. Consistency is
|
|
17
|
+
// only needed WITHIN one running instance (dev server OR deployed worker), since
|
|
18
|
+
// the key is compared between an outlet's host marker and a response header both
|
|
19
|
+
// produced by that same instance — dev and prod need not agree.
|
|
20
|
+
//
|
|
21
|
+
// Memoized: the dev resolver runs per request, and the key is a pure function of
|
|
22
|
+
// the path. 16 hex chars (64 bits) makes a same-instance collision between two
|
|
23
|
+
// boundary layouts — which would morph one shell's content into another — beyond
|
|
24
|
+
// reach without inflating the wire marker.
|
|
25
|
+
const keyCache = new Map<string, string>();
|
|
26
|
+
export function boundaryKey(file: string): string {
|
|
27
|
+
let key = keyCache.get(file);
|
|
28
|
+
if (key === undefined) {
|
|
29
|
+
key = createHash("sha256").update(file).digest("hex").slice(0, 16);
|
|
30
|
+
keyCache.set(file, key);
|
|
31
|
+
}
|
|
32
|
+
return key;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Build the layout chain (root→leaf, null entries filtered) and locate the
|
|
36
|
+
// boundary: the DEEPEST entry flagged `boundary`. The index is into the FILTERED
|
|
37
|
+
// chain — every consumer filters identically, so the index always matches the
|
|
38
|
+
// chain the worker actually renders. Generic over the entry type so codegen can
|
|
39
|
+
// pass layout-id strings where dev/build pass component functions.
|
|
40
|
+
export function resolveBoundary<C>(
|
|
41
|
+
items: Array<{ file: string; entry: C | null; boundary: boolean }>,
|
|
42
|
+
): { chain: C[]; boundaryIndex: number | null; key: string | null } {
|
|
43
|
+
const chain: C[] = [];
|
|
44
|
+
let boundaryIndex: number | null = null;
|
|
45
|
+
let boundaryFile: string | null = null;
|
|
46
|
+
for (const it of items) {
|
|
47
|
+
if (it.entry == null) continue; // dropped layout — filtered consistently everywhere
|
|
48
|
+
if (it.boundary) {
|
|
49
|
+
if (boundaryIndex !== null) console.warn(boundaryWarning(it.file));
|
|
50
|
+
boundaryIndex = chain.length;
|
|
51
|
+
boundaryFile = it.file;
|
|
52
|
+
}
|
|
53
|
+
chain.push(it.entry);
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
chain,
|
|
57
|
+
boundaryIndex,
|
|
58
|
+
key: boundaryFile === null ? null : boundaryKey(boundaryFile),
|
|
59
|
+
};
|
|
60
|
+
}
|
package/src/worker.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type React from "react";
|
|
|
14
14
|
import type { BrandedRoute } from "@junejs/core/route";
|
|
15
15
|
import type { AgentConfig } from "@junejs/core/config";
|
|
16
16
|
import type { DocumentConfig } from "@junejs/core/document";
|
|
17
|
+
import type { I18nConfig } from "@junejs/core/i18n";
|
|
17
18
|
import type { Resources } from "@junejs/core/resources";
|
|
18
19
|
|
|
19
20
|
import { createPipeline, type ExtraHandler, type LayoutComponent, type LoadingComponent, type Resolved, type ResolvedResource, type ResourceHandler } from "./pipeline";
|
|
@@ -29,10 +30,19 @@ export type WorkerManifest = {
|
|
|
29
30
|
// Layout chains (root→leaf) keyed by route path / dynamic pattern. The build
|
|
30
31
|
// freezes the same chain the dev server loads from app/layout.* files.
|
|
31
32
|
layoutChains?: Record<string, LayoutComponent[]>;
|
|
33
|
+
// Segment boundary per route: the chain index of the deepest layout exporting
|
|
34
|
+
// `segmentBoundary` (renders <JuneOutlet>) plus the shell's identity `key`.
|
|
35
|
+
// Routes without one are absent → whole-chain fragment (the default). Frozen
|
|
36
|
+
// statically by the build.
|
|
37
|
+
layoutBoundaries?: Record<string, { index: number; key: string }>;
|
|
32
38
|
// Nearest loading.tsx per route path → streaming Suspense fallback.
|
|
33
39
|
loadings?: Record<string, LoadingComponent>;
|
|
34
40
|
document: DocumentConfig;
|
|
35
41
|
agent: AgentConfig;
|
|
42
|
+
// Locale routing config, frozen from june.config.ts `i18n`. The locales table
|
|
43
|
+
// is plain data; a `resolveLocale` hook survives the in-process freeze (parity
|
|
44
|
+
// test) but not JSON codegen — worker hook support lands with the codegen pass.
|
|
45
|
+
i18n?: I18nConfig;
|
|
36
46
|
// Preload Link values (config earlyHints + auto font hints), frozen at build.
|
|
37
47
|
earlyHints?: string[];
|
|
38
48
|
htmlCacheControl?: string;
|
|
@@ -108,6 +118,8 @@ export function createWorker(
|
|
|
108
118
|
|
|
109
119
|
const chainFor = (key: string): LayoutComponent[] => manifest.layoutChains?.[key] ?? [];
|
|
110
120
|
const loadingFor = (key: string): LoadingComponent | undefined => manifest.loadings?.[key];
|
|
121
|
+
const boundaryFor = (key: string): { index: number; key: string } | null =>
|
|
122
|
+
manifest.layoutBoundaries?.[key] ?? null;
|
|
111
123
|
|
|
112
124
|
// The worker's env (D1/KV/R2 bindings) arrives per fetch and is stable across
|
|
113
125
|
// requests in an isolate; we capture the latest and hand it to the env-aware
|
|
@@ -118,6 +130,7 @@ export function createWorker(
|
|
|
118
130
|
const pipeline = createPipeline({
|
|
119
131
|
docConfig: manifest.document,
|
|
120
132
|
agent: manifest.agent,
|
|
133
|
+
i18n: manifest.i18n,
|
|
121
134
|
routeList: () => routeList,
|
|
122
135
|
earlyHints: manifest.earlyHints,
|
|
123
136
|
htmlCacheControl: manifest.htmlCacheControl,
|
|
@@ -126,8 +139,17 @@ export function createWorker(
|
|
|
126
139
|
resources: provider ? () => provider(currentEnv) : undefined,
|
|
127
140
|
resolve: async (pathname): Promise<Resolved | ResolvedResource | null> => {
|
|
128
141
|
const staticDef = manifest.routes[pathname];
|
|
129
|
-
if (staticDef)
|
|
130
|
-
|
|
142
|
+
if (staticDef) {
|
|
143
|
+
const b = boundaryFor(pathname);
|
|
144
|
+
return {
|
|
145
|
+
def: staticDef,
|
|
146
|
+
params: {},
|
|
147
|
+
chain: chainFor(pathname),
|
|
148
|
+
boundaryIndex: b?.index ?? null,
|
|
149
|
+
boundaryKey: b?.key ?? null,
|
|
150
|
+
loading: loadingFor(pathname),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
131
153
|
for (const d of dynamic) {
|
|
132
154
|
const m = pathname.match(d.regex);
|
|
133
155
|
if (m) {
|
|
@@ -139,7 +161,15 @@ export function createWorker(
|
|
|
139
161
|
return v === undefined ? [] : [[n, decodeURIComponent(v)]];
|
|
140
162
|
}),
|
|
141
163
|
);
|
|
142
|
-
|
|
164
|
+
const b = boundaryFor(d.pattern);
|
|
165
|
+
return {
|
|
166
|
+
def: d.def,
|
|
167
|
+
params,
|
|
168
|
+
chain: chainFor(d.pattern),
|
|
169
|
+
boundaryIndex: b?.index ?? null,
|
|
170
|
+
boundaryKey: b?.key ?? null,
|
|
171
|
+
loading: loadingFor(d.pattern),
|
|
172
|
+
};
|
|
143
173
|
}
|
|
144
174
|
}
|
|
145
175
|
// Resource routes after pages (so a page is never shadowed by one).
|