@junejs/server 1.0.0-dev.42 → 1.0.0-dev.47

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.
Files changed (48) hide show
  1. package/dist/agent-durable.d.ts +4 -0
  2. package/dist/agent-durable.js +22 -4
  3. package/dist/agent-native.d.ts +18 -2
  4. package/dist/agent-native.js +62 -8
  5. package/dist/app.d.ts +1 -0
  6. package/dist/app.js +18 -2
  7. package/dist/build.d.ts +3 -3
  8. package/dist/build.js +4 -2
  9. package/dist/{config-freeze-ZWC_Qd4h.d.ts → config-freeze-CyXvNqi2.d.ts} +1 -1
  10. package/dist/config-freeze.d.ts +1 -1
  11. package/dist/core-version.js +1 -1
  12. package/dist/dev.d.ts +6 -2
  13. package/dist/dev.js +40 -9
  14. package/dist/host.d.ts +1 -0
  15. package/dist/host.js +2 -1
  16. package/dist/index.d.ts +4 -4
  17. package/dist/index.js +2 -2
  18. package/dist/{manifest-DSNX4I-G.d.ts → manifest-CwTT1zVs.d.ts} +1 -1
  19. package/dist/manifest.d.ts +1 -1
  20. package/dist/manifest.js +3 -1
  21. package/dist/pipeline.js +15 -6
  22. package/dist/route-rank.d.ts +10 -0
  23. package/dist/route-rank.js +38 -0
  24. package/dist/route-scan-BMbBzPIB.d.ts +20 -0
  25. package/dist/route-scan.d.ts +2 -2
  26. package/dist/route-scan.js +47 -2
  27. package/dist/router.js +58 -44
  28. package/dist/sqlite-driver.d.ts +1 -0
  29. package/dist/sqlite-driver.js +17 -4
  30. package/dist/{worker-Dv4l6Ybs.d.ts → worker-2qbQjj2c.d.ts} +1 -0
  31. package/dist/worker.d.ts +1 -1
  32. package/dist/worker.js +59 -77
  33. package/package.json +3 -3
  34. package/src/agent-durable.ts +24 -3
  35. package/src/agent-native.ts +99 -13
  36. package/src/app.ts +34 -2
  37. package/src/build.ts +7 -1
  38. package/src/core-version.ts +1 -1
  39. package/src/dev.ts +41 -8
  40. package/src/host.ts +4 -3
  41. package/src/manifest.ts +2 -0
  42. package/src/pipeline.ts +20 -4
  43. package/src/route-rank.ts +60 -0
  44. package/src/route-scan.ts +78 -1
  45. package/src/router.ts +75 -58
  46. package/src/sqlite-driver.ts +36 -6
  47. package/src/worker.ts +76 -82
  48. package/dist/route-scan-DMlwoESy.d.ts +0 -13
@@ -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/route-scan.ts CHANGED
@@ -8,6 +8,8 @@ import { existsSync } from "node:fs";
8
8
  import { readdir } from "node:fs/promises";
9
9
  import { basename, join, relative, sep } from "node:path";
10
10
 
11
+ import { parseSegment } from "./route-rank";
12
+
11
13
  // The segment layout CHAIN root→leaf: every directory level (route groups
12
14
  // included) may contribute a layout.* that wraps routes below it.
