@junejs/server 0.0.22 → 0.0.24

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