@junejs/server 1.0.0-dev.36 → 1.0.0-dev.45
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 +19 -2
- package/dist/agent-durable.js +118 -7
- package/dist/agent-native.d.ts +13 -2
- package/dist/agent-native.js +108 -6
- package/dist/app.d.ts +1 -0
- package/dist/app.js +10 -1
- package/dist/build.d.ts +3 -3
- package/dist/build.js +8 -2
- package/dist/{config-freeze-ZWC_Qd4h.d.ts → config-freeze-CyXvNqi2.d.ts} +1 -1
- package/dist/config-freeze.d.ts +1 -1
- package/dist/core-version.js +1 -1
- package/dist/dev.d.ts +6 -2
- package/dist/dev.js +40 -9
- package/dist/host.d.ts +1 -0
- package/dist/host.js +2 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +1 -1
- package/dist/{manifest-DSNX4I-G.d.ts → manifest-CwTT1zVs.d.ts} +1 -1
- package/dist/manifest.d.ts +1 -1
- package/dist/manifest.js +3 -1
- package/dist/pipeline.js +15 -6
- package/dist/route-rank.d.ts +10 -0
- package/dist/route-rank.js +38 -0
- package/dist/route-scan-BMbBzPIB.d.ts +20 -0
- package/dist/route-scan.d.ts +2 -2
- package/dist/route-scan.js +47 -2
- package/dist/router.js +58 -44
- package/dist/{worker-Dv4l6Ybs.d.ts → worker-2qbQjj2c.d.ts} +1 -0
- package/dist/worker.d.ts +1 -1
- package/dist/worker.js +52 -76
- package/package.json +3 -3
- package/src/agent-durable.ts +164 -11
- package/src/agent-native.ts +161 -7
- package/src/app.ts +22 -1
- package/src/build.ts +11 -1
- package/src/core-version.ts +1 -1
- package/src/dev.ts +41 -8
- package/src/host.ts +4 -3
- package/src/manifest.ts +2 -0
- package/src/pipeline.ts +20 -4
- package/src/route-rank.ts +60 -0
- package/src/route-scan.ts +78 -1
- package/src/router.ts +75 -58
- package/src/worker.ts +66 -82
- package/dist/route-scan-DMlwoESy.d.ts +0 -13
package/src/agent-native.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
withSystem,
|
|
14
14
|
type ChannelPolicy,
|
|
15
15
|
type EventSink,
|
|
16
|
+
type InputAnnouncement,
|
|
16
17
|
type TurnEvent,
|
|
17
18
|
type Model,
|
|
18
19
|
type Msg,
|
|
@@ -92,7 +93,7 @@ class SqliteSessionStore implements SessionStore {
|
|
|
92
93
|
// generation (audit trail — never deleted), clear the live rows, status → "new".
|
|
93
94
|
// Archive tables + the generation counter are created lazily here, so existing
|
|
94
95
|
// databases stay untouched until the first reset.
|
|
95
|
-
reset(): number {
|
|
96
|
+
reset(inTx?: () => void): number {
|
|
96
97
|
return this.tx(() => {
|
|
97
98
|
this.db.exec(`CREATE TABLE IF NOT EXISTS agent_messages_archive (session_id TEXT, generation INTEGER, seq INTEGER, body TEXT)`);
|
|
98
99
|
this.db.exec(`CREATE TABLE IF NOT EXISTS agent_steps_archive (session_id TEXT, generation INTEGER, id TEXT, output TEXT)`);
|
|
@@ -105,6 +106,7 @@ class SqliteSessionStore implements SessionStore {
|
|
|
105
106
|
this.db.query("DELETE FROM agent_steps WHERE session_id = ?").run(this.sid);
|
|
106
107
|
this.db.query("INSERT INTO agent_sessions (session_id, status) VALUES (?, 'new') ON CONFLICT(session_id) DO UPDATE SET status = 'new'").run(this.sid);
|
|
107
108
|
this.db.query("INSERT INTO agent_session_generations (session_id, generation) VALUES (?, ?) ON CONFLICT(session_id) DO UPDATE SET generation = ?").run(this.sid, generation + 1, generation + 1);
|
|
109
|
+
inTx?.(); // writes into the fresh generation, committed with the archive (#260)
|
|
108
110
|
return generation;
|
|
109
111
|
});
|
|
110
112
|
}
|
|
@@ -120,7 +122,82 @@ class InProcEventSink implements EventSink {
|
|
|
120
122
|
|
|
121
123
|
// `instructions` (the agent's system prompt) is injected into the model per turn
|
|
122
124
|
// by the runtime (withSystem) — single-sourced on the def, not baked into `model`.
|
|
123
|
-
export type AgentDef = {
|
|
125
|
+
export type AgentDef = {
|
|
126
|
+
model: Model;
|
|
127
|
+
tools: Tool[];
|
|
128
|
+
instructions?: string;
|
|
129
|
+
channelInstructions?: Record<string, string | ChannelPolicy>;
|
|
130
|
+
// The app's receiver for input announcements (#260), installed on every session this
|
|
131
|
+
// runtime builds; undelivered announcements from an earlier process are flushed then.
|
|
132
|
+
onInputAnnouncement?: (a: InputAnnouncement) => void | Promise<void>;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// Retries a failed announcement delivery on a timer (#260): the engine keeps an undelivered
|
|
136
|
+
// announcement and flushes again on the next announcement, settled turn or rebuild — but a
|
|
137
|
+
// session that goes quiet would hold it until then. 5 s doubling to 5 min, per session;
|
|
138
|
+
// unref'd, so a pending retry never holds the process open. Owned by the runtime, so the
|
|
139
|
+
// backoff survives an evicted-and-rebuilt actor.
|
|
140
|
+
class AnnouncementRetry {
|
|
141
|
+
// Explicit fields (not parameter properties) — keep the shipped source erasable.
|
|
142
|
+
private readonly flush: (agent: string, id: string) => void;
|
|
143
|
+
private readonly state = new Map<string, { failures: number; timer?: ReturnType<typeof setTimeout> }>();
|
|
144
|
+
private stopped = false;
|
|
145
|
+
constructor(flush: (agent: string, id: string) => void) {
|
|
146
|
+
this.flush = flush;
|
|
147
|
+
}
|
|
148
|
+
delivered(key: string) {
|
|
149
|
+
const s = this.state.get(key);
|
|
150
|
+
if (s?.timer) clearTimeout(s.timer);
|
|
151
|
+
this.state.delete(key);
|
|
152
|
+
}
|
|
153
|
+
failed(key: string, agent: string, id: string) {
|
|
154
|
+
if (this.stopped) return; // the runtime is closed: nothing retries into it
|
|
155
|
+
const s = this.state.get(key) ?? { failures: 0 };
|
|
156
|
+
this.state.set(key, s);
|
|
157
|
+
if (s.timer) return; // one retry pending at a time
|
|
158
|
+
const delay = Math.min(5_000 * 2 ** s.failures, 300_000);
|
|
159
|
+
s.failures++;
|
|
160
|
+
s.timer = setTimeout(() => { s.timer = undefined; this.flush(agent, id); }, delay);
|
|
161
|
+
(s.timer as { unref?: () => void }).unref?.();
|
|
162
|
+
}
|
|
163
|
+
// Cancel every pending retry, for good (#317): unref only keeps a timer from holding the
|
|
164
|
+
// process open — it still fires while the process lives, into a runtime (and a db) that
|
|
165
|
+
// is gone.
|
|
166
|
+
stop() {
|
|
167
|
+
this.stopped = true;
|
|
168
|
+
for (const s of this.state.values()) if (s.timer) clearTimeout(s.timer);
|
|
169
|
+
this.state.clear();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// SQLite handles a runtime opened itself (createNativeRuntime), so close() closes those and
|
|
174
|
+
// never one a caller passed in.
|
|
175
|
+
const ownedDbs = new WeakSet<SyncSqlite>();
|
|
176
|
+
|
|
177
|
+
// Install the announcement hook on a freshly built session and deliver what an earlier life
|
|
178
|
+
// of it recorded but never delivered (#260). The flush never rejects; it logs — and a
|
|
179
|
+
// failure schedules a retry.
|
|
180
|
+
function wireAnnouncements(session: AgentSession, def: AgentDef, retry: AnnouncementRetry): AgentSession {
|
|
181
|
+
const hook = def.onInputAnnouncement;
|
|
182
|
+
if (hook) {
|
|
183
|
+
const key = `${session.agent}:${session.id}`;
|
|
184
|
+
session.onAnnounce = async (a) => {
|
|
185
|
+
try {
|
|
186
|
+
await hook(a);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
retry.failed(key, session.agent, session.id);
|
|
189
|
+
throw err; // the engine keeps the announcement and logs
|
|
190
|
+
}
|
|
191
|
+
retry.delivered(key);
|
|
192
|
+
};
|
|
193
|
+
void session.flushAnnouncements();
|
|
194
|
+
}
|
|
195
|
+
return session;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// The step the engine keeps its announcement outbox under (#260) — read by the native
|
|
199
|
+
// runtime's startup scan. Mirrors ANNOUNCE_OUTBOX in @junejs/core/agent-runtime.
|
|
200
|
+
const ANNOUNCE_OUTBOX_STEP = "announce-outbox";
|
|
124
201
|
|
|
125
202
|
// The runtime-side def for an assembled AgentDefinition (#173): the tools (channel
|
|
126
203
|
// capability tools and read_skill included), the system prompt (instructions + the
|
|
@@ -134,6 +211,7 @@ export function toAgentDef(agent: AgentDefinition, model: Model): AgentDef {
|
|
|
134
211
|
tools: agent.tools,
|
|
135
212
|
instructions: buildSystemPrompt(agent),
|
|
136
213
|
...(agent.channelInstructions ? { channelInstructions: agent.channelInstructions } : {}),
|
|
214
|
+
...(agent.onInputAnnouncement ? { onInputAnnouncement: agent.onInputAnnouncement } : {}),
|
|
137
215
|
};
|
|
138
216
|
}
|
|
139
217
|
|
|
@@ -156,7 +234,10 @@ export class NativeRuntime implements Runtime {
|
|
|
156
234
|
private actors = new Map<string, { session: AgentSession; sink: InProcEventSink }>();
|
|
157
235
|
private readonly agents: Record<string, AgentDef>;
|
|
158
236
|
private readonly db: SyncSqlite;
|
|
237
|
+
// Retries failed announcement deliveries (#260), across actor rebuilds.
|
|
238
|
+
private readonly announceRetry = new AnnouncementRetry((agent, id) => { void this.session(agent, id).flushAnnouncements(); });
|
|
159
239
|
private readonly maxSessions: number;
|
|
240
|
+
private closing: Promise<void> | undefined; // set by close() (#317)
|
|
160
241
|
|
|
161
242
|
constructor(agents: Record<string, AgentDef>, db: SyncSqlite, opts: NativeRuntimeOptions = {}) {
|
|
162
243
|
assertCoreRuntimeVersion("NativeRuntime"); // #94: fail power-on, not mid-turn
|
|
@@ -178,6 +259,9 @@ export class NativeRuntime implements Runtime {
|
|
|
178
259
|
session(agent: string, id: string): AgentSession {
|
|
179
260
|
const key = `${agent}:${id}`;
|
|
180
261
|
const hit = this.actors.get(key);
|
|
262
|
+
// A closing runtime still serves the actors it has (their turns are finishing), but
|
|
263
|
+
// builds no new one.
|
|
264
|
+
if (!hit && this.closing) throw new Error(`NativeRuntime is closed: cannot open session "${key}"`);
|
|
181
265
|
if (hit) {
|
|
182
266
|
// most recently used → the tail (Map iteration order is insertion order)
|
|
183
267
|
this.actors.delete(key);
|
|
@@ -188,7 +272,7 @@ export class NativeRuntime implements Runtime {
|
|
|
188
272
|
if (!def) throw new Error(`unknown agent: ${agent}`);
|
|
189
273
|
const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
|
|
190
274
|
const sink = new InProcEventSink();
|
|
191
|
-
const session = new AgentSession(agent, id, new SqliteSessionStore(this.db, key), sink, model, def.tools, this, def.channelInstructions);
|
|
275
|
+
const session = wireAnnouncements(new AgentSession(agent, id, new SqliteSessionStore(this.db, key), sink, model, def.tools, this, def.channelInstructions), def, this.announceRetry);
|
|
192
276
|
this.evictIdle();
|
|
193
277
|
this.actors.set(key, { session, sink });
|
|
194
278
|
return session;
|
|
@@ -197,6 +281,42 @@ export class NativeRuntime implements Runtime {
|
|
|
197
281
|
// Number of memoized actors (observability / tests).
|
|
198
282
|
get sessionCount(): number { return this.actors.size; }
|
|
199
283
|
|
|
284
|
+
// Deliver announcements an earlier process recorded but never delivered (#260), without
|
|
285
|
+
// waiting for their sessions to be used again: build every session whose outbox is not
|
|
286
|
+
// empty — building one flushes it. The agent and session come from the announcement
|
|
287
|
+
// itself, not from splitting the store key (an agent name may contain ":"). Called once
|
|
288
|
+
// by createNativeRuntime; safe to call again.
|
|
289
|
+
recoverAnnouncements(): void {
|
|
290
|
+
const rows = this.db.query("SELECT output FROM agent_steps WHERE id = ?").all(ANNOUNCE_OUTBOX_STEP) as { output: string }[];
|
|
291
|
+
for (const { output } of rows) {
|
|
292
|
+
const [first] = JSON.parse(output) as InputAnnouncement[];
|
|
293
|
+
if (first && this.agents[first.agent]?.onInputAnnouncement) this.session(first.agent, first.session);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Shut the runtime down (#317). Pending announcement retries are cancelled and no new
|
|
298
|
+
// session opens at once; then it waits until every actor is idle — a running turn or an
|
|
299
|
+
// announcement delivery in flight still writes the store after its hook or model call
|
|
300
|
+
// returns — before dropping the actors and closing the SQLite handle createNativeRuntime
|
|
301
|
+
// opened (a db passed to the constructor stays the caller's). So it resolves only after
|
|
302
|
+
// in-flight work finishes. Call it before discarding a runtime in a process that keeps
|
|
303
|
+
// running (a test suite, a host that swaps runtimes), or a retry fires later against a
|
|
304
|
+
// closed or deleted database. Safe to call twice: both calls resolve together.
|
|
305
|
+
close(): Promise<void> {
|
|
306
|
+
this.closing ??= (async () => {
|
|
307
|
+
this.announceRetry.stop();
|
|
308
|
+
while ([...this.actors.values()].some((a) => !a.session.idle())) {
|
|
309
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
310
|
+
}
|
|
311
|
+
this.actors.clear();
|
|
312
|
+
if (ownedDbs.has(this.db)) {
|
|
313
|
+
ownedDbs.delete(this.db);
|
|
314
|
+
this.db.close();
|
|
315
|
+
}
|
|
316
|
+
})();
|
|
317
|
+
return this.closing;
|
|
318
|
+
}
|
|
319
|
+
|
|
200
320
|
// Drop least recently used idle actors until one more fits under the cap. Runs before
|
|
201
321
|
// the new actor is inserted, so the actor being handed out is never a candidate.
|
|
202
322
|
private evictIdle() {
|
|
@@ -216,7 +336,24 @@ export async function createNativeRuntime(
|
|
|
216
336
|
path = ":memory:",
|
|
217
337
|
opts: NativeRuntimeOptions = {},
|
|
218
338
|
): Promise<NativeRuntime> {
|
|
219
|
-
|
|
339
|
+
const db = await openLocalSqliteSync(path);
|
|
340
|
+
ownedDbs.add(db);
|
|
341
|
+
let runtime: NativeRuntime | undefined;
|
|
342
|
+
try {
|
|
343
|
+
runtime = new NativeRuntime(agents, db, opts);
|
|
344
|
+
runtime.recoverAnnouncements(); // #260
|
|
345
|
+
return runtime;
|
|
346
|
+
} catch (err) {
|
|
347
|
+
// Nobody else holds this handle (a bad maxSessions, unreadable leftovers). A recovery
|
|
348
|
+
// that failed part-way may already have deliveries in flight, so go through close(),
|
|
349
|
+
// which waits for them before closing the db.
|
|
350
|
+
if (runtime) await runtime.close();
|
|
351
|
+
else {
|
|
352
|
+
ownedDbs.delete(db);
|
|
353
|
+
db.close();
|
|
354
|
+
}
|
|
355
|
+
throw err;
|
|
356
|
+
}
|
|
220
357
|
}
|
|
221
358
|
|
|
222
359
|
// ── memory backend — in-process, ephemeral (no DB, no disk) ───────────────────
|
|
@@ -242,12 +379,13 @@ class MemorySessionStore implements SessionStore {
|
|
|
242
379
|
getStatus(): string { return this.status; }
|
|
243
380
|
setStatus(s: string) { this.status = s; }
|
|
244
381
|
tx<T>(fn: () => T): T { return fn(); } // no rollback: an in-memory store is not a durability tier
|
|
245
|
-
reset(): number {
|
|
382
|
+
reset(inTx?: () => void): number {
|
|
246
383
|
const generation = this.generation++;
|
|
247
384
|
this.archives.push({ generation, msgs: this.msgs, steps: this.steps });
|
|
248
385
|
this.msgs = [];
|
|
249
386
|
this.steps = new Map();
|
|
250
387
|
this.status = "new";
|
|
388
|
+
inTx?.(); // writes into the fresh generation (#260)
|
|
251
389
|
return generation;
|
|
252
390
|
}
|
|
253
391
|
unwrap<H = unknown>(): H { return undefined as unknown as H; }
|
|
@@ -257,6 +395,8 @@ export class MemoryRuntime implements Runtime {
|
|
|
257
395
|
private actors = new Map<string, AgentSession>();
|
|
258
396
|
private stores = new Map<string, MemorySessionStore>();
|
|
259
397
|
private readonly agents: Record<string, AgentDef>;
|
|
398
|
+
private readonly announceRetry = new AnnouncementRetry((agent, id) => { void this.session(agent, id).flushAnnouncements(); }); // #260
|
|
399
|
+
private closed = false; // set by close() (#317)
|
|
260
400
|
constructor(agents: Record<string, AgentDef>) {
|
|
261
401
|
assertCoreRuntimeVersion("MemoryRuntime"); // #94: fail power-on, not mid-turn
|
|
262
402
|
this.agents = agents;
|
|
@@ -266,17 +406,28 @@ export class MemoryRuntime implements Runtime {
|
|
|
266
406
|
const key = `${agent}:${id}`;
|
|
267
407
|
let a = this.actors.get(key);
|
|
268
408
|
if (!a) {
|
|
409
|
+
if (this.closed) throw new Error(`MemoryRuntime is closed: cannot open session "${key}"`);
|
|
269
410
|
const def = this.agents[agent];
|
|
270
411
|
if (!def) throw new Error(`unknown agent: ${agent}`);
|
|
271
412
|
const store = new MemorySessionStore();
|
|
272
413
|
this.stores.set(key, store);
|
|
273
414
|
// Same def handling as NativeRuntime: switching backend must not change behavior.
|
|
274
415
|
const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
|
|
275
|
-
a = new AgentSession(agent, id, store, new InProcEventSink(), model, def.tools, this, def.channelInstructions);
|
|
416
|
+
a = wireAnnouncements(new AgentSession(agent, id, store, new InProcEventSink(), model, def.tools, this, def.channelInstructions), def, this.announceRetry);
|
|
276
417
|
this.actors.set(key, a);
|
|
277
418
|
}
|
|
278
419
|
return a;
|
|
279
420
|
}
|
|
421
|
+
// Shut the runtime down (#317): cancel pending announcement retries and open no new
|
|
422
|
+
// session. The state is the actors themselves, so they are dropped too; with no store
|
|
423
|
+
// to close there is nothing to wait for — work still in flight finishes against its own
|
|
424
|
+
// in-memory store. Safe to call twice.
|
|
425
|
+
async close(): Promise<void> {
|
|
426
|
+
this.closed = true;
|
|
427
|
+
this.announceRetry.stop();
|
|
428
|
+
this.actors.clear();
|
|
429
|
+
this.stores.clear();
|
|
430
|
+
}
|
|
280
431
|
}
|
|
281
432
|
|
|
282
433
|
// The selectable agent-runtime backends. `native` (SQLite via june/host, durable
|
|
@@ -288,10 +439,13 @@ export type AgentBackend = "native" | "memory" | "durable";
|
|
|
288
439
|
// Build an in-process runtime for the chosen backend. Throws for `durable` (that
|
|
289
440
|
// target is the DO the worker constructs, not an in-process object) — so the
|
|
290
441
|
// choice is explicit and a mis-selection fails loudly.
|
|
442
|
+
// An in-process runtime: a Runtime the host owns, so it can shut it down (#317).
|
|
443
|
+
export type InProcessRuntime = Runtime & { close(): Promise<void> };
|
|
444
|
+
|
|
291
445
|
export async function createAgentRuntime(
|
|
292
446
|
agents: Record<string, AgentDef>,
|
|
293
447
|
opts: { backend?: AgentBackend; path?: string; maxSessions?: number } = {},
|
|
294
|
-
): Promise<
|
|
448
|
+
): Promise<InProcessRuntime> {
|
|
295
449
|
const backend = opts.backend ?? "native";
|
|
296
450
|
if (backend === "memory") return new MemoryRuntime(agents);
|
|
297
451
|
if (backend === "native") return createNativeRuntime(agents, opts.path, { maxSessions: opts.maxSessions });
|
package/src/app.ts
CHANGED
|
@@ -27,9 +27,10 @@ import type { DocumentConfig } from "@junejs/core/document";
|
|
|
27
27
|
import { runWithTrace, type RequestTrace } from "@junejs/core/instrumentation";
|
|
28
28
|
|
|
29
29
|
import { findMiddlewareFile, isResourceFile, listRoutes, matchRouteTree, resolveNotFound, routeFiles, type SegmentMatch } from "./router";
|
|
30
|
+
import { routeProblems, scanRoutes } from "./route-scan";
|
|
30
31
|
import { createPipeline, type ExtraHandler, type LayoutComponent, type MiddlewareHandler, type Pipeline, type Resolved, type ResourceHandler } from "./pipeline";
|
|
31
32
|
import { discoverAgent } from "./agent-discover";
|
|
32
|
-
import { createAgentRuntime, mountAgent, toAgentDef } from "./agent-native";
|
|
33
|
+
import { createAgentRuntime, mountAgent, toAgentDef, type InProcessRuntime } from "./agent-native";
|
|
33
34
|
import { anthropic } from "@junejs/core/agent-models";
|
|
34
35
|
import { resolveBoundary } from "./segment";
|
|
35
36
|
import { memoizeResources } from "./resources";
|
|
@@ -65,6 +66,10 @@ export type JuneApp = {
|
|
|
65
66
|
warmup(): Promise<void>;
|
|
66
67
|
routePaths(): Promise<string[]>;
|
|
67
68
|
earlyHints(): string[];
|
|
69
|
+
// Shut down what the app started (#317): the agent runtime built for an agent/
|
|
70
|
+
// directory — its pending announcement retries, its in-flight turns, its SQLite
|
|
71
|
+
// handle. Resolves once they finish. A no-op when no runtime was built.
|
|
72
|
+
close(): Promise<void>;
|
|
68
73
|
};
|
|
69
74
|
|
|
70
75
|
// Import a layout file's default export as a layout component (memoized), plus
|
|
@@ -226,6 +231,8 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
|
|
|
226
231
|
// pipeline is built lazily on first fetch, memoized after.
|
|
227
232
|
let pipelinePromise: Promise<Pipeline> | undefined;
|
|
228
233
|
const getPipeline = (): Promise<Pipeline> => (pipelinePromise ??= buildPipeline());
|
|
234
|
+
// The agent runtime buildPipeline creates, kept so close() can shut it down (#317).
|
|
235
|
+
let agentRuntime: InProcessRuntime | undefined;
|
|
229
236
|
async function buildPipeline(): Promise<Pipeline> {
|
|
230
237
|
// Wire CSS Modules BEFORE any route import: the runtime interceptor must be
|
|
231
238
|
// active so `import "x.module.css"` in a route resolves to the scoped map,
|
|
@@ -264,6 +271,7 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
|
|
|
264
271
|
// toAgentDef: tools, prompt AND the per-surface policies (#149) from the one
|
|
265
272
|
// definition — channelInstructions was previously dropped on this path (#173).
|
|
266
273
|
const rt = await createAgentRuntime({ [def.name]: toAgentDef(def, model) }, { backend });
|
|
274
|
+
agentRuntime = rt;
|
|
267
275
|
const mounted = mountAgent(def, rt, { chatPath: agent.runtime.chat.path, channels: agent.runtime.channels });
|
|
268
276
|
agentSurface = (req) => mounted.surface(req);
|
|
269
277
|
await mounted.startAll();
|
|
@@ -443,6 +451,13 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
|
|
|
443
451
|
return toPipeline();
|
|
444
452
|
},
|
|
445
453
|
async warmup() {
|
|
454
|
+
// What `june build` refuses (same-path files, a segment after an optional
|
|
455
|
+
// or catch-all), dev reports up front — it still serves what it can.
|
|
456
|
+
const problems = routeProblems(
|
|
457
|
+
[await scanRoutes(appDir), hasJuneRoutes ? await scanRoutes(juneRoutesDir) : []],
|
|
458
|
+
dirname(appDir),
|
|
459
|
+
);
|
|
460
|
+
if (problems) console.error(problems);
|
|
446
461
|
const files = await routeFiles(appDir, { pageConvention: true });
|
|
447
462
|
if (hasJuneRoutes) files.push(...(await routeFiles(juneRoutesDir, { pageConvention: true })));
|
|
448
463
|
for (const file of files) {
|
|
@@ -453,5 +468,11 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
|
|
|
453
468
|
},
|
|
454
469
|
routePaths,
|
|
455
470
|
earlyHints: () => config.earlyHints ?? [],
|
|
471
|
+
async close() {
|
|
472
|
+
// A pipeline still being built may be about to create the runtime: let it finish
|
|
473
|
+
// (its failure is the fetch's to report, not close()'s).
|
|
474
|
+
await pipelinePromise?.catch(() => {});
|
|
475
|
+
await agentRuntime?.close();
|
|
476
|
+
},
|
|
456
477
|
};
|
|
457
478
|
}
|
package/src/build.ts
CHANGED
|
@@ -300,6 +300,12 @@ export async function juneBuild(
|
|
|
300
300
|
const resourceRoutesField = resources.length
|
|
301
301
|
? `\n resourceRoutes: [\n${resources.join("\n")}\n ],`
|
|
302
302
|
: "";
|
|
303
|
+
// .june/routes/ patterns rank after every app/ route (dev falls back to them).
|
|
304
|
+
// Only emitted when there are some, so bundles without them stay byte-identical.
|
|
305
|
+
const generatedPaths = routes.filter((r) => r.generated).map((r) => r.path);
|
|
306
|
+
const generatedRoutesField = generatedPaths.length
|
|
307
|
+
? `\n generatedRoutes: ${JSON.stringify(generatedPaths)},`
|
|
308
|
+
: "";
|
|
303
309
|
// Only emitted when some route declares a boundary, so boundary-less bundles
|
|
304
310
|
// stay byte-identical (additive manifest field, like resources).
|
|
305
311
|
const layoutBoundariesField = boundaries.length
|
|
@@ -417,6 +423,10 @@ export class JuneAgentDO extends DurableObject {
|
|
|
417
423
|
fetch(req: Request): Promise<Response> {
|
|
418
424
|
return this.#agent.fetch(req);
|
|
419
425
|
}
|
|
426
|
+
// Delivers input announcements left undelivered — a failed hook's retry, or the watchdog (#260).
|
|
427
|
+
alarm(): Promise<void> {
|
|
428
|
+
return this.#agent.alarm();
|
|
429
|
+
}
|
|
420
430
|
}
|
|
421
431
|
`
|
|
422
432
|
: "";
|
|
@@ -431,7 +441,7 @@ ${statics.join("\n")}
|
|
|
431
441
|
},
|
|
432
442
|
dynamicRoutes: [
|
|
433
443
|
${dynamics.join("\n")}
|
|
434
|
-
],${resourceRoutesField}
|
|
444
|
+
],${resourceRoutesField}${generatedRoutesField}
|
|
435
445
|
layoutChains: {
|
|
436
446
|
${chains.join("\n")}
|
|
437
447
|
},${layoutBoundariesField}
|
package/src/core-version.ts
CHANGED
|
@@ -15,7 +15,7 @@ import * as coreRuntime from "@junejs/core/agent-runtime";
|
|
|
15
15
|
|
|
16
16
|
// The contract number this server tree is built against. Bump in lockstep with
|
|
17
17
|
// core's RUNTIME_API_VERSION whenever the server↔core runtime contract changes.
|
|
18
|
-
const EXPECTED_CORE_RUNTIME_API =
|
|
18
|
+
const EXPECTED_CORE_RUNTIME_API = 6;
|
|
19
19
|
|
|
20
20
|
export function assertCoreRuntimeVersion(surface: string): void {
|
|
21
21
|
checkCoreRuntimeVersion(surface, (coreRuntime as { RUNTIME_API_VERSION?: number }).RUNTIME_API_VERSION);
|
package/src/dev.ts
CHANGED
|
@@ -19,20 +19,41 @@ import { findGlobalCss, processCssCached, invalidateCss } from "./css";
|
|
|
19
19
|
export type DevServerOptions = {
|
|
20
20
|
appDir: string;
|
|
21
21
|
port?: number;
|
|
22
|
+
// The address to bind. Default 127.0.0.1: the dev server is reachable from this
|
|
23
|
+
// machine only (MCP: "when running locally, servers SHOULD bind only to
|
|
24
|
+
// localhost"). "0.0.0.0" opens it to the LAN — `june dev --host`.
|
|
25
|
+
hostname?: string;
|
|
22
26
|
host?: JuneHost;
|
|
23
27
|
};
|
|
24
28
|
|
|
25
|
-
|
|
29
|
+
// The Host names a dev server answers /mcp and /api for (#308): localhost and its
|
|
30
|
+
// subdomains, plus IP literals (always allowed — rebinding needs a domain name),
|
|
31
|
+
// plus a named --host and whatever the config allows.
|
|
32
|
+
// The URL to print and return: localhost for loopback or a wildcard bind (it
|
|
33
|
+
// answers there too), else the one address bound — nothing listens on loopback then.
|
|
34
|
+
export function devUrl(hostname: string, port: number): string {
|
|
35
|
+
if (["127.0.0.1", "0.0.0.0", "::", "localhost"].includes(hostname)) return `http://localhost:${port}`;
|
|
36
|
+
return `http://${hostname.includes(":") ? `[${hostname}]` : hostname}:${port}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function devAllowedHosts(configured: readonly string[] | undefined, hostname: string): string[] {
|
|
40
|
+
const hosts = new Set(["localhost", ".localhost", ...(configured ?? [])]);
|
|
41
|
+
if (!/^[\d.]+$|:/.test(hostname)) hosts.add(hostname.toLowerCase()); // a name, not 0.0.0.0 / an IP
|
|
42
|
+
return [...hosts];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// stop() also shuts the app down (its agent runtime, #317); await it to know that's done.
|
|
46
|
+
export type DevServer = Omit<ServeHandle, "stop"> & { url: string; stop(force?: boolean): Promise<void> };
|
|
26
47
|
|
|
27
48
|
// A taken default port must not be a dead end in dev — walk forward until a
|
|
28
49
|
// port binds (the Vite convention). Probed with node:net, which both hosts
|
|
29
50
|
// implement, so the host interface stays untouched.
|
|
30
|
-
async function findFreePort(start: number, tries = 20): Promise<number> {
|
|
51
|
+
async function findFreePort(start: number, hostname: string, tries = 20): Promise<number> {
|
|
31
52
|
for (let p = start; p < start + tries; p++) {
|
|
32
53
|
const free = await new Promise<boolean>((resolve) => {
|
|
33
54
|
const probe = createNetServer();
|
|
34
55
|
probe.once("error", () => resolve(false));
|
|
35
|
-
probe.listen(p, () => probe.close(() => resolve(true)));
|
|
56
|
+
probe.listen(p, hostname, () => probe.close(() => resolve(true)));
|
|
36
57
|
});
|
|
37
58
|
if (free) return p;
|
|
38
59
|
}
|
|
@@ -42,10 +63,13 @@ async function findFreePort(start: number, tries = 20): Promise<number> {
|
|
|
42
63
|
export async function startDevServer({
|
|
43
64
|
appDir,
|
|
44
65
|
port = 3000,
|
|
66
|
+
hostname = "127.0.0.1",
|
|
45
67
|
host = defaultHost,
|
|
46
68
|
}: DevServerOptions): Promise<DevServer> {
|
|
47
69
|
await installAsyncContext();
|
|
48
|
-
const
|
|
70
|
+
const loaded = await loadJuneConfig(appDir);
|
|
71
|
+
// The DNS-rebinding check (#308): a local server is exactly what rebinding targets.
|
|
72
|
+
const config = { ...loaded, agent: { ...loaded.agent, allowedHosts: devAllowedHosts(loaded.agent?.allowedHosts, hostname) } };
|
|
49
73
|
|
|
50
74
|
// Apply pending migrations before serving — dev auto-applies the SAFE ones; a
|
|
51
75
|
// destructive one is reported and skipped (the server still starts, but the
|
|
@@ -57,7 +81,7 @@ export async function startDevServer({
|
|
|
57
81
|
const app = createApp({ appDir, config });
|
|
58
82
|
await app.warmup();
|
|
59
83
|
|
|
60
|
-
const freePort = await findFreePort(port);
|
|
84
|
+
const freePort = await findFreePort(port, hostname);
|
|
61
85
|
if (freePort !== port) console.log(`[june] port ${port} is taken → using ${freePort}`);
|
|
62
86
|
port = freePort;
|
|
63
87
|
|
|
@@ -65,6 +89,7 @@ export async function startDevServer({
|
|
|
65
89
|
// dev/built parity) never sees it. See dev-reload.ts.
|
|
66
90
|
const handle = host.serve(withLiveReload((req) => app.fetch(req)), {
|
|
67
91
|
port,
|
|
92
|
+
hostname,
|
|
68
93
|
earlyHints: () => app.earlyHints(),
|
|
69
94
|
});
|
|
70
95
|
|
|
@@ -87,7 +112,15 @@ export async function startDevServer({
|
|
|
87
112
|
void processCssCached(appDir).catch(() => {});
|
|
88
113
|
}
|
|
89
114
|
|
|
90
|
-
const url =
|
|
91
|
-
|
|
92
|
-
|
|
115
|
+
const url = devUrl(hostname, handle.port);
|
|
116
|
+
const lan = hostname === "127.0.0.1" ? "" : ` · listening on ${hostname}`;
|
|
117
|
+
console.log(`june dev → ${url} (host: ${host.name})${lan}`);
|
|
118
|
+
return {
|
|
119
|
+
...handle,
|
|
120
|
+
url,
|
|
121
|
+
async stop(force?: boolean) {
|
|
122
|
+
handle.stop(force);
|
|
123
|
+
await app.close();
|
|
124
|
+
},
|
|
125
|
+
};
|
|
93
126
|
}
|
package/src/host.ts
CHANGED
|
@@ -42,7 +42,8 @@ export interface JuneHost {
|
|
|
42
42
|
readonly name: "bun" | "node";
|
|
43
43
|
serve(
|
|
44
44
|
handler: (req: Request) => Promise<Response>,
|
|
45
|
-
|
|
45
|
+
// hostname: the address to bind. Absent = all interfaces (production, containers).
|
|
46
|
+
opts: { port: number; hostname?: string; earlyHints?: () => string[] },
|
|
46
47
|
): ServeHandle;
|
|
47
48
|
// Spawn a module in a child runtime (the react-server Flight renderer, which
|
|
48
49
|
// must run under a different module-resolution condition). Phase 4 supersedes
|
|
@@ -63,7 +64,7 @@ function bunHost(): JuneHost {
|
|
|
63
64
|
// idleTimeout 0: Bun's 10s default kills quiet long-lived responses —
|
|
64
65
|
// the dev live-reload SSE most visibly (the browser then reconnects,
|
|
65
66
|
// reads it as a server restart, and reloads the page every 10s).
|
|
66
|
-
const server = Bun.serve({ port: opts.port, fetch: handler, idleTimeout: 0 });
|
|
67
|
+
const server = Bun.serve({ port: opts.port, hostname: opts.hostname, fetch: handler, idleTimeout: 0 });
|
|
67
68
|
return { port: server.port ?? opts.port, stop: (force) => void server.stop(force) };
|
|
68
69
|
},
|
|
69
70
|
spawnModule(entry, args, opts) {
|
|
@@ -125,7 +126,7 @@ function nodeHost(): JuneHost {
|
|
|
125
126
|
res.end("Internal Server Error");
|
|
126
127
|
}
|
|
127
128
|
});
|
|
128
|
-
server.listen(opts.port);
|
|
129
|
+
server.listen(opts.port, opts.hostname);
|
|
129
130
|
return {
|
|
130
131
|
get port() {
|
|
131
132
|
const addr = server.address();
|
package/src/manifest.ts
CHANGED
|
@@ -63,6 +63,7 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
|
|
|
63
63
|
const routes: Record<string, BrandedRoute> = {};
|
|
64
64
|
const dynamicRoutes: Array<{ pattern: string; def: BrandedRoute }> = [];
|
|
65
65
|
const resourceRoutes: Array<{ pattern: string; handler: ResourceHandler }> = [];
|
|
66
|
+
const generatedRoutes = scanned.filter((r) => r.generated).map((r) => r.path);
|
|
66
67
|
const layoutChains: Record<string, LayoutComponent[]> = {};
|
|
67
68
|
const layoutBoundaries: Record<string, { index: number; key: string }> = {};
|
|
68
69
|
const loadings: Record<string, LoadingComponent> = {};
|
|
@@ -115,6 +116,7 @@ export async function buildManifest(appRoot: string): Promise<WorkerManifest> {
|
|
|
115
116
|
routes,
|
|
116
117
|
dynamicRoutes,
|
|
117
118
|
resourceRoutes,
|
|
119
|
+
...(generatedRoutes.length ? { generatedRoutes } : {}),
|
|
118
120
|
layoutChains,
|
|
119
121
|
layoutBoundaries,
|
|
120
122
|
loadings,
|
package/src/pipeline.ts
CHANGED
|
@@ -48,9 +48,10 @@ import {
|
|
|
48
48
|
siteSkill,
|
|
49
49
|
sitemapXml,
|
|
50
50
|
} from "@junejs/core/discovery";
|
|
51
|
-
import { mcpHandler, mcpServerIdentity, mcpTools } from "@junejs/core/mcp";
|
|
51
|
+
import { mcpForbidden, mcpHandler, mcpServerIdentity, mcpTools, originRejection } from "@junejs/core/mcp";
|
|
52
52
|
import {
|
|
53
53
|
apiActionId,
|
|
54
|
+
apiForbidden,
|
|
54
55
|
apiHandler,
|
|
55
56
|
apiNamespaceResponse,
|
|
56
57
|
isApiNamespace,
|
|
@@ -854,6 +855,14 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
854
855
|
}
|
|
855
856
|
}
|
|
856
857
|
|
|
858
|
+
// Which browsers may call /mcp and /api (#308) — the config's allowlists.
|
|
859
|
+
const originPolicy = { allowedOrigins: agent.allowedOrigins, allowedHosts: agent.allowedHosts };
|
|
860
|
+
// Only a POST runs an action on /mcp or /api; any other method is a CORS
|
|
861
|
+
// preflight or a 405. A preflight carries no credentials, so a cookie-reading
|
|
862
|
+
// resolver could throw on it — never resolve identity for one.
|
|
863
|
+
const callerIdentity = async (request: Request) =>
|
|
864
|
+
request.method === "POST" && cfg.identity ? await cfg.identity(request) : undefined;
|
|
865
|
+
|
|
857
866
|
async function handleRequest(request: Request): Promise<Response> {
|
|
858
867
|
const url = new URL(request.url);
|
|
859
868
|
|
|
@@ -864,11 +873,16 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
864
873
|
// ambient `db` is the SAME resource the UI uses, and — via cfg.identity —
|
|
865
874
|
// the same principal: requiresPrincipal actions and per-call connection
|
|
866
875
|
// auth are live on this surface. ctx carries identity only — not resources.
|
|
867
|
-
|
|
876
|
+
// The origin policy first: a refused browser call must not reach the
|
|
877
|
+
// identity resolver (its I/O, or a throw that would turn 403 into 500).
|
|
878
|
+
const refused = originRejection(request, originPolicy);
|
|
879
|
+
if (refused !== undefined) return mcpForbidden(refused);
|
|
880
|
+
const identity = await callerIdentity(request);
|
|
868
881
|
return mcpHandler(
|
|
869
882
|
request,
|
|
870
883
|
{ request, ...identity },
|
|
871
884
|
mcpServerIdentity(url.origin, { site: docConfig.site, agent }),
|
|
885
|
+
originPolicy,
|
|
872
886
|
);
|
|
873
887
|
}
|
|
874
888
|
// The REST projection of the same actions: POST /api/<id> dispatches through
|
|
@@ -887,8 +901,10 @@ export function createPipeline(cfg: PipelineConfig): Pipeline {
|
|
|
887
901
|
}
|
|
888
902
|
const actionId = apiActionId(url.pathname);
|
|
889
903
|
if (actionId) {
|
|
890
|
-
const
|
|
891
|
-
|
|
904
|
+
const refused = originRejection(request, originPolicy);
|
|
905
|
+
if (refused !== undefined) return apiForbidden(request, refused);
|
|
906
|
+
const identity = await callerIdentity(request);
|
|
907
|
+
return apiHandler(request, actionId, { request, ...identity }, originPolicy);
|
|
892
908
|
}
|
|
893
909
|
}
|
|
894
910
|
// The server card is read cross-origin by browser-based MCP clients; its
|
|
@@ -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
|
+
}
|