@junejs/server 0.0.21 → 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 +45 -18
- package/src/build.ts +112 -26
- package/src/dev-reload.ts +7 -1
- package/src/index.ts +15 -0
- package/src/negotiate.ts +21 -3
- package/src/pipeline.ts +146 -16
- package/src/router.ts +37 -11
- package/src/segment.ts +60 -0
- package/src/worker.ts +56 -5
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
|
@@ -18,8 +18,9 @@ import { resolveAgent, resolveSpeculationRules, type JuneConfig } from "@junejs/
|
|
|
18
18
|
import type { DocumentConfig } from "@junejs/core/document";
|
|
19
19
|
import { runWithTrace, type RequestTrace } from "@junejs/core/instrumentation";
|
|
20
20
|
|
|
21
|
-
import {
|
|
22
|
-
import { createPipeline, type ExtraHandler, type LayoutComponent, type Pipeline, type Resolved } from "./pipeline";
|
|
21
|
+
import { findMiddlewareFile, isResourceFile, listRoutes, matchRouteTree, resolveNotFound, routeFiles, type SegmentMatch } from "./router";
|
|
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 {
|
|
@@ -140,7 +157,7 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
|
|
|
140
157
|
}
|
|
141
158
|
}
|
|
142
159
|
let extra: ExtraHandler | undefined;
|
|
143
|
-
const extraFile =
|
|
160
|
+
const extraFile = findMiddlewareFile(appDir);
|
|
144
161
|
if (extraFile) {
|
|
145
162
|
const mod = (await import(pathToFileURL(extraFile).href)) as { default?: unknown };
|
|
146
163
|
if (typeof mod.default === "function") extra = mod.default as ExtraHandler;
|
|
@@ -149,22 +166,32 @@ 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,
|
|
155
173
|
notFoundComponent,
|
|
156
|
-
resolve: async (pathname)
|
|
174
|
+
resolve: async (pathname) => {
|
|
157
175
|
const match = await matchRouteTree(appDir, pathname, { pageConvention: true });
|
|
158
176
|
if (!match) return null;
|
|
159
177
|
const mod = await import(pathToFileURL(match.file).href);
|
|
178
|
+
// Resource route (route.*): the default export is the Response handler.
|
|
179
|
+
if (isResourceFile(match.file)) {
|
|
180
|
+
const handler = (mod as { default?: unknown }).default;
|
|
181
|
+
if (typeof handler !== "function") return null;
|
|
182
|
+
return { handler: handler as ResourceHandler, params: match.params };
|
|
183
|
+
}
|
|
160
184
|
const def = routeFromModule(mod);
|
|
161
185
|
if (!def) return null;
|
|
186
|
+
const { chain, boundaryIndex, boundaryKey } = await loadChain(match.segments);
|
|
162
187
|
return {
|
|
163
188
|
def,
|
|
164
189
|
params: match.params,
|
|
165
|
-
chain
|
|
190
|
+
chain,
|
|
191
|
+
boundaryIndex,
|
|
192
|
+
boundaryKey,
|
|
166
193
|
loading: await nearestLoading(match.segments),
|
|
167
|
-
};
|
|
194
|
+
} satisfies Resolved;
|
|
168
195
|
},
|
|
169
196
|
});
|
|
170
197
|
}
|
package/src/build.ts
CHANGED
|
@@ -31,8 +31,9 @@ import { workers, type JuneAdapter, type ResourcePlan } from "./adapter";
|
|
|
31
31
|
import type { DocumentConfig } from "@junejs/core/document";
|
|
32
32
|
import { collection } from "./content";
|
|
33
33
|
import { createWorker, type WorkerManifest } from "./worker";
|
|
34
|
-
import {
|
|
35
|
-
import
|
|
34
|
+
import { findMiddlewareFile } from "./router";
|
|
35
|
+
import { resolveBoundary } from "./segment";
|
|
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";
|
|
38
39
|
import { buildModuleCss, rolldownCssModulesPlugin, registerCssModules } from "./css-modules";
|
|
@@ -51,6 +52,7 @@ type RouteEntry = {
|
|
|
51
52
|
path: string;
|
|
52
53
|
file: string;
|
|
53
54
|
dynamic: boolean;
|
|
55
|
+
resource?: boolean; // a route.* resource route (raw-Response handler), not a page
|
|
54
56
|
layouts: string[];
|
|
55
57
|
loading?: string; // nearest loading.tsx up the tree → streaming Suspense fallback
|
|
56
58
|
};
|
|
@@ -87,7 +89,9 @@ export async function scanRoutes(
|
|
|
87
89
|
}
|
|
88
90
|
const ext = e.name.match(/\.[^.]+$/)?.[0] ?? "";
|
|
89
91
|
if (!ROUTE_EXTS.includes(ext)) continue;
|
|
90
|
-
|
|
92
|
+
const base = basename(e.name, ext);
|
|
93
|
+
const resource = base === "route";
|
|
94
|
+
if (!PAGE_BASENAMES.has(base) && !resource) continue;
|
|
91
95
|
const relDir = relative(appDir, dir);
|
|
92
96
|
const segments = relDir === "" ? [] : relDir.split(sep).filter((s) => !isRouteGroup(s));
|
|
93
97
|
const path = "/" + segments.join("/");
|
|
@@ -95,6 +99,7 @@ export async function scanRoutes(
|
|
|
95
99
|
path: path === "/" ? "/" : path,
|
|
96
100
|
file: full,
|
|
97
101
|
dynamic: /\[.+\]/.test(path),
|
|
102
|
+
resource,
|
|
98
103
|
layouts: chain,
|
|
99
104
|
loading: nearestLoading,
|
|
100
105
|
});
|
|
@@ -138,6 +143,7 @@ export async function generateContent(appRoot: string): Promise<string[]> {
|
|
|
138
143
|
export async function freezeConfig(appRoot: string): Promise<{
|
|
139
144
|
document: DocumentConfig;
|
|
140
145
|
agent: WorkerManifest["agent"];
|
|
146
|
+
i18n: WorkerManifest["i18n"];
|
|
141
147
|
earlyHints: string[];
|
|
142
148
|
buildExternal: string[];
|
|
143
149
|
}> {
|
|
@@ -159,14 +165,24 @@ export async function freezeConfig(appRoot: string): Promise<{
|
|
|
159
165
|
styles: hasCss ? STYLES_URL : null,
|
|
160
166
|
},
|
|
161
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,
|
|
162
172
|
earlyHints: cfg.earlyHints ?? [],
|
|
163
173
|
buildExternal: cfg.build?.external ?? [],
|
|
164
174
|
};
|
|
165
175
|
}
|
|
166
176
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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;
|
|
170
186
|
}
|
|
171
187
|
|
|
172
188
|
// The FREEZE, in-process: import route modules + layouts, build the manifest a
|
|
@@ -177,34 +193,53 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
|
|
|
177
193
|
const frozen = await freezeConfig(appRoot);
|
|
178
194
|
const scanned = (await scanRoutes(appDir)).sort((a, b) => a.path.localeCompare(b.path));
|
|
179
195
|
|
|
180
|
-
const layoutCache = new Map<string,
|
|
181
|
-
const
|
|
182
|
-
|
|
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 = [];
|
|
183
208
|
for (const f of files) {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
if (c) chain.push(c);
|
|
209
|
+
const c = await loadCached(f);
|
|
210
|
+
items.push({ file: f, entry: c?.component ?? null, boundary: !!c?.boundary });
|
|
187
211
|
}
|
|
188
|
-
return
|
|
212
|
+
return resolveBoundary(items);
|
|
189
213
|
};
|
|
190
214
|
|
|
191
215
|
const loadingCache = new Map<string, LoadingComponent | null>();
|
|
192
216
|
const loadingFor = async (file?: string): Promise<LoadingComponent | undefined> => {
|
|
193
217
|
if (!file) return undefined;
|
|
194
|
-
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
|
+
}
|
|
195
222
|
return loadingCache.get(file) ?? undefined;
|
|
196
223
|
};
|
|
197
224
|
|
|
198
225
|
const routes: Record<string, BrandedRoute> = {};
|
|
199
226
|
const dynamicRoutes: Array<{ pattern: string; def: BrandedRoute }> = [];
|
|
227
|
+
const resourceRoutes: Array<{ pattern: string; handler: ResourceHandler }> = [];
|
|
200
228
|
const layoutChains: Record<string, LayoutComponent[]> = {};
|
|
229
|
+
const layoutBoundaries: Record<string, { index: number; key: string }> = {};
|
|
201
230
|
const loadings: Record<string, LoadingComponent> = {};
|
|
202
231
|
|
|
203
232
|
for (const r of scanned) {
|
|
204
233
|
const mod = await import(pathToFileURL(r.file).href);
|
|
234
|
+
// Resource route (route.*): the default export is the Response handler.
|
|
235
|
+
if (r.resource) {
|
|
236
|
+
const handler = (mod as { default?: unknown }).default;
|
|
237
|
+
if (typeof handler === "function") resourceRoutes.push({ pattern: r.path, handler: handler as ResourceHandler });
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
205
240
|
const def = routeFromModule(mod);
|
|
206
241
|
if (!def) continue;
|
|
207
|
-
const chain = await componentsFor(r.layouts);
|
|
242
|
+
const { chain, boundaryIndex, key } = await componentsFor(r.layouts);
|
|
208
243
|
const loading = await loadingFor(r.loading);
|
|
209
244
|
if (r.dynamic) {
|
|
210
245
|
dynamicRoutes.push({ pattern: r.path, def });
|
|
@@ -213,11 +248,12 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
|
|
|
213
248
|
routes[r.path] = def;
|
|
214
249
|
layoutChains[r.path] = chain;
|
|
215
250
|
}
|
|
251
|
+
if (boundaryIndex !== null && key !== null) layoutBoundaries[r.path] = { index: boundaryIndex, key };
|
|
216
252
|
if (loading) loadings[r.path] = loading;
|
|
217
253
|
}
|
|
218
254
|
|
|
219
255
|
let extra: ExtraHandler | undefined;
|
|
220
|
-
const extraFile =
|
|
256
|
+
const extraFile = findMiddlewareFile(appDir);
|
|
221
257
|
if (extraFile) {
|
|
222
258
|
const mod = (await import(pathToFileURL(extraFile).href)) as { default?: unknown };
|
|
223
259
|
if (typeof mod.default === "function") extra = mod.default as ExtraHandler;
|
|
@@ -226,10 +262,13 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
|
|
|
226
262
|
return {
|
|
227
263
|
routes,
|
|
228
264
|
dynamicRoutes,
|
|
265
|
+
resourceRoutes,
|
|
229
266
|
layoutChains,
|
|
267
|
+
layoutBoundaries,
|
|
230
268
|
loadings,
|
|
231
269
|
document: frozen.document,
|
|
232
270
|
agent: frozen.agent,
|
|
271
|
+
i18n: frozen.i18n,
|
|
233
272
|
earlyHints: frozen.earlyHints,
|
|
234
273
|
extra,
|
|
235
274
|
};
|
|
@@ -256,6 +295,15 @@ export async function juneBuild(
|
|
|
256
295
|
if (routes.length === 0) throw new Error(`no page.* routes found under ${appDir}`);
|
|
257
296
|
|
|
258
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
|
+
}
|
|
259
307
|
// The deploy adapter packages the portable build for its target (default:
|
|
260
308
|
// built-in workers()). It contributes the entry's export wrapper + emits the
|
|
261
309
|
// deploy config.
|
|
@@ -348,17 +396,54 @@ export async function juneBuild(
|
|
|
348
396
|
}
|
|
349
397
|
return id;
|
|
350
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
|
+
}
|
|
351
408
|
const chains: string[] = [];
|
|
409
|
+
const boundaries: string[] = [];
|
|
352
410
|
const loadings: string[] = [];
|
|
411
|
+
const resources: string[] = [];
|
|
353
412
|
routes.forEach((r, i) => {
|
|
413
|
+
// Resource route (route.*): the default export IS the handler — import it
|
|
414
|
+
// directly (no routeFromModule, no layout chain).
|
|
415
|
+
if (r.resource) {
|
|
416
|
+
imports.push(`import h${i} from ${JSON.stringify(importPath(genDir, r.file))};`);
|
|
417
|
+
resources.push(` { pattern: ${JSON.stringify(r.path)}, handler: h${i} },`);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
354
420
|
imports.push(`import * as r${i} from ${JSON.stringify(importPath(genDir, r.file))};`);
|
|
355
421
|
if (r.dynamic) dynamics.push(` { pattern: ${JSON.stringify(r.path)}, def: routeFromModule(r${i}) },`);
|
|
356
422
|
else statics.push(` ${JSON.stringify(r.path)}: routeFromModule(r${i}),`);
|
|
357
|
-
|
|
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
|
+
}
|
|
358
435
|
if (r.loading) loadings.push(` ${JSON.stringify(r.path)}: ${loadingId(r.loading)},`);
|
|
359
436
|
});
|
|
360
|
-
|
|
361
|
-
|
|
437
|
+
const resourceRoutesField = resources.length
|
|
438
|
+
? `\n resourceRoutes: [\n${resources.join("\n")}\n ],`
|
|
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
|
+
: "";
|
|
445
|
+
|
|
446
|
+
const builtExtraFile = findMiddlewareFile(appDir);
|
|
362
447
|
if (builtExtraFile) {
|
|
363
448
|
imports.push(`import extra from ${JSON.stringify(importPath(genDir, builtExtraFile))};`);
|
|
364
449
|
}
|
|
@@ -418,15 +503,15 @@ ${statics.join("\n")}
|
|
|
418
503
|
},
|
|
419
504
|
dynamicRoutes: [
|
|
420
505
|
${dynamics.join("\n")}
|
|
421
|
-
]
|
|
506
|
+
],${resourceRoutesField}
|
|
422
507
|
layoutChains: {
|
|
423
508
|
${chains.join("\n")}
|
|
424
|
-
}
|
|
509
|
+
},${layoutBoundariesField}
|
|
425
510
|
loadings: {
|
|
426
511
|
${loadings.join("\n")}
|
|
427
512
|
},
|
|
428
513
|
document: ${JSON.stringify(frozen.document, null, 2).replace(/\n/g, "\n ")},
|
|
429
|
-
agent: ${JSON.stringify(frozen.agent)}
|
|
514
|
+
agent: ${JSON.stringify(frozen.agent)},${frozen.i18n ? `\n i18n: ${JSON.stringify(frozen.i18n)},` : ""}
|
|
430
515
|
earlyHints: ${JSON.stringify(frozen.earlyHints)},${builtExtraFile ? "\n extra," : ""}${resourcesField}
|
|
431
516
|
});
|
|
432
517
|
|
|
@@ -487,10 +572,11 @@ ${adapterEntry.wrap("pipeline")}
|
|
|
487
572
|
const def = manifest.routes[r.path];
|
|
488
573
|
if (!def || !def.prerender) continue;
|
|
489
574
|
const stem = r.path === "/" ? "index" : r.path.slice(1);
|
|
490
|
-
// The homepage's projection requests are
|
|
491
|
-
// the
|
|
492
|
-
|
|
493
|
-
const
|
|
575
|
+
// The homepage's projection requests are `/index.md` / `/index.json` (negotiate
|
|
576
|
+
// treats `/index` as the alias for `/`); these become the `index.md` /
|
|
577
|
+
// `index.json` assets the worker serves at the same intuitive paths.
|
|
578
|
+
const mdReq = r.path === "/" ? "/index.md" : `${r.path}.md`;
|
|
579
|
+
const jsonReq = r.path === "/" ? "/index.json" : `${r.path}.json`;
|
|
494
580
|
const targets: Array<[string, string]> = [
|
|
495
581
|
[r.path, `${stem}.html`],
|
|
496
582
|
[mdReq, `${stem}.md`],
|
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)) {
|
|
@@ -43,6 +48,19 @@ export function negotiate(url: URL, request: Request): Negotiated {
|
|
|
43
48
|
}
|
|
44
49
|
}
|
|
45
50
|
|
|
51
|
+
// "/index" is the conventional alias for the home route "/": the home page's
|
|
52
|
+
// projections live at the intuitive `/index.md` / `/index.json` (and plain
|
|
53
|
+
// `/index` serves the home view), matching the build's `index.md` / `index.json`
|
|
54
|
+
// assets.
|
|
55
|
+
if (pathname === "/index") {
|
|
56
|
+
pathname = "/";
|
|
57
|
+
} else if (target && pathname === "/") {
|
|
58
|
+
// A bare projection on the root — `/.md`, `/.json` — is NOT a real URL (the
|
|
59
|
+
// home surface is `/index.md`). Stripping the extension collapses it to "/",
|
|
60
|
+
// so steer it back to the literal path: no route matches → 404.
|
|
61
|
+
pathname = original;
|
|
62
|
+
}
|
|
63
|
+
|
|
46
64
|
if (!target) {
|
|
47
65
|
const accept = request.headers.get("accept") ?? "";
|
|
48
66
|
for (const [re, t] of ACCEPT_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,15 +74,39 @@ 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
|
|
|
90
|
+
// A RESOURCE route (app/**/route.*): a handler returning a raw Response — binary,
|
|
91
|
+
// custom content-type, webhook — resolved by the SAME matcher (params from the
|
|
92
|
+
// path), so it lives in the route table instead of a hand-rolled url branch.
|
|
93
|
+
export type ResourceHandler = (
|
|
94
|
+
request: Request,
|
|
95
|
+
ctx: RouteContext,
|
|
96
|
+
) => Response | Promise<Response>;
|
|
97
|
+
export type ResolvedResource = { handler: ResourceHandler; params: Record<string, string> };
|
|
98
|
+
|
|
59
99
|
// The one thing dev and worker do differently: turn a clean pathname into a
|
|
60
100
|
// matched route. Dev walks the filesystem; the worker reads the frozen manifest.
|
|
61
|
-
|
|
101
|
+
// A match is either a page (Resolved) or a resource route (ResolvedResource).
|
|
102
|
+
export type RouteResolver = (pathname: string) => Promise<Resolved | ResolvedResource | null>;
|
|
62
103
|
|
|
63
104
|
export type PipelineConfig = {
|
|
64
105
|
docConfig: DocumentConfig;
|
|
65
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;
|
|
66
110
|
// The route list for discovery surfaces (sitemap / llms.txt). Async so dev can
|
|
67
111
|
// re-scan the filesystem; the worker returns a frozen array.
|
|
68
112
|
routeList: () => Promise<string[]> | string[];
|
|
@@ -80,10 +124,18 @@ export type PipelineConfig = {
|
|
|
80
124
|
extra?: ExtraHandler;
|
|
81
125
|
};
|
|
82
126
|
|
|
83
|
-
export
|
|
127
|
+
// app/_middleware.* default export. Runs after the agent surface, before route
|
|
128
|
+
// resolution; return null to pass through, a Response to short-circuit.
|
|
129
|
+
// ⚠ Don't authorize here — authorization is the single defineAction
|
|
130
|
+
// run(input, ctx) gate; a check here is a second, ungoverned path.
|
|
131
|
+
// ⚠ It runs BEFORE routes, so over-broad url matching shadows a page. For a
|
|
132
|
+
// custom endpoint (binary, webhook) prefer a route.* resource route.
|
|
133
|
+
export type MiddlewareHandler = (
|
|
84
134
|
request: Request,
|
|
85
135
|
url: URL,
|
|
86
136
|
) => Promise<Response | null> | Response | null;
|
|
137
|
+
/** @deprecated the former name (app/_extra); use {@link MiddlewareHandler}. */
|
|
138
|
+
export type ExtraHandler = MiddlewareHandler;
|
|
87
139
|
|
|
88
140
|
export type Pipeline = { fetch(request: Request): Promise<Response> };
|
|
89
141
|
|
|
@@ -153,11 +205,22 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
153
205
|
return headers;
|
|
154
206
|
}
|
|
155
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
|
+
|
|
156
217
|
async function renderDocument(
|
|
157
218
|
node: React.ReactNode,
|
|
158
219
|
metadata: Metadata | undefined,
|
|
159
220
|
status: number,
|
|
160
221
|
chain: LayoutComponent[],
|
|
222
|
+
locale?: string,
|
|
223
|
+
boundaryKey?: string | null,
|
|
161
224
|
): Promise<Response> {
|
|
162
225
|
// Wrap root→leaf: chain[0] is outermost.
|
|
163
226
|
const wrapped = chain.reduceRight<React.ReactNode>(
|
|
@@ -165,7 +228,13 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
165
228
|
node,
|
|
166
229
|
);
|
|
167
230
|
const stream = await renderToReadableStream(
|
|
168
|
-
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
|
+
}),
|
|
169
238
|
);
|
|
170
239
|
await stream.allReady; // fully resolved markup (no streamed Suspense fallbacks)
|
|
171
240
|
// React 19 emits <!DOCTYPE html> itself for an <html> root — don't prepend a
|
|
@@ -183,8 +252,20 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
183
252
|
node: React.ReactNode,
|
|
184
253
|
metadata: Metadata | undefined,
|
|
185
254
|
chain: LayoutComponent[],
|
|
255
|
+
boundaryIndex?: number | null,
|
|
256
|
+
boundaryKey?: string | null,
|
|
186
257
|
): Promise<Response> {
|
|
187
|
-
|
|
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>(
|
|
188
269
|
(acc, L) => React.createElement(L, null, acc),
|
|
189
270
|
node,
|
|
190
271
|
);
|
|
@@ -194,6 +275,9 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
194
275
|
const headers = new Headers({ "content-type": "text/html; charset=utf-8" });
|
|
195
276
|
const title = typeof metadata?.title === "string" ? metadata.title : undefined;
|
|
196
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!);
|
|
197
281
|
return new Response(html, { status: 200, headers });
|
|
198
282
|
}
|
|
199
283
|
|
|
@@ -215,7 +299,7 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
215
299
|
loadPromise: Promise<unknown>,
|
|
216
300
|
ctx: RouteContext,
|
|
217
301
|
): Promise<Response> {
|
|
218
|
-
const { def, chain, loading: Loading } = resolved;
|
|
302
|
+
const { def, chain, loading: Loading, boundaryKey } = resolved;
|
|
219
303
|
const leaf = React.createElement(StreamedView, { loadPromise, def, ctx });
|
|
220
304
|
const boundary = React.createElement(
|
|
221
305
|
Suspense,
|
|
@@ -228,7 +312,13 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
228
312
|
);
|
|
229
313
|
const metadata = typeof def.metadata === "object" ? def.metadata : undefined;
|
|
230
314
|
const stream = await renderToReadableStream(
|
|
231
|
-
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
|
+
}),
|
|
232
322
|
{ onError: (e: unknown) => console.error("[june] streaming render error:", e) },
|
|
233
323
|
);
|
|
234
324
|
// NO allReady — return the live stream (shell first). React 19 streams the
|
|
@@ -236,7 +326,11 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
236
326
|
return new Response(stream, { status: 200, headers: htmlHeaders() });
|
|
237
327
|
}
|
|
238
328
|
|
|
239
|
-
function notFoundResponse(
|
|
329
|
+
function notFoundResponse(
|
|
330
|
+
target: RenderTarget,
|
|
331
|
+
pathname: string,
|
|
332
|
+
locale?: string,
|
|
333
|
+
): Promise<Response> | Response {
|
|
240
334
|
// Data clients get a JSON 404; humans get the rendered NotFound document.
|
|
241
335
|
return target !== "view"
|
|
242
336
|
? Response.json({ error: "Not Found", path: pathname }, { status: 404 })
|
|
@@ -245,6 +339,7 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
245
339
|
{ title: "Not found", robots: "noindex" },
|
|
246
340
|
404,
|
|
247
341
|
[],
|
|
342
|
+
locale,
|
|
248
343
|
);
|
|
249
344
|
}
|
|
250
345
|
|
|
@@ -275,15 +370,15 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
275
370
|
data: unknown,
|
|
276
371
|
ctx: RouteContext,
|
|
277
372
|
): Promise<Response> {
|
|
278
|
-
const { def, chain } = resolved;
|
|
373
|
+
const { def, chain, boundaryIndex, boundaryKey } = resolved;
|
|
279
374
|
// A projection declared `false` is disabled → 404 (and absent from discovery).
|
|
280
375
|
// "fragment" isn't a declarable projection (it's the view rendered without the
|
|
281
376
|
// shell), so it's never disabled — exclude it from the check.
|
|
282
377
|
if (target !== "fragment" && def[target] === false) {
|
|
283
|
-
return notFoundResponse(target, ctx.url.pathname);
|
|
378
|
+
return notFoundResponse(target, ctx.url.pathname, ctx.locale);
|
|
284
379
|
}
|
|
285
380
|
|
|
286
|
-
const res = await renderTarget(target, def, data, ctx, chain);
|
|
381
|
+
const res = await renderTarget(target, def, data, ctx, chain, boundaryIndex, boundaryKey);
|
|
287
382
|
// Every projection here is chosen by the Accept header at the SAME url (a full
|
|
288
383
|
// page, its fragment, its .md and .json all live at the clean path). Without
|
|
289
384
|
// Vary, a shared/browser cache can hand a soft-nav fragment to a real page
|
|
@@ -299,6 +394,8 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
299
394
|
data: unknown,
|
|
300
395
|
ctx: RouteContext,
|
|
301
396
|
chain: LayoutComponent[],
|
|
397
|
+
boundaryIndex?: number | null,
|
|
398
|
+
boundaryKey?: string | null,
|
|
302
399
|
): Promise<Response> {
|
|
303
400
|
if (target === "md") return renderMarkdown(def, data, ctx);
|
|
304
401
|
if (target === "json") {
|
|
@@ -308,8 +405,11 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
308
405
|
}
|
|
309
406
|
const node = provideLoaderData(data, def.view ? def.view(data, ctx) : null);
|
|
310
407
|
const meta = resolveMeta(def, data, ctx);
|
|
311
|
-
|
|
312
|
-
|
|
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);
|
|
313
413
|
}
|
|
314
414
|
|
|
315
415
|
async function discovery(url: URL): Promise<Response | null> {
|
|
@@ -365,10 +465,35 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
365
465
|
return letterFavicon(docConfig.site.name);
|
|
366
466
|
}
|
|
367
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
|
+
|
|
368
493
|
// --- routes ----------------------------------------------------------
|
|
369
|
-
const { target, pathname, speculative } = negotiate(url, request);
|
|
494
|
+
const { target, pathname, speculative } = negotiate(url, request, routeBase);
|
|
370
495
|
const resolved = await cfg.resolve(pathname);
|
|
371
|
-
if (!resolved) return notFoundResponse(target, pathname);
|
|
496
|
+
if (!resolved) return notFoundResponse(target, pathname, locale);
|
|
372
497
|
|
|
373
498
|
// ctx is identity/request only; db/kv/blob are ambient (read from the
|
|
374
499
|
// request scope this whole handler runs inside — see runInScope below).
|
|
@@ -377,8 +502,13 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
377
502
|
url,
|
|
378
503
|
params: resolved.params,
|
|
379
504
|
target,
|
|
505
|
+
locale,
|
|
380
506
|
speculative,
|
|
381
507
|
};
|
|
508
|
+
|
|
509
|
+
// Resource route (route.*): a raw-Response handler — no projection, no doc
|
|
510
|
+
// shell. It's matched like any route, so it can't shadow a page.
|
|
511
|
+
if ("handler" in resolved) return resolved.handler(request, ctx);
|
|
382
512
|
// Streaming Suspense: a view request on a route with loading.tsx AND
|
|
383
513
|
// static metadata flushes the shell + fallback before load() resolves.
|
|
384
514
|
// (data-derived metadata can't stream — the <head> needs the title.)
|
|
@@ -394,7 +524,7 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
394
524
|
data = resolved.def.load ? await resolved.def.load(ctx) : undefined;
|
|
395
525
|
} catch {
|
|
396
526
|
// unknown slug etc. → 404 (segment error boundaries are a later milestone)
|
|
397
|
-
return notFoundResponse(target, pathname);
|
|
527
|
+
return notFoundResponse(target, pathname, locale);
|
|
398
528
|
}
|
|
399
529
|
return renderProjection(resolved, target, data, ctx);
|
|
400
530
|
}
|
package/src/router.ts
CHANGED
|
@@ -31,14 +31,27 @@ export type RouteTreeMatch = {
|
|
|
31
31
|
|
|
32
32
|
const routeExtensions = new Set([".tsx", ".jsx", ".ts", ".js"]);
|
|
33
33
|
|
|
34
|
-
// app/
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
34
|
+
// app/_middleware.* — the pre-route middleware seam (a `_` file, so never a
|
|
35
|
+
// route): runs after the agent surface, before route resolution. Return null to
|
|
36
|
+
// pass; return a Response to short-circuit. Two cautions (see MiddlewareHandler):
|
|
37
|
+
// don't authorize here (that's the single run(input, ctx) gate), and because it
|
|
38
|
+
// runs BEFORE routes, over-broad matching can shadow a page. For a custom
|
|
39
|
+
// endpoint (binary, webhook), prefer a route.* resource route instead.
|
|
40
|
+
// `_extra` is the deprecated former name (warned + still honored for one cycle).
|
|
41
|
+
// Dev and the build look it up through this ONE helper so the conventions can't
|
|
42
|
+
// drift.
|
|
43
|
+
export function findMiddlewareFile(appDir: string): string | null {
|
|
38
44
|
for (const ext of routeExtensions) {
|
|
39
|
-
const f = join(appDir, `
|
|
45
|
+
const f = join(appDir, `_middleware${ext}`);
|
|
40
46
|
if (existsSync(f)) return f;
|
|
41
47
|
}
|
|
48
|
+
for (const ext of routeExtensions) {
|
|
49
|
+
const f = join(appDir, `_extra${ext}`);
|
|
50
|
+
if (existsSync(f)) {
|
|
51
|
+
console.warn("[june] app/_extra is deprecated — rename it to app/_middleware.");
|
|
52
|
+
return f;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
42
55
|
return null;
|
|
43
56
|
}
|
|
44
57
|
|
|
@@ -58,6 +71,14 @@ function isPageFile(file: string) {
|
|
|
58
71
|
return base === "page" || base === "index";
|
|
59
72
|
}
|
|
60
73
|
|
|
74
|
+
// app/**/route.* — a RESOURCE route: a handler returning a raw Response (binary,
|
|
75
|
+
// custom content-type, webhook), not a React page. It's a first-class route (in
|
|
76
|
+
// the route table, params from the path, resolved by the SAME matcher), so it
|
|
77
|
+
// can't shadow pages by accident and doesn't need hand-rolled url matching.
|
|
78
|
+
export function isResourceFile(file: string): boolean {
|
|
79
|
+
return baseName(file) === "route";
|
|
80
|
+
}
|
|
81
|
+
|
|
61
82
|
// Special (never-a-route) files that shape the segment tree.
|
|
62
83
|
const SPECIAL_FILES = new Set(["layout", "loading", "error", "not-found"]);
|
|
63
84
|
|
|
@@ -118,7 +139,7 @@ function routePath(appDir: string, file: string) {
|
|
|
118
139
|
// Route groups shape the filesystem, not the URL.
|
|
119
140
|
const parts = withoutExtension.split("/").filter((p) => !isRouteGroup(p));
|
|
120
141
|
|
|
121
|
-
if (parts.at(-1) === "page" || parts.at(-1) === "index") {
|
|
142
|
+
if (parts.at(-1) === "page" || parts.at(-1) === "index" || parts.at(-1) === "route") {
|
|
122
143
|
parts.pop();
|
|
123
144
|
}
|
|
124
145
|
|
|
@@ -146,9 +167,12 @@ export async function matchRouteTree(
|
|
|
146
167
|
const entries = await listDir(dir);
|
|
147
168
|
const segments = [...chain, segmentAt(dir, entries)];
|
|
148
169
|
|
|
149
|
-
// Terminal: URL consumed → find the page in this dir.
|
|
170
|
+
// Terminal: URL consumed → find the page (or resource route) in this dir.
|
|
150
171
|
if (rest.length === 0) {
|
|
151
|
-
const page =
|
|
172
|
+
const page =
|
|
173
|
+
fileFor(entries, dir, "page") ??
|
|
174
|
+
fileFor(entries, dir, "index") ??
|
|
175
|
+
fileFor(entries, dir, "route");
|
|
152
176
|
if (page) return { file: page, params, segments };
|
|
153
177
|
} else if (!options.pageConvention) {
|
|
154
178
|
// Legacy flat convention: a non-special leaf FILE names the final segment
|
|
@@ -251,17 +275,19 @@ export async function routeFiles(
|
|
|
251
275
|
if (!routeExtensions.has(file.match(/\.[^.]+$/)?.[0] ?? "")) return false;
|
|
252
276
|
if (isSpecialFile(file)) return false;
|
|
253
277
|
if (file.split(sep).some((p) => p.startsWith("_"))) return false;
|
|
254
|
-
if (options.pageConvention && !isPageFile(file)) return false;
|
|
278
|
+
if (options.pageConvention && !isPageFile(file) && !isResourceFile(file)) return false;
|
|
255
279
|
return true;
|
|
256
280
|
});
|
|
257
281
|
}
|
|
258
282
|
|
|
259
|
-
// All route paths under appDir (for sitemap / llms.txt / api-catalog).
|
|
283
|
+
// All PAGE route paths under appDir (for sitemap / llms.txt / api-catalog).
|
|
284
|
+
// Resource routes (route.*) are excluded — they're machine endpoints (og images,
|
|
285
|
+
// webhooks), not pages, so they don't belong in the human/agent sitemap.
|
|
260
286
|
export async function listRoutes(
|
|
261
287
|
appDir: string,
|
|
262
288
|
options: MatchOptions = {},
|
|
263
289
|
): Promise<string[]> {
|
|
264
|
-
const files = await routeFiles(appDir, options);
|
|
290
|
+
const files = (await routeFiles(appDir, options)).filter((file) => !isResourceFile(file));
|
|
265
291
|
return [...new Set(files.map((file) => routePath(appDir, file)))].sort();
|
|
266
292
|
}
|
|
267
293
|
|
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,22 +14,35 @@ 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
|
-
import { createPipeline, type ExtraHandler, type LayoutComponent, type LoadingComponent, type Resolved } from "./pipeline";
|
|
20
|
+
import { createPipeline, type ExtraHandler, type LayoutComponent, type LoadingComponent, type Resolved, type ResolvedResource, type ResourceHandler } from "./pipeline";
|
|
20
21
|
|
|
21
22
|
export type WorkerManifest = {
|
|
22
23
|
// Static paths → route definitions ("/", "/users", ...).
|
|
23
24
|
routes: Record<string, BrandedRoute>;
|
|
24
25
|
// Dynamic patterns in file-route syntax ("/posts/[slug]", "/docs/[...path]").
|
|
25
26
|
dynamicRoutes?: Array<{ pattern: string; def: BrandedRoute }>;
|
|
27
|
+
// Resource routes (app/**/route.*): a raw-Response handler keyed by its pattern
|
|
28
|
+
// (static or dynamic). Matched after pages, so a page never gets shadowed.
|
|
29
|
+
resourceRoutes?: Array<{ pattern: string; handler: ResourceHandler }>;
|
|
26
30
|
// Layout chains (root→leaf) keyed by route path / dynamic pattern. The build
|
|
27
31
|
// freezes the same chain the dev server loads from app/layout.* files.
|
|
28
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 }>;
|
|
29
38
|
// Nearest loading.tsx per route path → streaming Suspense fallback.
|
|
30
39
|
loadings?: Record<string, LoadingComponent>;
|
|
31
40
|
document: DocumentConfig;
|
|
32
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;
|
|
33
46
|
// Preload Link values (config earlyHints + auto font hints), frozen at build.
|
|
34
47
|
earlyHints?: string[];
|
|
35
48
|
htmlCacheControl?: string;
|
|
@@ -93,6 +106,11 @@ export function createWorker(
|
|
|
93
106
|
def: d.def,
|
|
94
107
|
pattern: d.pattern,
|
|
95
108
|
}));
|
|
109
|
+
const resources = (manifest.resourceRoutes ?? []).map((r) => ({
|
|
110
|
+
...compilePattern(r.pattern),
|
|
111
|
+
handler: r.handler,
|
|
112
|
+
pattern: r.pattern,
|
|
113
|
+
}));
|
|
96
114
|
const routeList = [
|
|
97
115
|
...Object.keys(manifest.routes),
|
|
98
116
|
...(manifest.dynamicRoutes ?? []).map((d) => d.pattern),
|
|
@@ -100,6 +118,8 @@ export function createWorker(
|
|
|
100
118
|
|
|
101
119
|
const chainFor = (key: string): LayoutComponent[] => manifest.layoutChains?.[key] ?? [];
|
|
102
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;
|
|
103
123
|
|
|
104
124
|
// The worker's env (D1/KV/R2 bindings) arrives per fetch and is stable across
|
|
105
125
|
// requests in an isolate; we capture the latest and hand it to the env-aware
|
|
@@ -110,16 +130,26 @@ export function createWorker(
|
|
|
110
130
|
const pipeline = createPipeline({
|
|
111
131
|
docConfig: manifest.document,
|
|
112
132
|
agent: manifest.agent,
|
|
133
|
+
i18n: manifest.i18n,
|
|
113
134
|
routeList: () => routeList,
|
|
114
135
|
earlyHints: manifest.earlyHints,
|
|
115
136
|
htmlCacheControl: manifest.htmlCacheControl,
|
|
116
137
|
notFoundComponent: manifest.notFound,
|
|
117
138
|
extra: manifest.extra,
|
|
118
139
|
resources: provider ? () => provider(currentEnv) : undefined,
|
|
119
|
-
resolve: async (pathname): Promise<Resolved | null> => {
|
|
140
|
+
resolve: async (pathname): Promise<Resolved | ResolvedResource | null> => {
|
|
120
141
|
const staticDef = manifest.routes[pathname];
|
|
121
|
-
if (staticDef)
|
|
122
|
-
|
|
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
|
+
}
|
|
123
153
|
for (const d of dynamic) {
|
|
124
154
|
const m = pathname.match(d.regex);
|
|
125
155
|
if (m) {
|
|
@@ -131,7 +161,28 @@ export function createWorker(
|
|
|
131
161
|
return v === undefined ? [] : [[n, decodeURIComponent(v)]];
|
|
132
162
|
}),
|
|
133
163
|
);
|
|
134
|
-
|
|
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
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// Resource routes after pages (so a page is never shadowed by one).
|
|
176
|
+
for (const r of resources) {
|
|
177
|
+
const m = pathname.match(r.regex);
|
|
178
|
+
if (m) {
|
|
179
|
+
const params = Object.fromEntries(
|
|
180
|
+
r.names.flatMap((n, i) => {
|
|
181
|
+
const v = m[i + 1];
|
|
182
|
+
return v === undefined ? [] : [[n, decodeURIComponent(v)]];
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
return { handler: r.handler, params };
|
|
135
186
|
}
|
|
136
187
|
}
|
|
137
188
|
return null;
|