13
15
  export type RouteEntry = {
@@ -15,6 +17,7 @@ export type RouteEntry = {
15
17
  file: string;
16
18
  dynamic: boolean;
17
19
  resource?: boolean; // a route.* resource route (raw-Response handler), not a page
20
+ generated?: boolean; // scanned from .june/routes/ — ranks after every app/ route, as in dev
18
21
  layouts: string[];
19
22
  loading?: string; // nearest loading.tsx up the tree → streaming Suspense fallback
20
23
  };
@@ -77,8 +80,82 @@ export async function scanAppRoutes(appRoot: string): Promise<RouteEntry[]> {
77
80
  const juneRoutesDir = join(appRoot, ".june", "routes");
78
81
  const appRoutes = await scanRoutes(appDir);
79
82
  const frameworkRoutes = existsSync(juneRoutesDir) ? await scanRoutes(juneRoutesDir) : [];
83
+ const problems = routeProblems([appRoutes, frameworkRoutes], appRoot);
84
+ if (problems) throw new Error(problems);
80
85
  const appPaths = new Set(appRoutes.map((r) => r.path));
81
- return [...appRoutes, ...frameworkRoutes.filter((r) => !appPaths.has(r.path))].sort((a, b) =>
86
+ const generated = frameworkRoutes
87
+ .filter((r) => !appPaths.has(r.path))
88
+ .map((r) => ({ ...r, generated: true }));
89
+ return [...appRoutes, ...generated].sort((a, b) =>
82
90
  a.path.localeCompare(b.path),
83
91
  );
84
92
  }
93
+
94
+ // Two route files in ONE tree that resolve to the same URL: `(a)/about/page.tsx`
95
+ // and `(b)/about/page.tsx` (groups vanish from the URL), or `page.tsx` next to
96
+ // `index.tsx`. Pages and resource routes are counted apart — a page and a
97
+ // route.ts at the same path are allowed, in one dir or across groups (the page
98
+ // wins). No winner is defined for the rest: dev
99
+ // would pick by group order and the worker by scan order, so the build refuses
100
+ // them and dev reports them (#316).
101
+ export function routeConflicts(routes: RouteEntry[]): Array<{ path: string; files: string[] }> {
102
+ const byKey = new Map<string, { path: string; files: string[] }>();
103
+ for (const r of routes) {
104
+ const key = `${r.resource ? "route" : "page"} ${r.path}`;
105
+ const hit = byKey.get(key);
106
+ if (hit) hit.files.push(r.file);
107
+ else byKey.set(key, { path: r.path, files: [r.file] });
108
+ }
109
+ return [...byKey.values()]
110
+ .filter((c) => c.files.length > 1)
111
+ .map((c) => ({ path: c.path, files: c.files.sort() }))
112
+ .sort((a, b) => a.path.localeCompare(b.path));
113
+ }
114
+
115
+ // An optional or catch-all segment ([[x]], [...x], [[...x]]) must END the route
116
+ // path (#315, the Next.js rule, opinionated). Mid-path there is no behavior to
117
+ // keep: dev never matched past a catch-all nor skipped a mid-path [[x]], while
118
+ // the worker's regex did both. A (group) after it is not a URL segment, so it's
119
+ // fine. Returns the offending routes, path-sorted.
120
+ const ENDS_PATH = new Set(["optional", "catchAll", "optionalCatchAll"]);
121
+ const beforeLast = (path: string) => path.split("/").filter(Boolean).slice(0, -1).map((s) => parseSegment(s).kind);
122
+
123
+ export function misplacedSegments(routes: RouteEntry[]): RouteEntry[] {
124
+ return routes
125
+ .filter((r) => beforeLast(r.path).some((k) => ENDS_PATH.has(k)))
126
+ .sort((a, b) => a.path.localeCompare(b.path) || a.file.localeCompare(b.file));
127
+ }
128
+
129
+ // Every route-table problem the build refuses and dev reports, as one message
130
+ // (null when there are none). Each tree (app/, .june/routes/) is checked on its
131
+ // own: the same path in both is not a conflict — app/ wins.
132
+ export function routeProblems(trees: RouteEntry[][], appRoot: string): string | null {
133
+ const rel = (f: string) => relative(appRoot, f).split(sep).join("/");
134
+ const sections: string[] = [];
135
+
136
+ const conflicts = trees.flatMap(routeConflicts);
137
+ if (conflicts.length) {
138
+ sections.push(
139
+ "[june] more than one route file resolves to the same path — keep one per path:\n" +
140
+ conflicts.map((c) => ` ${c.path}: ${c.files.map(rel).join(", ")}`).join("\n"),
141
+ );
142
+ }
143
+
144
+ const misplaced = trees.flatMap(misplacedSegments);
145
+ if (misplaced.length) {
146
+ const lines = misplaced.map((r) => ` ${r.path}: ${rel(r.file)}`);
147
+ // Developers (and models) read [[x]] the SvelteKit way, skippable anywhere;
148
+ // say so when a single optional is the culprit, and point at the real tool.
149
+ if (misplaced.some((r) => beforeLast(r.path).includes("optional"))) {
150
+ lines.push(
151
+ " Unlike SvelteKit, June does not skip a [[param]] mid-path. For a locale prefix, set i18n.locales in june.config.ts instead of a [[lang]] directory.",
152
+ );
153
+ }
154
+ sections.push(
155
+ "[june] an optional or catch-all segment must be the last segment of a route path — nothing may follow it:\n" +
156
+ lines.join("\n"),
157
+ );
158
+ }
159
+
160
+ return sections.length ? sections.join("\n") : null;
161
+ }
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,12 @@ 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); `_`-prefixed entries never participate. Route groups `(name)` are
155
+ // invisible in the URL, so they are invisible to ranking too: a group's children
156
+ // compete as siblings of the level the group sits in (a grouped `[slug]` never
157
+ // shadows a static sibling), while the group still contributes its layout.
152
158
  // Returns the page file, accumulated params (catch-all joins with "/"), and the
153
159
  // chain of segments (with their special files) from the app root to the page.
154
160
  export async function matchRouteTree(
@@ -158,85 +164,96 @@ export async function matchRouteTree(
158
164
  ): Promise<RouteTreeMatch | null> {
159
165
  const urlSegments = pathname.split("/").filter(Boolean).map(decodeURIComponent);
160
166
 
167
+ // One URL level = this dir plus every route group under it (groups nest), each
168
+ // with the segment chain down to it: the dir first, then groups in name order.
169
+ type Level = { dir: string; entries: DirEntry[]; segments: SegmentMatch[] };
170
+ async function levelAt(dir: string, chain: SegmentMatch[]): Promise<Level[]> {
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));
173
+ const segments = [...chain, segmentAt(dir, entries)];
174
+ const levels: Level[] = [{ dir, entries, segments }];
175
+ for (const e of entries) {
176
+ if (e.dir && isRouteGroup(e.name)) levels.push(...(await levelAt(join(dir, e.name), segments)));
177
+ }
178
+ return levels;
179
+ }
180
+
181
+ // A URL level can span several real dirs: `(a)/blog` and `(b)/blog` are both
182
+ // `/blog`, so they descend TOGETHER — otherwise (a)/blog/[slug] would answer
183
+ // /blog/about before (b)/blog/about was ever seen.
184
+ type Node = { dir: string; chain: SegmentMatch[] };
185
+
161
186
  async function descend(
162
- dir: string,
187
+ nodes: Node[],
163
188
  rest: string[],
164
189
  params: Record<string, string>,
165
- chain: SegmentMatch[],
166
190
  ): Promise<RouteTreeMatch | null> {
167
- const entries = await listDir(dir);
168
- const segments = [...chain, segmentAt(dir, entries)];
191
+ const levels = (await Promise.all(nodes.map((n) => levelAt(n.dir, n.chain)))).flat();
169
192
 
170
- // Terminal: URL consumed → find the page (or resource route) in this dir.
193
+ // Terminal: URL consumed → find the page at this level, groups included,
194
+ // and only then a resource route: a page beats a route.ts at the same path
195
+ // even when the two sit in different groups (the worker ranks them so).
171
196
  if (rest.length === 0) {
172
- const page =
173
- fileFor(entries, dir, "page") ??
174
- fileFor(entries, dir, "index") ??
175
- fileFor(entries, dir, "route");
176
- if (page) return { file: page, params, segments };
177
- } else if (!options.pageConvention) {
197
+ for (const base of [["page", "index"], ["route"]]) {
198
+ for (const { dir: d, entries, segments } of levels) {
199
+ const file = base.map((b) => fileFor(entries, d, b)).find(Boolean);
200
+ if (file) return { file, params, segments };
201
+ }
202
+ }
203
+ } else if (!options.pageConvention && rest.length === 1) {
178
204
  // Legacy flat convention: a non-special leaf FILE names the final segment
179
205
  // (examples/rsc: about.tsx → /about). Only valid for the last segment.
180
- if (rest.length === 1) {
181
- const leaf = fileFor(entries, dir, rest[0]!);
182
- if (leaf && !isSpecialFile(leaf) && !isPageFile(leaf)) {
183
- return { file: leaf, params, segments };
184
- }
206
+ for (const { dir: d, entries, segments } of levels) {
207
+ const leaf = fileFor(entries, d, rest[0]!);
208
+ if (leaf && !isSpecialFile(leaf) && !isPageFile(leaf)) return { file: leaf, params, segments };
185
209
  }
186
210
  }
187
211
 
188
- // Route groups: try descending into every (group) without consuming URL.
189
- for (const e of entries) {
190
- if (!e.dir || !isRouteGroup(e.name)) continue;
191
- const hit = await descend(join(dir, e.name), rest, params, segments);
192
- if (hit) return hit;
212
+ // Every child dir at this level, groups flattened and same names merged, in
213
+ // rank order.
214
+ const children = new Map<string, Node[]>();
215
+ for (const lv of levels) {
216
+ for (const e of lv.entries) {
217
+ if (!e.dir || isRouteGroup(e.name)) continue;
218
+ const node = { dir: join(lv.dir, e.name), chain: lv.segments };
219
+ const same = children.get(e.name);
220
+ if (same) same.push(node);
221
+ else children.set(e.name, [node]);
222
+ }
193
223
  }
224
+ const names = [...children.keys()].sort(compareSegments);
194
225
 
195
226
  if (rest.length === 0) {
196
227
  // Optional segments match ABSENCE too: descend without consuming and
197
228
  // without setting the param.
198
- for (const e of entries) {
199
- if (!e.dir || !(isOptionalDir(e.name) || isOptionalCatchAllDir(e.name))) continue;
200
- const hit = await descend(join(dir, e.name), [], params, segments);
229
+ for (const name of names) {
230
+ if (!(isOptionalDir(name) || isOptionalCatchAllDir(name))) continue;
231
+ const hit = await descend(children.get(name)!, [], params);
201
232
  if (hit) return hit;
202
233
  }
203
234
  return null;
204
235
  }
205
236
  const [head, ...tail] = rest as [string, ...string[]];
206
237
 
207
- // 1) exact static dir
208
- const exact = entries.find((e) => e.dir && e.name === head);
209
- if (exact) {
210
- const hit = await descend(join(dir, head), tail, params, segments);
211
- if (hit) return hit;
212
- }
213
- // 2) [param] and [[param]] dirs consume one segment
214
- for (const e of entries) {
215
- if (!e.dir || !(isParamDir(e.name) || isOptionalDir(e.name))) continue;
216
- const hit = await descend(
217
- join(dir, e.name),
218
- tail,
219
- { ...params, [paramName(e.name)]: head },
220
- segments,
221
- );
222
- if (hit) return hit;
223
- }
224
- // 3) [...catchAll] and [[...catchAll]] dirs consume everything remaining
225
- for (const e of entries) {
226
- if (!e.dir || !(isCatchAllDir(e.name) || isOptionalCatchAllDir(e.name))) continue;
227
- const hit = await descend(
228
- join(dir, e.name),
229
- [],
230
- { ...params, [paramName(e.name)]: rest.join("/") },
231
- segments,
232
- );
238
+ for (const name of names) {
239
+ const into = children.get(name)!;
240
+ let hit: RouteTreeMatch | null = null;
241
+ if (isParamDir(name) || isOptionalDir(name)) {
242
+ // [param] and [[param]] consume one segment
243
+ hit = await descend(into, tail, { ...params, [paramName(name)]: head });
244
+ } else if (isCatchAllDir(name) || isOptionalCatchAllDir(name)) {
245
+ // [...catchAll] and [[...catchAll]] consume everything remaining
246
+ hit = await descend(into, [], { ...params, [paramName(name)]: rest.join("/") });
247
+ } else if (name === head) {
248
+ hit = await descend(into, tail, params); // exact static dir
249
+ }
233
250
  if (hit) return hit;
234
251
  }
235
252
 
236
253
  return null;
237
254
  }
238
255
 
239
- return descend(appDir, urlSegments, {}, []);
256
+ return descend([{ dir: appDir, chain: [] }], urlSegments, {});
240
257
  }
241
258
 
242
259
  // 404 path: walk the longest matchable STATIC prefix of the URL collecting
@@ -135,6 +135,33 @@ export function makeWarningFilter(original: EmitWarning): EmitWarning {
135
135
  // runtime, which is exactly what the typeof guard checks.
136
136
  declare const Bun: unknown;
137
137
 
138
+ // A file database commits durably: WAL, fsynced on every commit. SQLite's default
139
+ // rollback journal (DELETE mode) commits by unlinking the journal, and at
140
+ // synchronous=FULL it does not fsync the directory after that unlink — so a power
141
+ // cut right after a commit can bring the journal back, and the next open rolls the
142
+ // committed transaction back. On a VM power cut that lost the last committed agent
143
+ // turn 5 times out of 5; with WAL + FULL, 0 of 5. FULL is set explicitly: it is a
144
+ // build-time default, and WAL at synchronous=NORMAL skips the per-commit fsync.
145
+ // journal_mode=WAL persists in the file; `:memory:` has no journal to change.
146
+ // A PRAGMA that throws (a locked file mid-switch) closes the handle it was handed,
147
+ // so a retry is not blocked by a descriptor nobody can reach; the original error
148
+ // is what surfaces. Exported for the unit test of that path.
149
+ export function useDurableJournal(db: SyncSqlite, path: string): SyncSqlite {
150
+ if (path === ":memory:" || path === "") return db;
151
+ try {
152
+ db.exec("PRAGMA journal_mode=WAL");
153
+ db.exec("PRAGMA synchronous=FULL");
154
+ } catch (err) {
155
+ try {
156
+ db.close();
157
+ } catch {
158
+ // the setup error is the one worth reporting
159
+ }
160
+ throw err;
161
+ }
162
+ return db;
163
+ }
164
+
138
165
  // Open a LOCAL *synchronous* sqlite handle on whichever runtime we're under —
139
166
  // bun:sqlite under Bun, node:sqlite under Node. This is the raw synchronous
140
167
  // surface: asyncSqlite() wraps it as the edge-safe async JuneDb (below), while
@@ -148,7 +175,7 @@ export async function openLocalSqliteSync(path: string): Promise<SyncSqlite> {
148
175
  const { Database } = (await import(specifier)) as {
149
176
  Database: new (p: string, o?: { create?: boolean }) => SyncSqlite;
150
177
  };
151
- return new Database(path, { create: true });
178
+ return useDurableJournal(new Database(path, { create: true }), path);
152
179
  }
153
180
 
154
181
  // Node: node:sqlite is a builtin, but only flag-free on a recent-enough
@@ -172,11 +199,14 @@ export async function openLocalSqliteSync(path: string): Promise<SyncSqlite> {
172
199
  }
173
200
  const db = new mod.DatabaseSync(path);
174
201
  // Adapt node:sqlite (prepare()) to the query()-shaped SyncSqlite surface.
175
- return {
176
- query: (sql) => db.prepare(sql),
177
- exec: (sql) => db.exec(sql),
178
- close: () => db.close(),
179
- };
202
+ return useDurableJournal(
203
+ {
204
+ query: (sql) => db.prepare(sql),
205
+ exec: (sql) => db.exec(sql),
206
+ close: () => db.close(),
207
+ },
208
+ path,
209
+ );
180
210
  }
181
211
 
182
212
  // Open a LOCAL sqlite file (or ":memory:") as the async JuneDb — the single seam
package/src/worker.ts CHANGED
@@ -18,10 +18,12 @@ import type { I18nConfig } from "@junejs/core/i18n";
18
18
  import type { Resources } from "@junejs/core/resources";
19
19
 
20
20
  import type { Channel, ChannelFactory } from "@junejs/core/agent-config";
21
+ import { blobFileStore } from "@junejs/core/agent-files";
21
22
 
22
23
  import { createPipeline, type ExtraHandler, type LayoutComponent, type LoadingComponent, type Resolved, type ResolvedResource, type ResourceHandler } from "./pipeline";
23
24
  import { durableAgentSurface, durableChannelSurface, type DurableObjectNamespace } from "./agent-durable";
24
25
  import { acceptTarget } from "./negotiate";
26
+ import { compareRoutePatterns, parseSegment } from "./route-rank";
25
27
  import { contentTypeFor, RESERVED_PREFIX, safeRelativePath } from "./static-files";
26
28
 
27
29
  // The slice of workerd's ExecutionContext the worker threads through: webhook
@@ -37,8 +39,13 @@ export type WorkerManifest = {
37
39
  // Dynamic patterns in file-route syntax ("/posts/[slug]", "/docs/[...path]").
38
40
  dynamicRoutes?: Array<{ pattern: string; def: BrandedRoute }>;
39
41
  // Resource routes (app/**/route.*): a raw-Response handler keyed by its pattern
40
- // (static or dynamic). Matched after pages, so a page never gets shadowed.
42
+ // (static or dynamic). Ranked with the pages by segment shape (route-rank.ts),
43
+ // exactly as `june dev` resolves; a page wins only at the SAME pattern.
41
44
  resourceRoutes?: Array<{ pattern: string; handler: ResourceHandler }>;
45
+ // Patterns scanned from .june/routes/ (framework-generated, e.g. Kura). Dev
46
+ // consults app/ first and falls back to .june/routes/, so every one of these
47
+ // ranks after every app/ route. Absent → all routes are app/ routes.
48
+ generatedRoutes?: string[];
42
49
  // Layout chains (root→leaf) keyed by route path / dynamic pattern. The build
43
50
  // freezes the same chain the dev server loads from app/layout.* files.
44
51
  layoutChains?: Record<string, LayoutComponent[]>;
@@ -83,7 +90,13 @@ export type WorkerManifest = {
83
90
  services?: (env?: unknown) => unknown;
84
91
  };
85
92
 
86
- type Compiled = { regex: RegExp; names: string[]; def: BrandedRoute; pattern: string };
93
+ // One row of the worker's route table: a page (def) or a resource route (handler).
94
+ type Compiled = {
95
+ regex: RegExp;
96
+ names: string[];
97
+ pattern: string;
98
+ generated: boolean;
99
+ } & ({ def: BrandedRoute; handler?: undefined } | { def?: undefined; handler: ResourceHandler });
87
100
 
88
101
  // "/posts/[slug]" | "/docs/[...path]" | "/notes/[[tag]]" → matcher. Optional
89
102
  // segments ([[x]], [[...x]]) wrap slash + capture together so absence matches;
@@ -93,34 +106,19 @@ function compilePattern(pattern: string): { regex: RegExp; names: string[] } {
93
106
  let source = "";
94
107
  let allOptional = true;
95
108
  for (const seg of pattern.split("/").filter(Boolean)) {
96
- const oca = seg.match(/^\[\[\.\.\.(\w+)\]\]$/);
97
- if (oca) {
98
- names.push(oca[1]!);
99
- source += "(?:/(.+))?";
100
- continue;
101
- }
102
- const ca = seg.match(/^\[\.\.\.(\w+)\]$/);
103
- if (ca) {
104
- names.push(ca[1]!);
105
- source += "/(.+)";
109
+ // Same grammar as the dev matcher (route-rank.ts parseSegment).
110
+ const { kind, name } = parseSegment(seg);
111
+ if (name) names.push(name);
112
+ if (kind === "optionalCatchAll") source += "(?:/(.+))?";
113
+ else if (kind === "optional") source += "(?:/([^/]+))?";
114
+ else {
115
+ if (kind === "catchAll") source += "/(.+)";
116
+ else if (kind === "param") source += "/([^/]+)";
117
+ // A static segment matches literally: escape every RegExp metacharacter,
118
+ // brackets included (`docs[v2`, `[slug].png` are static names).
119
+ else source += "/" + seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
106
120
  allOptional = false;
107
- continue;
108
- }
109
- const op = seg.match(/^\[\[(\w+)\]\]$/);
110
- if (op) {
111
- names.push(op[1]!);
112
- source += "(?:/([^/]+))?";
113
- continue;
114
121
  }
115
- const p = seg.match(/^\[(\w+)\]$/);
116
- if (p) {
117
- names.push(p[1]!);
118
- source += "/([^/]+)";
119
- allOptional = false;
120
- continue;
121
- }
122
- source += "/" + seg.replace(/[.*+?^${}()|\\]/g, "\\$&");
123
- allOptional = false;
124
122
  }
125
123
  if (source === "") return { regex: /^\/$/, names };
126
124
  return { regex: new RegExp(allOptional ? `^(?:${source}|/)$` : `^${source}$`), names };
@@ -132,16 +130,23 @@ export function createWorker(
132
130
  manifest: WorkerManifest,
133
131
  opts: { staticBuild?: boolean } = {},
134
132
  ): { fetch(request: Request, env?: unknown, ctx?: WorkerExecutionContext): Promise<Response> } {
135
- const dynamic: Compiled[] = (manifest.dynamicRoutes ?? []).map((d) => ({
136
- ...compilePattern(d.pattern),
137
- def: d.def,
138
- pattern: d.pattern,
139
- }));
140
- const resources = (manifest.resourceRoutes ?? []).map((r) => ({
141
- ...compilePattern(r.pattern),
142
- handler: r.handler,
143
- pattern: r.pattern,
144
- }));
133
+ // Pages and resource routes in ONE table, ordered the way `june dev` resolves
134
+ // (#312): app/ before .june/routes/, then segment shape (static > [param] >
135
+ // [...catchAll], required before optional), then a page before a resource
136
+ // route at the same pattern. The kind of route never outranks the shape.
137
+ const generated = new Set(manifest.generatedRoutes ?? []);
138
+ const row = (pattern: string) => ({ ...compilePattern(pattern), pattern, generated: generated.has(pattern) });
139
+ const table: Compiled[] = [
140
+ ...Object.entries(manifest.routes).map(([pattern, def]) => ({ ...row(pattern), def })),
141
+ ...(manifest.dynamicRoutes ?? []).map((d) => ({ ...row(d.pattern), def: d.def })),
142
+ ...(manifest.resourceRoutes ?? []).map((r) => ({ ...row(r.pattern), handler: r.handler })),
143
+ ];
144
+ table.sort(
145
+ (a, b) =>
146
+ Number(a.generated) - Number(b.generated) ||
147
+ compareRoutePatterns(a.pattern, b.pattern) ||
148
+ Number(!!a.handler) - Number(!!b.handler),
149
+ );
145
150
  const routeList = [
146
151
  ...Object.keys(manifest.routes),
147
152
  ...(manifest.dynamicRoutes ?? []).map((d) => d.pattern),
@@ -151,6 +156,17 @@ export function createWorker(
151
156
  const loadingFor = (key: string): LoadingComponent | undefined => manifest.loadings?.[key];
152
157
  const boundaryFor = (key: string): { index: number; key: string } | null =>
153
158
  manifest.layoutBoundaries?.[key] ?? null;
159
+ const resolvedPage = (def: BrandedRoute, key: string, params: Record<string, string>): Resolved => {
160
+ const b = boundaryFor(key);
161
+ return {
162
+ def,
163
+ params,
164
+ chain: chainFor(key),
165
+ boundaryIndex: b?.index ?? null,
166
+ boundaryKey: b?.key ?? null,
167
+ loading: loadingFor(key),
168
+ };
169
+ };
154
170
 
155
171
  // The worker's env (D1/KV/R2 bindings) arrives per fetch and is stable across
156
172
  // requests in an isolate; we capture the latest and hand it to the env-aware
@@ -204,6 +220,15 @@ export function createWorker(
204
220
  env: locals?.env,
205
221
  services: servicesProvider,
206
222
  waitUntil: locals?.ctx?.waitUntil?.bind(locals.ctx),
223
+ // The blob resource the agent's Durable Object stores files in, opened from
224
+ // this isolate's env — the channels read attached files back from it.
225
+ files: provider
226
+ ? blobFileStore(async () => {
227
+ const blob = (await provider(locals?.env)).blob;
228
+ if (!blob) throw new Error(`agent "${agentName}": no blob resource bound in this isolate — attached files can't be read`);
229
+ return blob;
230
+ })
231
+ : undefined,
207
232
  });
208
233
  return channels(req);
209
234
  };
@@ -224,52 +249,21 @@ export function createWorker(
224
249
  resources: provider ? () => provider(currentEnv) : undefined,
225
250
  services: servicesProvider ? () => (servicesCache ??= { v: servicesProvider(currentEnv) }).v : undefined,
226
251
  resolve: async (pathname): Promise<Resolved | ResolvedResource | null> => {
252
+ // Fast path: an app/ static page outranks every other route for its path.
227
253
  const staticDef = manifest.routes[pathname];
228
- if (staticDef) {
229
- const b = boundaryFor(pathname);
230
- return {
231
- def: staticDef,
232
- params: {},
233
- chain: chainFor(pathname),
234
- boundaryIndex: b?.index ?? null,
235
- boundaryKey: b?.key ?? null,
236
- loading: loadingFor(pathname),
237
- };
238
- }
239
- for (const d of dynamic) {
240
- const m = pathname.match(d.regex);
241
- if (m) {
242
- // Optional segments leave their capture undefined — the param is
243
- // then absent, matching the dev matcher's semantics.
244
- const params = Object.fromEntries(
245
- d.names.flatMap((n, i) => {
246
- const v = m[i + 1];
247
- return v === undefined ? [] : [[n, decodeURIComponent(v)]];
248
- }),
249
- );
250
- const b = boundaryFor(d.pattern);
251
- return {
252
- def: d.def,
253
- params,
254
- chain: chainFor(d.pattern),
255
- boundaryIndex: b?.index ?? null,
256
- boundaryKey: b?.key ?? null,
257
- loading: loadingFor(d.pattern),
258
- };
259
- }
260
- }
261
- // Resource routes after pages (so a page is never shadowed by one).
262
- for (const r of resources) {
254
+ if (staticDef && !generated.has(pathname)) return resolvedPage(staticDef, pathname, {});
255
+ for (const r of table) {
263
256
  const m = pathname.match(r.regex);
264
- if (m) {
265
- const params = Object.fromEntries(
266
- r.names.flatMap((n, i) => {
267
- const v = m[i + 1];
268
- return v === undefined ? [] : [[n, decodeURIComponent(v)]];
269
- }),
270
- );
271
- return { handler: r.handler, params };
272
- }
257
+ if (!m) continue;
258
+ // Optional segments leave their capture undefined — the param is
259
+ // then absent, matching the dev matcher's semantics.
260
+ const params = Object.fromEntries(
261
+ r.names.flatMap((n, i) => {
262
+ const v = m[i + 1];
263
+ return v === undefined ? [] : [[n, decodeURIComponent(v)]];
264
+ }),
265
+ );
266
+ return r.handler ? { handler: r.handler, params } : resolvedPage(r.def, r.pattern, params);
273
267
  }
274
268
  return null;
275
269
  },
@@ -1,13 +0,0 @@
1
- //#region src/route-scan.d.ts
2
- type RouteEntry = {
3
- path: string;
4
- file: string;
5
- dynamic: boolean;
6
- resource?: boolean;
7
- layouts: string[];
8
- loading?: string;
9
- };
10
- declare function scanRoutes(appDir: string, dir?: string, layouts?: string[], out?: RouteEntry[], loading?: string): Promise<RouteEntry[]>;
11
- declare function scanAppRoutes(appRoot: string): Promise<RouteEntry[]>;
12
- //#endregion
13
- export { scanAppRoutes as n, scanRoutes as r, RouteEntry as t };