@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junejs/server",
3
- "version": "0.0.21",
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.21",
34
- "@junejs/db": "0.0.21",
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 { findExtraFile, listRoutes, matchRouteTree, resolveNotFound, routeFiles, type SegmentMatch } from "./router";
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
- const layoutCache = new Map<string, LayoutComponent>();
44
- async function loadLayout(file: string): Promise<LayoutComponent | null> {
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 { default?: LayoutComponent };
48
- if (typeof mod.default !== "function") return null;
49
- layoutCache.set(file, mod.default);
50
- return mod.default;
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
- async function loadChain(segments: SegmentMatch[]): Promise<LayoutComponent[]> {
68
- const chain: LayoutComponent[] = [];
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
- if (L) chain.push(L);
88
+ items.push({ file: seg.layout, entry: L?.component ?? null, boundary: !!L?.boundary });
73
89
  }
74
- return chain;
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 = findExtraFile(appDir);
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): Promise<Resolved | null> => {
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: await loadChain(match.segments),
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 { findExtraFile } from "./router";
35
- import type { ExtraHandler, LayoutComponent, LoadingComponent } from "./pipeline";
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
- if (!PAGE_BASENAMES.has(basename(e.name, ext))) continue;
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
- async function importLayout(file: string): Promise<LayoutComponent | null> {
168
- const mod = (await import(pathToFileURL(file).href)) as { default?: LayoutComponent };
169
- return typeof mod.default === "function" ? mod.default : null;
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, LayoutComponent | null>();
181
- const componentsFor = async (files: string[]): Promise<LayoutComponent[]> => {
182
- const chain: LayoutComponent[] = [];
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
- if (!layoutCache.has(f)) layoutCache.set(f, await importLayout(f));
185
- const c = layoutCache.get(f) ?? null;
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 chain;
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)) loadingCache.set(file, await importLayout(file) as LoadingComponent | null);
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 = findExtraFile(appDir);
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
- chains.push(` ${JSON.stringify(r.path)}: [${r.layouts.map(layoutId).join(", ")}],`);
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
- const builtExtraFile = findExtraFile(appDir);
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 `/.md` / `/.json` (negotiate strips
491
- // the ext back to `/`); withAssets serves them for `Accept: text/markdown`.
492
- const mdReq = r.path === "/" ? "/.md" : `${r.path}.md`;
493
- const jsonReq = r.path === "/" ? "/.json" : `${r.path}.json`;
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
- const es = new EventSource(${JSON.stringify(EVENTS_PATH)});
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
- export function negotiate(url: URL, request: Request): Negotiated {
35
- let pathname = url.pathname;
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
- export type RouteResolver = (pathname: string) => Promise<Resolved | null>;
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 type ExtraHandler = (
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, { config: docConfigForRender(), metadata, children: wrapped }),
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
- const wrapped = chain.reduceRight<React.ReactNode>(
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, { config: docConfigForRender(), metadata, children: wrapped }),
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(target: RenderTarget, pathname: string): Promise<Response> | Response {
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
- if (target === "fragment") return renderFragment(node, meta, chain);
312
- return renderDocument(node, meta, 200, chain);
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/_extra.* — the pre-route escape hatch (a `_` file, so never a route).
35
- // Dev and the build look it up through this ONE helper so the conventions
36
- // cannot drift.
37
- export function findExtraFile(appDir: string): string | null {
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, `_extra${ext}`);
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 = fileFor(entries, dir, "page") ?? fileFor(entries, dir, "index");
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
- return { def: staticDef, params: {}, chain: chainFor(pathname), loading: loadingFor(pathname) };
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
- return { def: d.def, params, chain: chainFor(d.pattern), loading: loadingFor(d.pattern) };
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;