@junejs/server 0.1.2 → 0.1.3

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/dist/build.d.ts CHANGED
@@ -15,6 +15,7 @@ type RouteEntry = {
15
15
  file: string;
16
16
  dynamic: boolean;
17
17
  resource?: boolean;
18
+ generated?: boolean;
18
19
  layouts: string[];
19
20
  loading?: string;
20
21
  };
package/dist/build.js CHANGED
@@ -193,7 +193,11 @@ async function buildManifest(appRoot) {
193
193
  const appRoutes = await scanRoutes(appDir);
194
194
  const frameworkRoutes = existsSync(juneRoutesDir) ? await scanRoutes(juneRoutesDir) : [];
195
195
  const appPaths = new Set(appRoutes.map((r) => r.path));
196
- const scanned = [...appRoutes, ...frameworkRoutes.filter((r) => !appPaths.has(r.path))].sort((a, b) => a.path.localeCompare(b.path));
196
+ const scanned = [...appRoutes, ...frameworkRoutes.filter((r) => !appPaths.has(r.path)).map((r) => ({
197
+ ...r,
198
+ generated: true
199
+ }))].sort((a, b) => a.path.localeCompare(b.path));
200
+ const generatedRoutes = scanned.filter((r) => r.generated).map((r) => r.path);
197
201
  const layoutCache = /* @__PURE__ */ new Map();
198
202
  const loadCached = async (f) => {
199
203
  if (!layoutCache.has(f)) layoutCache.set(f, await importLayout(f));
@@ -266,6 +270,7 @@ async function buildManifest(appRoot) {
266
270
  routes,
267
271
  dynamicRoutes,
268
272
  resourceRoutes,
273
+ ...generatedRoutes.length ? { generatedRoutes } : {},
269
274
  layoutChains,
270
275
  layoutBoundaries,
271
276
  loadings,
@@ -304,7 +309,10 @@ async function juneBuild(appRoot, options = {}) {
304
309
  const appRoutes2 = await scanRoutes(appDir);
305
310
  const frameworkRoutes2 = existsSync(juneRoutesDir2) ? await scanRoutes(juneRoutesDir2) : [];
306
311
  const appPaths2 = new Set(appRoutes2.map((r) => r.path));
307
- const routes = [...appRoutes2, ...frameworkRoutes2.filter((r) => !appPaths2.has(r.path))].sort((a, b) => a.path.localeCompare(b.path));
312
+ const routes = [...appRoutes2, ...frameworkRoutes2.filter((r) => !appPaths2.has(r.path)).map((r) => ({
313
+ ...r,
314
+ generated: true
315
+ }))].sort((a, b) => a.path.localeCompare(b.path));
308
316
  if (routes.length === 0) throw new Error(`no page.* routes found under ${appDir} or .june/routes/`);
309
317
  const frozen = await freezeConfig(appRoot);
310
318
  if (frozen.i18n?.resolveLocale) console.warn("[june build] i18n.resolveLocale is not yet wired into the built worker (URL-pinned + built-in negotiation work; the hook runs in dev only).");
@@ -392,6 +400,8 @@ async function juneBuild(appRoot, options = {}) {
392
400
  if (r.loading) loadings.push(` ${JSON.stringify(r.path)}: ${loadingId(r.loading)},`);
393
401
  });
394
402
  const resourceRoutesField = resources.length ? `\n resourceRoutes: [\n${resources.join("\n")}\n ],` : "";
403
+ const generatedPaths = routes.filter((r) => r.generated).map((r) => r.path);
404
+ const generatedRoutesField = generatedPaths.length ? `\n generatedRoutes: ${JSON.stringify(generatedPaths)},` : "";
395
405
  const layoutBoundariesField = boundaries.length ? `\n layoutBoundaries: {\n${boundaries.join("\n")}\n },` : "";
396
406
  const builtExtraFile = findMiddlewareFile(appDir);
397
407
  if (builtExtraFile) imports.push(`import extra from ${JSON.stringify(importPath(genDir, builtExtraFile))};`);
@@ -427,7 +437,7 @@ ${statics.join("\n")}
427
437
  },
428
438
  dynamicRoutes: [
429
439
  ${dynamics.join("\n")}
430
- ],${resourceRoutesField}
440
+ ],${resourceRoutesField}${generatedRoutesField}
431
441
  layoutChains: {
432
442
  ${chains.join("\n")}
433
443
  },${layoutBoundariesField}
@@ -0,0 +1,11 @@
1
+ //#region src/route-rank.d.ts
2
+ type SegmentKind = "static" | "param" | "optional" | "catchAll" | "optionalCatchAll";
3
+ declare function parseSegment(segment: string): {
4
+ kind: SegmentKind;
5
+ name?: string;
6
+ };
7
+ declare function segmentRank(segment: string): number;
8
+ declare function compareSegments(a: string, b: string): number;
9
+ declare function compareRoutePatterns(a: string, b: string): number;
10
+ //#endregion
11
+ export { SegmentKind, compareRoutePatterns, compareSegments, parseSegment, segmentRank };
@@ -0,0 +1,38 @@
1
+ //#region src/route-rank.ts
2
+ const DYNAMIC = /^\[(\[)?(\.\.\.)?([A-Za-z_][A-Za-z0-9_]*)\](\])?$/;
3
+ function parseSegment(segment) {
4
+ const m = segment.match(DYNAMIC);
5
+ if (!m || !!m[1] !== !!m[4]) return { kind: "static" };
6
+ const optional = !!m[1];
7
+ return {
8
+ kind: !!m[2] ? optional ? "optionalCatchAll" : "catchAll" : optional ? "optional" : "param",
9
+ name: m[3]
10
+ };
11
+ }
12
+ const RANK = {
13
+ static: 0,
14
+ param: 1,
15
+ optional: 2,
16
+ catchAll: 3,
17
+ optionalCatchAll: 4
18
+ };
19
+ function segmentRank(segment) {
20
+ return RANK[parseSegment(segment).kind];
21
+ }
22
+ function compareSegments(a, b) {
23
+ return segmentRank(a) - segmentRank(b) || (a < b ? -1 : a > b ? 1 : 0);
24
+ }
25
+ function compareRoutePatterns(a, b) {
26
+ const as = a.split("/").filter(Boolean);
27
+ const bs = b.split("/").filter(Boolean);
28
+ for (let i = 0; i < Math.max(as.length, bs.length); i++) {
29
+ const x = as[i];
30
+ const y = bs[i];
31
+ if (x === void 0) return -1;
32
+ if (y === void 0) return 1;
33
+ if (x !== y) return compareSegments(x, y);
34
+ }
35
+ return 0;
36
+ }
37
+ //#endregion
38
+ export { compareRoutePatterns, compareSegments, parseSegment, segmentRank };
package/dist/router.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { compareSegments, parseSegment } from "./route-rank.js";
1
2
  import { existsSync } from "node:fs";
2
3
  import { readdir } from "node:fs/promises";
3
4
  import { join, relative, sep } from "node:path";
@@ -42,11 +43,11 @@ function isSpecialFile(file) {
42
43
  return SPECIAL_FILES.has(baseName(file));
43
44
  }
44
45
  const isRouteGroup = (name) => /^\(.+\)$/.test(name);
45
- const isParamDir = (name) => /^\[([A-Za-z_][A-Za-z0-9_]*)\]$/.test(name);
46
- const isOptionalDir = (name) => /^\[\[([A-Za-z_][A-Za-z0-9_]*)\]\]$/.test(name);
47
- const isOptionalCatchAllDir = (name) => /^\[\[\.\.\.([A-Za-z_][A-Za-z0-9_]*)\]\]$/.test(name);
48
- const isCatchAllDir = (name) => /^\[\.\.\.([A-Za-z_][A-Za-z0-9_]*)\]$/.test(name);
49
- const paramName = (name) => name.replace(/^\[+(\.\.\.)?|\]+$/g, "");
46
+ const isParamDir = (name) => parseSegment(name).kind === "param";
47
+ const isOptionalDir = (name) => parseSegment(name).kind === "optional";
48
+ const isOptionalCatchAllDir = (name) => parseSegment(name).kind === "optionalCatchAll";
49
+ const isCatchAllDir = (name) => parseSegment(name).kind === "catchAll";
50
+ const paramName = (name) => parseSegment(name).name;
50
51
  async function listDir(dir) {
51
52
  return (await readdir(dir, { withFileTypes: true }).catch(() => [])).filter((e) => !e.name.startsWith("_") && !e.name.startsWith(".")).map((e) => ({
52
53
  name: e.name,
@@ -85,7 +86,7 @@ function routePath(appDir, file) {
85
86
  async function matchRouteTree(appDir, pathname, options = {}) {
86
87
  const urlSegments = pathname.split("/").filter(Boolean).map(decodeURIComponent);
87
88
  async function descend(dir, rest, params, chain) {
88
- const entries = await listDir(dir);
89
+ const entries = (await listDir(dir)).sort((a, b) => compareSegments(a.name, b.name));
89
90
  const segments = [...chain, segmentAt(dir, entries)];
90
91
  if (rest.length === 0) {
91
92
  const page = fileFor(entries, dir, "page") ?? fileFor(entries, dir, "index") ?? fileFor(entries, dir, "route");
package/dist/worker.d.ts CHANGED
@@ -16,6 +16,7 @@ type WorkerManifest = {
16
16
  pattern: string;
17
17
  handler: ResourceHandler;
18
18
  }>;
19
+ generatedRoutes?: string[];
19
20
  layoutChains?: Record<string, LayoutComponent[]>;
20
21
  layoutBoundaries?: Record<string, {
21
22
  index: number;
package/dist/worker.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { durableAgentSurface } from "./agent-durable.js";
2
+ import { compareRoutePatterns, parseSegment } from "./route-rank.js";
2
3
  import { createPipeline } from "./pipeline.js";
3
4
  import { RESERVED_PREFIX, contentTypeFor, safeRelativePath } from "./static-files.js";
4
5
  //#region src/worker.ts
@@ -7,34 +8,16 @@ function compilePattern(pattern) {
7
8
  let source = "";
8
9
  let allOptional = true;
9
10
  for (const seg of pattern.split("/").filter(Boolean)) {
10
- const oca = seg.match(/^\[\[\.\.\.(\w+)\]\]$/);
11
- if (oca) {
12
- names.push(oca[1]);
13
- source += "(?:/(.+))?";
14
- continue;
15
- }
16
- const ca = seg.match(/^\[\.\.\.(\w+)\]$/);
17
- if (ca) {
18
- names.push(ca[1]);
19
- source += "/(.+)";
20
- allOptional = false;
21
- continue;
22
- }
23
- const op = seg.match(/^\[\[(\w+)\]\]$/);
24
- if (op) {
25
- names.push(op[1]);
26
- source += "(?:/([^/]+))?";
27
- continue;
28
- }
29
- const p = seg.match(/^\[(\w+)\]$/);
30
- if (p) {
31
- names.push(p[1]);
32
- source += "/([^/]+)";
11
+ const { kind, name } = parseSegment(seg);
12
+ if (name) names.push(name);
13
+ if (kind === "optionalCatchAll") source += "(?:/(.+))?";
14
+ else if (kind === "optional") source += "(?:/([^/]+))?";
15
+ else {
16
+ if (kind === "catchAll") source += "/(.+)";
17
+ else if (kind === "param") source += "/([^/]+)";
18
+ else source += "/" + seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
33
19
  allOptional = false;
34
- continue;
35
20
  }
36
- source += "/" + seg.replace(/[.*+?^${}()|\\]/g, "\\$&");
37
- allOptional = false;
38
21
  }
39
22
  if (source === "") return {
40
23
  regex: /^\/$/,
@@ -46,20 +29,42 @@ function compilePattern(pattern) {
46
29
  };
47
30
  }
48
31
  function createWorker(manifest) {
49
- const dynamic = (manifest.dynamicRoutes ?? []).map((d) => ({
50
- ...compilePattern(d.pattern),
51
- def: d.def,
52
- pattern: d.pattern
53
- }));
54
- const resources = (manifest.resourceRoutes ?? []).map((r) => ({
55
- ...compilePattern(r.pattern),
56
- handler: r.handler,
57
- pattern: r.pattern
58
- }));
32
+ const generated = new Set(manifest.generatedRoutes ?? []);
33
+ const row = (pattern) => ({
34
+ ...compilePattern(pattern),
35
+ pattern,
36
+ generated: generated.has(pattern)
37
+ });
38
+ const table = [
39
+ ...Object.entries(manifest.routes).map(([pattern, def]) => ({
40
+ ...row(pattern),
41
+ def
42
+ })),
43
+ ...(manifest.dynamicRoutes ?? []).map((d) => ({
44
+ ...row(d.pattern),
45
+ def: d.def
46
+ })),
47
+ ...(manifest.resourceRoutes ?? []).map((r) => ({
48
+ ...row(r.pattern),
49
+ handler: r.handler
50
+ }))
51
+ ];
52
+ table.sort((a, b) => Number(a.generated) - Number(b.generated) || compareRoutePatterns(a.pattern, b.pattern) || Number(!!a.handler) - Number(!!b.handler));
59
53
  const routeList = [...Object.keys(manifest.routes), ...(manifest.dynamicRoutes ?? []).map((d) => d.pattern)].sort();
60
54
  const chainFor = (key) => manifest.layoutChains?.[key] ?? [];
61
55
  const loadingFor = (key) => manifest.loadings?.[key];
62
56
  const boundaryFor = (key) => manifest.layoutBoundaries?.[key] ?? null;
57
+ const resolvedPage = (def, key, params) => {
58
+ const b = boundaryFor(key);
59
+ return {
60
+ def,
61
+ params,
62
+ chain: chainFor(key),
63
+ boundaryIndex: b?.index ?? null,
64
+ boundaryKey: b?.key ?? null,
65
+ loading: loadingFor(key)
66
+ };
67
+ };
63
68
  let currentEnv;
64
69
  const provider = manifest.resources;
65
70
  const servicesProvider = manifest.services;
@@ -85,47 +90,18 @@ function createWorker(manifest) {
85
90
  services: servicesProvider ? () => (servicesCache ??= { v: servicesProvider(currentEnv) }).v : void 0,
86
91
  resolve: async (pathname) => {
87
92
  const staticDef = manifest.routes[pathname];
88
- if (staticDef) {
89
- const b = boundaryFor(pathname);
90
- return {
91
- def: staticDef,
92
- params: {},
93
- chain: chainFor(pathname),
94
- boundaryIndex: b?.index ?? null,
95
- boundaryKey: b?.key ?? null,
96
- loading: loadingFor(pathname)
97
- };
98
- }
99
- for (const d of dynamic) {
100
- const m = pathname.match(d.regex);
101
- if (m) {
102
- const params = Object.fromEntries(d.names.flatMap((n, i) => {
103
- const v = m[i + 1];
104
- return v === void 0 ? [] : [[n, decodeURIComponent(v)]];
105
- }));
106
- const b = boundaryFor(d.pattern);
107
- return {
108
- def: d.def,
109
- params,
110
- chain: chainFor(d.pattern),
111
- boundaryIndex: b?.index ?? null,
112
- boundaryKey: b?.key ?? null,
113
- loading: loadingFor(d.pattern)
114
- };
115
- }
116
- }
117
- for (const r of resources) {
93
+ if (staticDef && !generated.has(pathname)) return resolvedPage(staticDef, pathname, {});
94
+ for (const r of table) {
118
95
  const m = pathname.match(r.regex);
119
- if (m) {
120
- const params = Object.fromEntries(r.names.flatMap((n, i) => {
121
- const v = m[i + 1];
122
- return v === void 0 ? [] : [[n, decodeURIComponent(v)]];
123
- }));
124
- return {
125
- handler: r.handler,
126
- params
127
- };
128
- }
96
+ if (!m) continue;
97
+ const params = Object.fromEntries(r.names.flatMap((n, i) => {
98
+ const v = m[i + 1];
99
+ return v === void 0 ? [] : [[n, decodeURIComponent(v)]];
100
+ }));
101
+ return r.handler ? {
102
+ handler: r.handler,
103
+ params
104
+ } : resolvedPage(r.def, r.pattern, params);
129
105
  }
130
106
  return null;
131
107
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junejs/server",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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",
package/src/build.ts CHANGED
@@ -60,6 +60,7 @@ type RouteEntry = {
60
60
  file: string;
61
61
  dynamic: boolean;
62
62
  resource?: boolean; // a route.* resource route (raw-Response handler), not a page
63
+ generated?: boolean; // scanned from .june/routes/ — ranks after every app/ route, as in dev
63
64
  layouts: string[];
64
65
  loading?: string; // nearest loading.tsx up the tree → streaming Suspense fallback
65
66
  };
@@ -372,8 +373,9 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
372
373
  const appPaths = new Set(appRoutes.map((r) => r.path));
373
374
  const scanned = [
374
375
  ...appRoutes,
375
- ...frameworkRoutes.filter((r) => !appPaths.has(r.path)),
376
+ ...frameworkRoutes.filter((r) => !appPaths.has(r.path)).map((r) => ({ ...r, generated: true })),
376
377
  ].sort((a, b) => a.path.localeCompare(b.path));
378
+ const generatedRoutes = scanned.filter((r) => r.generated).map((r) => r.path);
377
379
 
378
380
  const layoutCache = new Map<string, ImportedLayout | null>();
379
381
  const loadCached = async (f: string): Promise<ImportedLayout | null> => {
@@ -445,6 +447,7 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
445
447
  routes,
446
448
  dynamicRoutes,
447
449
  resourceRoutes,
450
+ ...(generatedRoutes.length ? { generatedRoutes } : {}),
448
451
  layoutChains,
449
452
  layoutBoundaries,
450
453
  loadings,
@@ -493,7 +496,7 @@ export async function juneBuild(
493
496
  const appPaths2 = new Set(appRoutes2.map((r) => r.path));
494
497
  const routes = [
495
498
  ...appRoutes2,
496
- ...frameworkRoutes2.filter((r) => !appPaths2.has(r.path)),
499
+ ...frameworkRoutes2.filter((r) => !appPaths2.has(r.path)).map((r) => ({ ...r, generated: true })),
497
500
  ].sort((a, b) => a.path.localeCompare(b.path));
498
501
  if (routes.length === 0) throw new Error(`no page.* routes found under ${appDir} or .june/routes/`);
499
502
 
@@ -650,6 +653,12 @@ export async function juneBuild(
650
653
  const resourceRoutesField = resources.length
651
654
  ? `\n resourceRoutes: [\n${resources.join("\n")}\n ],`
652
655
  : "";
656
+ // .june/routes/ patterns rank after every app/ route (dev falls back to them).
657
+ // Only emitted when there are some, so bundles without them stay byte-identical.
658
+ const generatedPaths = routes.filter((r) => r.generated).map((r) => r.path);
659
+ const generatedRoutesField = generatedPaths.length
660
+ ? `\n generatedRoutes: ${JSON.stringify(generatedPaths)},`
661
+ : "";
653
662
  // Only emitted when some route declares a boundary, so boundary-less bundles
654
663
  // stay byte-identical (additive manifest field, like resources).
655
664
  const layoutBoundariesField = boundaries.length
@@ -733,7 +742,7 @@ ${statics.join("\n")}
733
742
  },
734
743
  dynamicRoutes: [
735
744
  ${dynamics.join("\n")}
736
- ],${resourceRoutesField}
745
+ ],${resourceRoutesField}${generatedRoutesField}
737
746
  layoutChains: {
738
747
  ${chains.join("\n")}
739
748
  },${layoutBoundariesField}
@@ -0,0 +1,60 @@
1
+ // Route precedence — the ONE ranking both resolvers use (#312). The dev matcher
2
+ // (router.ts, matchRouteTree) walks the app tree trying each level's children in
3
+ // this order; the built worker (worker.ts) cannot walk a tree, so it sorts its
4
+ // flat route table with compareRoutePatterns and takes the first regex match.
5
+ // For patterns that both match a URL, the depth-first walk and the sorted table
6
+ // pick the same winner: the first segment where two patterns differ decides.
7
+ //
8
+ // Worker-safe: no node:* imports.
9
+
10
+ export type SegmentKind = "static" | "param" | "optional" | "catchAll" | "optionalCatchAll";
11
+
12
+ // The one segment grammar: a param name is an identifier ([A-Za-z_][A-Za-z0-9_]*).
13
+ // Anything else in brackets (`[1]`, `[foo-bar]`, `[slug].png`) is a STATIC
14
+ // segment named literally, in dev, in the ranking and in the worker's regex.
15
+ const DYNAMIC = /^\[(\[)?(\.\.\.)?([A-Za-z_][A-Za-z0-9_]*)\](\])?$/;
16
+
17
+ export function parseSegment(segment: string): { kind: SegmentKind; name?: string } {
18
+ const m = segment.match(DYNAMIC);
19
+ // Brackets must pair: `[[x]]` or `[x]`, never `[[x]` / `[x]]`.
20
+ if (!m || !!m[1] !== !!m[4]) return { kind: "static" };
21
+ const optional = !!m[1];
22
+ const catchAll = !!m[2];
23
+ const kind: SegmentKind = catchAll
24
+ ? optional ? "optionalCatchAll" : "catchAll"
25
+ : optional ? "optional" : "param";
26
+ return { kind, name: m[3] };
27
+ }
28
+
29
+ const RANK: Record<SegmentKind, number> = { static: 0, param: 1, optional: 2, catchAll: 3, optionalCatchAll: 4 };
30
+
31
+ // Per-segment rank, most specific first: static, [param], [[param]], [...rest],
32
+ // [[...rest]]. Optional sits after its required form, so `[slug]` answers
33
+ // before `[[slug]]` when a segment is present.
34
+ export function segmentRank(segment: string): number {
35
+ return RANK[parseSegment(segment).kind];
36
+ }
37
+
38
+ // Sibling order within one directory level: by rank, then by name so a tie
39
+ // (two [param] dirs) resolves the same on every filesystem.
40
+ export function compareSegments(a: string, b: string): number {
41
+ return segmentRank(a) - segmentRank(b) || (a < b ? -1 : a > b ? 1 : 0);
42
+ }
43
+
44
+ // Order two file-route patterns ("/docs/[[...path]]", "/feed.xml") by
45
+ // precedence. The first differing segment decides; a pattern that ends there
46
+ // beats one that continues, since only absent optional segments can follow a
47
+ // match that already consumed the URL (dev checks a directory's own page before
48
+ // descending into its optional children).
49
+ export function compareRoutePatterns(a: string, b: string): number {
50
+ const as = a.split("/").filter(Boolean);
51
+ const bs = b.split("/").filter(Boolean);
52
+ for (let i = 0; i < Math.max(as.length, bs.length); i++) {
53
+ const x = as[i];
54
+ const y = bs[i];
55
+ if (x === undefined) return -1;
56
+ if (y === undefined) return 1;
57
+ if (x !== y) return compareSegments(x, y);
58
+ }
59
+ return 0;
60
+ }
package/src/router.ts CHANGED
@@ -6,6 +6,8 @@ import { existsSync } from "node:fs";
6
6
  import { readdir } from "node:fs/promises";
7
7
  import { join, relative, sep } from "node:path";
8
8
 
9
+ import { compareSegments, parseSegment } from "./route-rank";
10
+
9
11
  export type RouteMatch = {
10
12
  file: string;
11
13
  params: Record<string, string>;
@@ -87,13 +89,13 @@ function isSpecialFile(file: string) {
87
89
  }
88
90
 
89
91
  const isRouteGroup = (name: string) => /^\(.+\)$/.test(name);
90
- const isParamDir = (name: string) => /^\[([A-Za-z_][A-Za-z0-9_]*)\]$/.test(name);
92
+ const isParamDir = (name: string) => parseSegment(name).kind === "param";
91
93
  // [[slug]] / [[...slug]] — match like their required forms, and ALSO match the
92
94
  // segment being absent (the param is then simply missing from ctx.params).
93
- const isOptionalDir = (name: string) => /^\[\[([A-Za-z_][A-Za-z0-9_]*)\]\]$/.test(name);
94
- const isOptionalCatchAllDir = (name: string) => /^\[\[\.\.\.([A-Za-z_][A-Za-z0-9_]*)\]\]$/.test(name);
95
- const isCatchAllDir = (name: string) => /^\[\.\.\.([A-Za-z_][A-Za-z0-9_]*)\]$/.test(name);
96
- const paramName = (name: string) => name.replace(/^\[+(\.\.\.)?|\]+$/g, "");
95
+ const isOptionalDir = (name: string) => parseSegment(name).kind === "optional";
96
+ const isOptionalCatchAllDir = (name: string) => parseSegment(name).kind === "optionalCatchAll";
97
+ const isCatchAllDir = (name: string) => parseSegment(name).kind === "catchAll";
98
+ const paramName = (name: string) => parseSegment(name).name!;
97
99
 
98
100
  type DirEntry = { name: string; dir: boolean };
99
101
 
@@ -147,8 +149,10 @@ function routePath(appDir: string, file: string) {
147
149
  }
148
150
 
149
151
  // Recursive-descent matcher over the app directory. Priority at every level:
150
- // exact static segment > [param] > [...catchAll]; route groups `(name)` descend
151
- // without consuming a URL segment; `_`-prefixed entries never participate.
152
+ // exact static segment > [param] > [...catchAll], required before optional, ties
153
+ // by name (route-rank.ts — the built worker sorts its route table by the same
154
+ // ranking); route groups `(name)` descend without consuming a URL segment;
155
+ // `_`-prefixed entries never participate.
152
156
  // Returns the page file, accumulated params (catch-all joins with "/"), and the
153
157
  // chain of segments (with their special files) from the app root to the page.
154
158
  export async function matchRouteTree(
@@ -164,7 +168,8 @@ export async function matchRouteTree(
164
168
  params: Record<string, string>,
165
169
  chain: SegmentMatch[],
166
170
  ): Promise<RouteTreeMatch | null> {
167
- const entries = await listDir(dir);
171
+ // Ranked, not readdir order, so the pick is the same on every filesystem.
172
+ const entries = (await listDir(dir)).sort((a, b) => compareSegments(a.name, b.name));
168
173
  const segments = [...chain, segmentAt(dir, entries)];
169
174
 
170
175
  // Terminal: URL consumed → find the page (or resource route) in this dir.
package/src/worker.ts CHANGED
@@ -19,6 +19,7 @@ import type { Resources } from "@junejs/core/resources";
19
19
 
20
20
  import { createPipeline, type ExtraHandler, type LayoutComponent, type LoadingComponent, type Resolved, type ResolvedResource, type ResourceHandler } from "./pipeline";
21
21
  import { durableAgentSurface, type DurableObjectNamespace } from "./agent-durable";
22
+ import { compareRoutePatterns, parseSegment } from "./route-rank";
22
23
  import { contentTypeFor, RESERVED_PREFIX, safeRelativePath } from "./static-files";
23
24
 
24
25
  export type WorkerManifest = {
@@ -27,8 +28,13 @@ export type WorkerManifest = {
27
28
  // Dynamic patterns in file-route syntax ("/posts/[slug]", "/docs/[...path]").
28
29
  dynamicRoutes?: Array<{ pattern: string; def: BrandedRoute }>;
29
30
  // Resource routes (app/**/route.*): a raw-Response handler keyed by its pattern
30
- // (static or dynamic). Matched after pages, so a page never gets shadowed.
31
+ // (static or dynamic). Ranked with the pages by segment shape (route-rank.ts),
32
+ // exactly as `june dev` resolves; a page wins only at the SAME pattern.
31
33
  resourceRoutes?: Array<{ pattern: string; handler: ResourceHandler }>;
34
+ // Patterns scanned from .june/routes/ (framework-generated, e.g. Kura). Dev
35
+ // consults app/ first and falls back to .june/routes/, so every one of these
36
+ // ranks after every app/ route. Absent → all routes are app/ routes.
37
+ generatedRoutes?: string[];
32
38
  // Layout chains (root→leaf) keyed by route path / dynamic pattern. The build
33
39
  // freezes the same chain the dev server loads from app/layout.* files.
34
40
  layoutChains?: Record<string, LayoutComponent[]>;
@@ -66,7 +72,13 @@ export type WorkerManifest = {
66
72
  services?: (env?: unknown) => unknown;
67
73
  };
68
74
 
69
- type Compiled = { regex: RegExp; names: string[]; def: BrandedRoute; pattern: string };
75
+ // One row of the worker's route table: a page (def) or a resource route (handler).
76
+ type Compiled = {
77
+ regex: RegExp;
78
+ names: string[];
79
+ pattern: string;
80
+ generated: boolean;
81
+ } & ({ def: BrandedRoute; handler?: undefined } | { def?: undefined; handler: ResourceHandler });
70
82
 
71
83
  // "/posts/[slug]" | "/docs/[...path]" | "/notes/[[tag]]" → matcher. Optional
72
84
  // segments ([[x]], [[...x]]) wrap slash + capture together so absence matches;
@@ -76,34 +88,19 @@ function compilePattern(pattern: string): { regex: RegExp; names: string[] } {
76
88
  let source = "";
77
89
  let allOptional = true;
78
90
  for (const seg of pattern.split("/").filter(Boolean)) {
79
- const oca = seg.match(/^\[\[\.\.\.(\w+)\]\]$/);
80
- if (oca) {
81
- names.push(oca[1]!);
82
- source += "(?:/(.+))?";
83
- continue;
84
- }
85
- const ca = seg.match(/^\[\.\.\.(\w+)\]$/);
86
- if (ca) {
87
- names.push(ca[1]!);
88
- source += "/(.+)";
89
- allOptional = false;
90
- continue;
91
- }
92
- const op = seg.match(/^\[\[(\w+)\]\]$/);
93
- if (op) {
94
- names.push(op[1]!);
95
- source += "(?:/([^/]+))?";
96
- continue;
97
- }
98
- const p = seg.match(/^\[(\w+)\]$/);
99
- if (p) {
100
- names.push(p[1]!);
101
- source += "/([^/]+)";
91
+ // Same grammar as the dev matcher (route-rank.ts parseSegment).
92
+ const { kind, name } = parseSegment(seg);
93
+ if (name) names.push(name);
94
+ if (kind === "optionalCatchAll") source += "(?:/(.+))?";
95
+ else if (kind === "optional") source += "(?:/([^/]+))?";
96
+ else {
97
+ if (kind === "catchAll") source += "/(.+)";
98
+ else if (kind === "param") source += "/([^/]+)";
99
+ // A static segment matches literally: escape every RegExp metacharacter,
100
+ // brackets included (`docs[v2`, `[slug].png` are static names).
101
+ else source += "/" + seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
102
102
  allOptional = false;
103
- continue;
104
103
  }
105
- source += "/" + seg.replace(/[.*+?^${}()|\\]/g, "\\$&");
106
- allOptional = false;
107
104
  }
108
105
  if (source === "") return { regex: /^\/$/, names };
109
106
  return { regex: new RegExp(allOptional ? `^(?:${source}|/)$` : `^${source}$`), names };
@@ -112,16 +109,23 @@ function compilePattern(pattern: string): { regex: RegExp; names: string[] } {
112
109
  export function createWorker(
113
110
  manifest: WorkerManifest,
114
111
  ): { fetch(request: Request, env?: unknown): Promise<Response> } {
115
- const dynamic: Compiled[] = (manifest.dynamicRoutes ?? []).map((d) => ({
116
- ...compilePattern(d.pattern),
117
- def: d.def,
118
- pattern: d.pattern,
119
- }));
120
- const resources = (manifest.resourceRoutes ?? []).map((r) => ({
121
- ...compilePattern(r.pattern),
122
- handler: r.handler,
123
- pattern: r.pattern,
124
- }));
112
+ // Pages and resource routes in ONE table, ordered the way `june dev` resolves
113
+ // (#312): app/ before .june/routes/, then segment shape (static > [param] >
114
+ // [...catchAll], required before optional), then a page before a resource
115
+ // route at the same pattern. The kind of route never outranks the shape.
116
+ const generated = new Set(manifest.generatedRoutes ?? []);
117
+ const row = (pattern: string) => ({ ...compilePattern(pattern), pattern, generated: generated.has(pattern) });
118
+ const table: Compiled[] = [
119
+ ...Object.entries(manifest.routes).map(([pattern, def]) => ({ ...row(pattern), def })),
120
+ ...(manifest.dynamicRoutes ?? []).map((d) => ({ ...row(d.pattern), def: d.def })),
121
+ ...(manifest.resourceRoutes ?? []).map((r) => ({ ...row(r.pattern), handler: r.handler })),
122
+ ];
123
+ table.sort(
124
+ (a, b) =>
125
+ Number(a.generated) - Number(b.generated) ||
126
+ compareRoutePatterns(a.pattern, b.pattern) ||
127
+ Number(!!a.handler) - Number(!!b.handler),
128
+ );
125
129
  const routeList = [
126
130
  ...Object.keys(manifest.routes),
127
131
  ...(manifest.dynamicRoutes ?? []).map((d) => d.pattern),
@@ -131,6 +135,17 @@ export function createWorker(
131
135
  const loadingFor = (key: string): LoadingComponent | undefined => manifest.loadings?.[key];
132
136
  const boundaryFor = (key: string): { index: number; key: string } | null =>
133
137
  manifest.layoutBoundaries?.[key] ?? null;
138
+ const resolvedPage = (def: BrandedRoute, key: string, params: Record<string, string>): Resolved => {
139
+ const b = boundaryFor(key);
140
+ return {
141
+ def,
142
+ params,
143
+ chain: chainFor(key),
144
+ boundaryIndex: b?.index ?? null,
145
+ boundaryKey: b?.key ?? null,
146
+ loading: loadingFor(key),
147
+ };
148
+ };
134
149
 
135
150
  // The worker's env (D1/KV/R2 bindings) arrives per fetch and is stable across
136
151
  // requests in an isolate; we capture the latest and hand it to the env-aware
@@ -172,52 +187,21 @@ export function createWorker(
172
187
  resources: provider ? () => provider(currentEnv) : undefined,
173
188
  services: servicesProvider ? () => (servicesCache ??= { v: servicesProvider(currentEnv) }).v : undefined,
174
189
  resolve: async (pathname): Promise<Resolved | ResolvedResource | null> => {
190
+ // Fast path: an app/ static page outranks every other route for its path.
175
191
  const staticDef = manifest.routes[pathname];
176
- if (staticDef) {
177
- const b = boundaryFor(pathname);
178
- return {
179
- def: staticDef,
180
- params: {},
181
- chain: chainFor(pathname),
182
- boundaryIndex: b?.index ?? null,
183
- boundaryKey: b?.key ?? null,
184
- loading: loadingFor(pathname),
185
- };
186
- }
187
- for (const d of dynamic) {
188
- const m = pathname.match(d.regex);
189
- if (m) {
190
- // Optional segments leave their capture undefined — the param is
191
- // then absent, matching the dev matcher's semantics.
192
- const params = Object.fromEntries(
193
- d.names.flatMap((n, i) => {
194
- const v = m[i + 1];
195
- return v === undefined ? [] : [[n, decodeURIComponent(v)]];
196
- }),
197
- );
198
- const b = boundaryFor(d.pattern);
199
- return {
200
- def: d.def,
201
- params,
202
- chain: chainFor(d.pattern),
203
- boundaryIndex: b?.index ?? null,
204
- boundaryKey: b?.key ?? null,
205
- loading: loadingFor(d.pattern),
206
- };
207
- }
208
- }
209
- // Resource routes after pages (so a page is never shadowed by one).
210
- for (const r of resources) {
192
+ if (staticDef && !generated.has(pathname)) return resolvedPage(staticDef, pathname, {});
193
+ for (const r of table) {
211
194
  const m = pathname.match(r.regex);
212
- if (m) {
213
- const params = Object.fromEntries(
214
- r.names.flatMap((n, i) => {
215
- const v = m[i + 1];
216
- return v === undefined ? [] : [[n, decodeURIComponent(v)]];
217
- }),
218
- );
219
- return { handler: r.handler, params };
220
- }
195
+ if (!m) continue;
196
+ // Optional segments leave their capture undefined — the param is
197
+ // then absent, matching the dev matcher's semantics.
198
+ const params = Object.fromEntries(
199
+ r.names.flatMap((n, i) => {
200
+ const v = m[i + 1];
201
+ return v === undefined ? [] : [[n, decodeURIComponent(v)]];
202
+ }),
203
+ );
204
+ return r.handler ? { handler: r.handler, params } : resolvedPage(r.def, r.pattern, params);
221
205
  }
222
206
  return null;
223
207
  },