@junejs/server 0.1.2-dev.9 → 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.
@@ -10,8 +10,7 @@
10
10
  import {
11
11
  AgentSession,
12
12
  withSystem,
13
- type EventSink,
14
- type TurnEvent,
13
+ type Broadcaster,
15
14
  type Model,
16
15
  type Msg,
17
16
  type Runtime,
@@ -20,7 +19,6 @@ import {
20
19
  } from "@junejs/core/agent-runtime";
21
20
  import { channelFetch, type AgentDefinition, type ChannelContext } from "@junejs/core/agent-config";
22
21
  import { openLocalSqliteSync, type SyncSqlite } from "./sqlite-driver";
23
- import { assertCoreRuntimeVersion } from "./core-version";
24
22
 
25
23
  function initSchema(db: SyncSqlite) {
26
24
  db.exec(`CREATE TABLE IF NOT EXISTS agent_sessions (session_id TEXT PRIMARY KEY, status TEXT)`);
@@ -47,8 +45,8 @@ class SqliteSessionStore implements SessionStore {
47
45
  return (this.db.query("SELECT body FROM agent_messages WHERE session_id = ? ORDER BY seq").all(this.sid) as { body: string }[])
48
46
  .map((r) => JSON.parse(r.body));
49
47
  }
50
- hasOpeningMessage(turnId: string): boolean {
51
- return this.messages().some((m) => (m.role === "user" || m.role === "trigger") && m.turnId === turnId);
48
+ hasUserTurn(turnId: string): boolean {
49
+ return this.messages().some((m) => m.role === "user" && m.turnId === turnId);
52
50
  }
53
51
  getStep(id: string): unknown | undefined {
54
52
  const r = this.db.query("SELECT output FROM agent_steps WHERE session_id = ? AND id = ?").get(this.sid, id) as { output: string } | undefined;
@@ -57,9 +55,6 @@ class SqliteSessionStore implements SessionStore {
57
55
  putStep(id: string, output: unknown) {
58
56
  this.db.query("INSERT INTO agent_steps (session_id, id, output) VALUES (?, ?, ?)").run(this.sid, id, JSON.stringify(output));
59
57
  }
60
- delStep(id: string) {
61
- this.db.query("DELETE FROM agent_steps WHERE session_id = ? AND id = ?").run(this.sid, id);
62
- }
63
58
  getStatus(): string {
64
59
  return (this.db.query("SELECT status FROM agent_sessions WHERE session_id = ?").get(this.sid) as { status: string } | undefined)?.status ?? "new";
65
60
  }
@@ -82,15 +77,15 @@ class SqliteSessionStore implements SessionStore {
82
77
  unwrap<H = unknown>(): H { return this.db as unknown as H; }
83
78
  }
84
79
 
85
- class InProcEventSink implements EventSink {
86
- private subs = new Set<(e: TurnEvent) => void>();
87
- emit(e: TurnEvent) { this.subs.forEach((cb) => { try { cb(e); } catch { /* a bad subscriber must not break emit */ } }); }
88
- subscribe(cb: (e: TurnEvent) => void): () => void { this.subs.add(cb); return () => this.subs.delete(cb); }
80
+ class InProcBroadcaster implements Broadcaster {
81
+ private subs = new Set<(t: string) => void>();
82
+ publish(turnId: string) { this.subs.forEach((cb) => { try { cb(turnId); } catch { /* a bad subscriber must not break publish */ } }); }
83
+ subscribe(cb: (t: string) => void): () => void { this.subs.add(cb); return () => this.subs.delete(cb); }
89
84
  }
90
85
 
91
86
  // `instructions` (the agent's system prompt) is injected into the model per turn
92
87
  // by the runtime (withSystem) — single-sourced on the def, not baked into `model`.
93
- export type AgentDef = { model: Model; tools: Tool[]; instructions?: string; channelInstructions?: Record<string, string> };
88
+ export type AgentDef = { model: Model; tools: Tool[]; instructions?: string };
94
89
 
95
90
  // The native Runtime: a registry of agent definitions over one SQLite handle,
96
91
  // handing out (and memoizing) an AgentSession actor per (agent, id).
@@ -100,7 +95,6 @@ export class NativeRuntime implements Runtime {
100
95
  private readonly db: SyncSqlite;
101
96
 
102
97
  constructor(agents: Record<string, AgentDef>, db: SyncSqlite) {
103
- assertCoreRuntimeVersion("NativeRuntime"); // #94: fail power-on, not mid-turn
104
98
  this.agents = agents;
105
99
  this.db = db;
106
100
  initSchema(db);
@@ -113,7 +107,7 @@ export class NativeRuntime implements Runtime {
113
107
  const def = this.agents[agent];
114
108
  if (!def) throw new Error(`unknown agent: ${agent}`);
115
109
  const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
116
- a = new AgentSession(agent, id, new SqliteSessionStore(this.db, key), new InProcEventSink(), model, def.tools, this, def.channelInstructions);
110
+ a = new AgentSession(agent, id, new SqliteSessionStore(this.db, key), new InProcBroadcaster(), model, def.tools, this);
117
111
  this.actors.set(key, a);
118
112
  }
119
113
  return a;
@@ -141,10 +135,9 @@ class MemorySessionStore implements SessionStore {
141
135
  private status = "new";
142
136
  appendMessage(m: Msg) { this.msgs.push(m); }
143
137
  messages(): Msg[] { return this.msgs.slice(); }
144
- hasOpeningMessage(turnId: string): boolean { return this.msgs.some((m) => (m.role === "user" || m.role === "trigger") && m.turnId === turnId); }
138
+ hasUserTurn(turnId: string): boolean { return this.msgs.some((m) => m.role === "user" && m.turnId === turnId); }
145
139
  getStep(id: string): unknown | undefined { return this.steps.has(id) ? this.steps.get(id) : undefined; }
146
140
  putStep(id: string, output: unknown) { this.steps.set(id, output); }
147
- delStep(id: string) { this.steps.delete(id); }
148
141
  getStatus(): string { return this.status; }
149
142
  setStatus(s: string) { this.status = s; }
150
143
  tx<T>(fn: () => T): T { return fn(); } // no rollback: an in-memory store is not a durability tier
@@ -156,7 +149,6 @@ export class MemoryRuntime implements Runtime {
156
149
  private stores = new Map<string, MemorySessionStore>();
157
150
  private readonly agents: Record<string, AgentDef>;
158
151
  constructor(agents: Record<string, AgentDef>) {
159
- assertCoreRuntimeVersion("MemoryRuntime"); // #94: fail power-on, not mid-turn
160
152
  this.agents = agents;
161
153
  }
162
154
  session(agent: string, id: string): AgentSession {
@@ -167,7 +159,7 @@ export class MemoryRuntime implements Runtime {
167
159
  if (!def) throw new Error(`unknown agent: ${agent}`);
168
160
  const store = new MemorySessionStore();
169
161
  this.stores.set(key, store);
170
- a = new AgentSession(agent, id, store, new InProcEventSink(), def.model, def.tools, this);
162
+ a = new AgentSession(agent, id, store, new InProcBroadcaster(), def.model, def.tools, this);
171
163
  this.actors.set(key, a);
172
164
  }
173
165
  return a;
@@ -203,7 +195,7 @@ export async function createAgentRuntime(
203
195
  export function mountAgent(
204
196
  agent: AgentDefinition,
205
197
  runtime: Runtime,
206
- opts: { chatPath?: string; channels?: boolean; services?: unknown } = {},
198
+ opts: { chatPath?: string; channels?: boolean } = {},
207
199
  ): {
208
200
  surface: (req: Request) => Promise<Response | null>;
209
201
  fetch: (req: Request) => Promise<Response | null>;
@@ -214,14 +206,8 @@ export function mountAgent(
214
206
  const channelsOn = opts.channels ?? true;
215
207
  const ctx: ChannelContext = {
216
208
  agent,
217
- services: opts.services, // same DI bag reachable from channel hooks (parity with durableChannelSurface)
218
209
  run: (message, o) =>
219
- runtime.session(agent.name, o?.session ?? "default").turn({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger }),
220
- // FIRE-AND-FORGET (#77): start() without awaiting the result. Native has no waitUntil
221
- // ceiling (Node keeps floating promises alive), but the seam is the same so a channel
222
- // written against ctx.runDetached behaves identically on both targets.
223
- runDetached: async (message, o) =>
224
- runtime.session(agent.name, o?.session ?? "default").start({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger }),
210
+ runtime.session(agent.name, o?.session ?? "default").turn({ turnId: o?.turnId, userText: message }),
225
211
  };
226
212
  const channels = channelFetch(agent, ctx);
227
213
  const surface = async (req: Request): Promise<Response | null> => {
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
  },
@@ -274,8 +258,11 @@ export function withAssets(
274
258
  }
275
259
 
276
260
  // 2. Static assets (prerendered HTML/.md/.json, /client.js, hashed CSS)
277
- // served direct.
278
- if (assets) {
261
+ // served direct — for reads only. Assets never answer a write, and on
262
+ // workerd handing the incoming request to ASSETS.fetch() consumes its
263
+ // body: a POST that 404s here would reach the pipeline with a used stream
264
+ // (POST /mcp → -32700 Parse error, an action's input gone).
265
+ if (assets && (request.method === "GET" || request.method === "HEAD")) {
279
266
  const a = await assets.fetch(request);
280
267
  if (a.status !== 404) {
281
268
  const ct = a.headers.get("content-type") ?? "";
@@ -1,5 +0,0 @@
1
- //#region src/core-version.d.ts
2
- declare function assertCoreRuntimeVersion(surface: string): void;
3
- declare function checkCoreRuntimeVersion(surface: string, actual: number | undefined, expected?: number): void;
4
- //#endregion
5
- export { assertCoreRuntimeVersion, checkCoreRuntimeVersion };
@@ -1,12 +0,0 @@
1
- import * as coreRuntime from "@junejs/core/agent-runtime";
2
- //#region src/core-version.ts
3
- const EXPECTED_CORE_RUNTIME_API = 1;
4
- function assertCoreRuntimeVersion(surface) {
5
- checkCoreRuntimeVersion(surface, coreRuntime.RUNTIME_API_VERSION);
6
- }
7
- function checkCoreRuntimeVersion(surface, actual, expected = EXPECTED_CORE_RUNTIME_API) {
8
- if (actual === expected) return;
9
- throw new Error(`${surface}: @junejs/server is built against @junejs/core runtime API v${expected}, but the @junejs/core it resolved provides ${actual === void 0 ? "no RUNTIME_API_VERSION (a pre-0.1.1-dev.16 core)" : `v${actual}`} — two copies of @junejs/core are likely installed (one nested under @junejs/server). Align the app's @junejs/core with @junejs/server's peer range, then reinstall/dedupe so one copy remains.`);
10
- }
11
- //#endregion
12
- export { assertCoreRuntimeVersion, checkCoreRuntimeVersion };
@@ -1,34 +0,0 @@
1
- // core-version.ts — the #94 belt-and-braces: fail at POWER-ON when the @junejs/core
2
- // copy this server resolved is not the one it was built for.
3
- //
4
- // The primary fix is @junejs/core being a peerDependency (the app's single resolution
5
- // always wins), but a nested second copy can still appear (npm legacy peer handling,
6
- // a stale lockfile, a transitive pin). When it does, the failure without this check
7
- // is far from the cause and mid-turn ("sink.emit is not a function", first seen in
8
- // crisp-agent production); with it, construction throws with both versions named.
9
- //
10
- // Namespace import + optional read on purpose: a PRE-#94 core has no
11
- // RUNTIME_API_VERSION export, and a named import of a missing export would die at
12
- // module link time with an unhelpful SyntaxError — exactly the confusion this
13
- // module exists to remove.
14
- import * as coreRuntime from "@junejs/core/agent-runtime";
15
-
16
- // The contract number this server tree is built against. Bump in lockstep with
17
- // core's RUNTIME_API_VERSION whenever the server↔core runtime contract changes.
18
- const EXPECTED_CORE_RUNTIME_API = 1;
19
-
20
- export function assertCoreRuntimeVersion(surface: string): void {
21
- checkCoreRuntimeVersion(surface, (coreRuntime as { RUNTIME_API_VERSION?: number }).RUNTIME_API_VERSION);
22
- }
23
-
24
- // The pure check, split out so the mismatch paths are unit-testable (the ambient
25
- // read above can only ever see the workspace's own — matching — core).
26
- export function checkCoreRuntimeVersion(surface: string, actual: number | undefined, expected: number = EXPECTED_CORE_RUNTIME_API): void {
27
- if (actual === expected) return;
28
- throw new Error(
29
- `${surface}: @junejs/server is built against @junejs/core runtime API v${expected}, ` +
30
- `but the @junejs/core it resolved provides ${actual === undefined ? "no RUNTIME_API_VERSION (a pre-0.1.1-dev.16 core)" : `v${actual}`} — ` +
31
- `two copies of @junejs/core are likely installed (one nested under @junejs/server). ` +
32
- `Align the app's @junejs/core with @junejs/server's peer range, then reinstall/dedupe so one copy remains.`,
33
- );
34
- }