@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.
- package/dist/agent-durable.d.ts +3 -39
- package/dist/agent-durable.js +26 -297
- package/dist/agent-native.d.ts +0 -2
- package/dist/agent-native.js +10 -28
- 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 +53 -77
- package/package.json +3 -3
- package/src/agent-durable.ts +25 -438
- package/src/agent-native.ts +13 -27
- package/src/build.ts +12 -3
- package/src/route-rank.ts +60 -0
- package/src/router.ts +13 -8
- package/src/worker.ts +71 -84
- package/dist/core-version.d.ts +0 -5
- package/dist/core-version.js +0 -12
- package/src/core-version.ts +0 -34
package/src/agent-native.ts
CHANGED
|
@@ -10,8 +10,7 @@
|
|
|
10
10
|
import {
|
|
11
11
|
AgentSession,
|
|
12
12
|
withSystem,
|
|
13
|
-
type
|
|
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
|
-
|
|
51
|
-
return this.messages().some((m) =>
|
|
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
|
|
86
|
-
private subs = new Set<(
|
|
87
|
-
|
|
88
|
-
subscribe(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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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) =>
|
|
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
|
},
|
|
@@ -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
|
-
|
|
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") ?? "";
|
package/dist/core-version.d.ts
DELETED
|
@@ -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 };
|
package/dist/core-version.js
DELETED
|
@@ -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 };
|
package/src/core-version.ts
DELETED
|
@@ -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
|
-
}
|