@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 +1 -0
- package/dist/build.js +13 -3
- package/dist/route-rank.d.ts +11 -0
- package/dist/route-rank.js +38 -0
- package/dist/router.js +7 -6
- package/dist/worker.d.ts +1 -0
- package/dist/worker.js +52 -76
- package/package.json +1 -1
- package/src/build.ts +12 -3
- package/src/route-rank.ts +60 -0
- package/src/router.ts +13 -8
- package/src/worker.ts +66 -82
package/dist/build.d.ts
CHANGED
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))
|
|
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))
|
|
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) =>
|
|
46
|
-
const isOptionalDir = (name) =>
|
|
47
|
-
const isOptionalCatchAllDir = (name) =>
|
|
48
|
-
const isCatchAllDir = (name) =>
|
|
49
|
-
const paramName = (name) => name.
|
|
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
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
|
|
11
|
-
if (
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
pattern
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
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) =>
|
|
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) =>
|
|
94
|
-
const isOptionalCatchAllDir = (name: string) =>
|
|
95
|
-
const isCatchAllDir = (name: string) =>
|
|
96
|
-
const paramName = (name: string) => name.
|
|
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]
|
|
151
|
-
//
|
|
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
|
-
|
|
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).
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
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
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
},
|