@junejs/server 1.0.0-dev.42 → 1.0.0-dev.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-durable.d.ts +4 -0
- package/dist/agent-durable.js +22 -4
- package/dist/agent-native.d.ts +18 -2
- package/dist/agent-native.js +62 -8
- package/dist/app.d.ts +1 -0
- package/dist/app.js +18 -2
- package/dist/build.d.ts +3 -3
- package/dist/build.js +4 -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 +2 -2
- 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/sqlite-driver.d.ts +1 -0
- package/dist/sqlite-driver.js +17 -4
- package/dist/{worker-Dv4l6Ybs.d.ts → worker-2qbQjj2c.d.ts} +1 -0
- package/dist/worker.d.ts +1 -1
- package/dist/worker.js +59 -77
- package/package.json +3 -3
- package/src/agent-durable.ts +24 -3
- package/src/agent-native.ts +99 -13
- package/src/app.ts +34 -2
- package/src/build.ts +7 -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/sqlite-driver.ts +36 -6
- package/src/worker.ts +76 -82
- package/dist/route-scan-DMlwoESy.d.ts +0 -13
package/src/agent-durable.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import {
|
|
19
19
|
AgentSession,
|
|
20
|
+
FatalToolError,
|
|
20
21
|
grantAnswer,
|
|
21
22
|
ResumeAuthorizationError,
|
|
22
23
|
withSystem,
|
|
@@ -39,6 +40,7 @@ import {
|
|
|
39
40
|
} from "@junejs/core/agent-runtime";
|
|
40
41
|
import type { Principal } from "@junejs/core/context";
|
|
41
42
|
import type { Resources } from "@junejs/core/resources";
|
|
43
|
+
import { blobFileStore, type FileStore } from "@junejs/core/agent-files";
|
|
42
44
|
import { observeTurnEvents } from "./turn-events";
|
|
43
45
|
import {
|
|
44
46
|
channelDispatch,
|
|
@@ -255,6 +257,10 @@ export type DoAgentDef = {
|
|
|
255
257
|
// Receives this session's input announcements (#260) — run inside the request scope, so it
|
|
256
258
|
// can write the app's cross-session index with the ambient db.
|
|
257
259
|
onInputAnnouncement?: (a: InputAnnouncement) => void | Promise<void>;
|
|
260
|
+
// Where this session's tools store the files they produce (docs/rfc-agent-files.md), and
|
|
261
|
+
// where host-side delivery reads them back. Default: the `blob` of `resources` (an R2
|
|
262
|
+
// bucket), under agent-files/. Without either, a tool's ctx.files.put fails the turn.
|
|
263
|
+
files?: FileStore;
|
|
258
264
|
};
|
|
259
265
|
|
|
260
266
|
// The agent runtime INSIDE a Durable Object. A plain class (constructor takes the
|
|
@@ -300,6 +306,7 @@ export class AgentDurableObject {
|
|
|
300
306
|
private readonly authorizeAnswer?: AuthorizeAnswer;
|
|
301
307
|
private readonly onInputAnnouncement?: (a: InputAnnouncement) => void | Promise<void>;
|
|
302
308
|
private readonly storage: DurableStorage;
|
|
309
|
+
private readonly files: FileStore;
|
|
303
310
|
// Consecutive failed announcement deliveries (#260) — sets the alarm retry's backoff.
|
|
304
311
|
private announceFailures = 0;
|
|
305
312
|
private resolveResources(): Promise<Resources> {
|
|
@@ -347,6 +354,15 @@ export class AgentDurableObject {
|
|
|
347
354
|
this.onInputAnnouncement = def.onInputAnnouncement;
|
|
348
355
|
this.storage = state.storage;
|
|
349
356
|
const name = def.name ?? "agent";
|
|
357
|
+
// The blob resource opens with the rest of resources, lazily — the store is installed
|
|
358
|
+
// on the session before then, and only a tool that stores a file opens it.
|
|
359
|
+
this.files =
|
|
360
|
+
def.files ??
|
|
361
|
+
blobFileStore(async () => {
|
|
362
|
+
const blob = (await this.resolveResources()).blob;
|
|
363
|
+
if (!blob) throw new FatalToolError(`agent "${name}": no file store — declare a blob resource (DoAgentDef.resources) or pass DoAgentDef.files`);
|
|
364
|
+
return blob;
|
|
365
|
+
});
|
|
350
366
|
// Failure observability (#76): a turn that dies after the fast-ACK has no other
|
|
351
367
|
// observable surface on the edge — the webhook already 200'd and runBackground
|
|
352
368
|
// swallows the rejection unless the channel wired onError. Subscribing HERE (the
|
|
@@ -459,6 +475,7 @@ export class AgentDurableObject {
|
|
|
459
475
|
if (key !== undefined && key !== "self" && stored === undefined) this.store.setSessionKey(key);
|
|
460
476
|
this.sessionKey = resolved;
|
|
461
477
|
this.session = new AgentSession(this.name, resolved, this.store, this.sink, this.model, this.tools, crossDoUnsupported, this.channelInstructions);
|
|
478
|
+
this.session.files = this.files;
|
|
462
479
|
// A held turn (#263) that asked for delivery is rendered through its source channel when
|
|
463
480
|
// it finally runs — possibly in a later life of this object, which is why the ask rides
|
|
464
481
|
// in the turn's hostContext and this hook is installed on every rebuild.
|
|
@@ -537,7 +554,7 @@ export class AgentDurableObject {
|
|
|
537
554
|
const events: AsyncIterable<TurnEvent> = { [Symbol.asyncIterator]: () => it };
|
|
538
555
|
const target = { channelId: event.channelId, threadId: event.threadId, recipientUserId: event.user?.id, recipientTeamId: event.teamId };
|
|
539
556
|
this.ready()
|
|
540
|
-
.then((resources) => runInScope({ resources, services: this.services }, () => channel.deliver!(target, events, { session: this.sessionKey })))
|
|
557
|
+
.then((resources) => runInScope({ resources, services: this.services }, () => channel.deliver!(target, events, { session: this.sessionKey, files: this.files })))
|
|
541
558
|
.catch(logRenderFailure)
|
|
542
559
|
.finally(() => { void it.return?.(); });
|
|
543
560
|
} catch (err) {
|
|
@@ -682,7 +699,7 @@ export class AgentDurableObject {
|
|
|
682
699
|
const events: AsyncIterable<TurnEvent> = { [Symbol.asyncIterator]: () => it };
|
|
683
700
|
const target = { channelId: event!.channelId, threadId: event!.threadId, recipientUserId: event!.user?.id, recipientTeamId: event!.teamId };
|
|
684
701
|
Promise.resolve()
|
|
685
|
-
.then(() => runInScope({ resources, services: this.services }, () => deliverChannel!.deliver!(target, events, { session: this.sessionKey })))
|
|
702
|
+
.then(() => runInScope({ resources, services: this.services }, () => deliverChannel!.deliver!(target, events, { session: this.sessionKey, files: this.files })))
|
|
686
703
|
.catch(logRenderFailure)
|
|
687
704
|
.finally(() => { void it.return?.(); });
|
|
688
705
|
} catch (err) {
|
|
@@ -770,7 +787,7 @@ export class AgentDurableObject {
|
|
|
770
787
|
const it = observeTurnEvents(session, turnId)[Symbol.asyncIterator]();
|
|
771
788
|
const events: AsyncIterable<TurnEvent> = { [Symbol.asyncIterator]: () => it };
|
|
772
789
|
Promise.resolve()
|
|
773
|
-
.then(() => runInScope({ resources, services: this.services }, () => resumeChannel!.deliverResume!(target!, events, { session: this.sessionKey })))
|
|
790
|
+
.then(() => runInScope({ resources, services: this.services }, () => resumeChannel!.deliverResume!(target!, events, { session: this.sessionKey, files: this.files })))
|
|
774
791
|
.catch(logRenderFailure)
|
|
775
792
|
.finally(() => { void it.return?.(); });
|
|
776
793
|
} catch (err) {
|
|
@@ -1079,6 +1096,9 @@ export function durableChannelSurface(
|
|
|
1079
1096
|
// agentName must have one provider.
|
|
1080
1097
|
services?: (env: unknown) => unknown;
|
|
1081
1098
|
waitUntil?: (p: Promise<unknown>) => void;
|
|
1099
|
+
// Where the channels read the files a turn attached (docs/rfc-agent-files.md) — the
|
|
1100
|
+
// same storage the Durable Object's tools write, opened from this isolate's env.
|
|
1101
|
+
files?: FileStore;
|
|
1082
1102
|
},
|
|
1083
1103
|
): (req: Request) => Promise<Response | null> {
|
|
1084
1104
|
const resolved = opts.channels.map((c) => resolveChannel(c, opts.env));
|
|
@@ -1088,6 +1108,7 @@ export function durableChannelSurface(
|
|
|
1088
1108
|
agent: { name: opts.agentName, instructions: "", tools: [], skills: [], channels: [], connections: [] } satisfies AgentDefinition,
|
|
1089
1109
|
services: resolveServices(opts.services, opts.env, opts.agentName),
|
|
1090
1110
|
waitUntil: opts.waitUntil,
|
|
1111
|
+
files: opts.files,
|
|
1091
1112
|
run: async (message, o) => {
|
|
1092
1113
|
const namespace = getNamespace();
|
|
1093
1114
|
if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
|
package/src/agent-native.ts
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// SessionStore = a session-scoped view over one shared synchronous SQLite handle
|
|
4
4
|
// (bun:sqlite under Bun, node:sqlite under Node — the same handle openLocalSqlite
|
|
5
5
|
// wraps as the async JuneDb, opened here directly because the durability tx must
|
|
6
|
-
// be synchronous).
|
|
6
|
+
// be synchronous). EventSink = an in-process subscriber set. Turn serialization
|
|
7
7
|
// comes from the AgentSession actor in core. On the edge target this same shape
|
|
8
|
-
// is reimplemented over a Durable Object's ctx.storage.sql (
|
|
8
|
+
// is reimplemented over a Durable Object's ctx.storage.sql (agent-durable.ts).
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
11
|
AgentSession,
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
type Tool,
|
|
23
23
|
} from "@junejs/core/agent-runtime";
|
|
24
24
|
import { buildSystemPrompt, channelFetch, type AgentDefinition, type ChannelContext } from "@junejs/core/agent-config";
|
|
25
|
+
import type { FileStore } from "@junejs/core/agent-files";
|
|
25
26
|
import { openLocalSqliteSync, type SyncSqlite } from "./sqlite-driver";
|
|
26
27
|
import { assertCoreRuntimeVersion } from "./core-version";
|
|
27
28
|
import { observeTurnEvents } from "./turn-events";
|
|
@@ -141,6 +142,7 @@ class AnnouncementRetry {
|
|
|
141
142
|
// Explicit fields (not parameter properties) — keep the shipped source erasable.
|
|
142
143
|
private readonly flush: (agent: string, id: string) => void;
|
|
143
144
|
private readonly state = new Map<string, { failures: number; timer?: ReturnType<typeof setTimeout> }>();
|
|
145
|
+
private stopped = false;
|
|
144
146
|
constructor(flush: (agent: string, id: string) => void) {
|
|
145
147
|
this.flush = flush;
|
|
146
148
|
}
|
|
@@ -150,6 +152,7 @@ class AnnouncementRetry {
|
|
|
150
152
|
this.state.delete(key);
|
|
151
153
|
}
|
|
152
154
|
failed(key: string, agent: string, id: string) {
|
|
155
|
+
if (this.stopped) return; // the runtime is closed: nothing retries into it
|
|
153
156
|
const s = this.state.get(key) ?? { failures: 0 };
|
|
154
157
|
this.state.set(key, s);
|
|
155
158
|
if (s.timer) return; // one retry pending at a time
|
|
@@ -158,8 +161,20 @@ class AnnouncementRetry {
|
|
|
158
161
|
s.timer = setTimeout(() => { s.timer = undefined; this.flush(agent, id); }, delay);
|
|
159
162
|
(s.timer as { unref?: () => void }).unref?.();
|
|
160
163
|
}
|
|
164
|
+
// Cancel every pending retry, for good (#317): unref only keeps a timer from holding the
|
|
165
|
+
// process open — it still fires while the process lives, into a runtime (and a db) that
|
|
166
|
+
// is gone.
|
|
167
|
+
stop() {
|
|
168
|
+
this.stopped = true;
|
|
169
|
+
for (const s of this.state.values()) if (s.timer) clearTimeout(s.timer);
|
|
170
|
+
this.state.clear();
|
|
171
|
+
}
|
|
161
172
|
}
|
|
162
173
|
|
|
174
|
+
// SQLite handles a runtime opened itself (createNativeRuntime), so close() closes those and
|
|
175
|
+
// never one a caller passed in.
|
|
176
|
+
const ownedDbs = new WeakSet<SyncSqlite>();
|
|
177
|
+
|
|
163
178
|
// Install the announcement hook on a freshly built session and deliver what an earlier life
|
|
164
179
|
// of it recorded but never delivered (#260). The flush never rejects; it logs — and a
|
|
165
180
|
// failure schedules a retry.
|
|
@@ -207,6 +222,10 @@ export type NativeRuntimeOptions = {
|
|
|
207
222
|
// dropped; their state is in SQLite, so the next session() rebuilds them transparently.
|
|
208
223
|
// Busy actors are never dropped, so the count can exceed the cap while they run.
|
|
209
224
|
maxSessions?: number;
|
|
225
|
+
// Where the sessions' tools store the files they produce (docs/rfc-agent-files.md), and
|
|
226
|
+
// where mountAgent's channels read them back to deliver. Absent: ctx.files.put fails the
|
|
227
|
+
// turn naming the missing configuration.
|
|
228
|
+
files?: FileStore;
|
|
210
229
|
};
|
|
211
230
|
|
|
212
231
|
// The native Runtime: a registry of agent definitions over one SQLite handle,
|
|
@@ -223,11 +242,14 @@ export class NativeRuntime implements Runtime {
|
|
|
223
242
|
// Retries failed announcement deliveries (#260), across actor rebuilds.
|
|
224
243
|
private readonly announceRetry = new AnnouncementRetry((agent, id) => { void this.session(agent, id).flushAnnouncements(); });
|
|
225
244
|
private readonly maxSessions: number;
|
|
245
|
+
private closing: Promise<void> | undefined; // set by close() (#317)
|
|
246
|
+
readonly files: FileStore | undefined;
|
|
226
247
|
|
|
227
248
|
constructor(agents: Record<string, AgentDef>, db: SyncSqlite, opts: NativeRuntimeOptions = {}) {
|
|
228
249
|
assertCoreRuntimeVersion("NativeRuntime"); // #94: fail power-on, not mid-turn
|
|
229
250
|
this.agents = agents;
|
|
230
251
|
this.db = db;
|
|
252
|
+
this.files = opts.files;
|
|
231
253
|
const max = opts.maxSessions ?? 1000;
|
|
232
254
|
// At least one actor is always retained (the one being handed out), so a cap below 1
|
|
233
255
|
// means nothing, and NaN would compare false and evict every idle actor on each miss.
|
|
@@ -244,6 +266,9 @@ export class NativeRuntime implements Runtime {
|
|
|
244
266
|
session(agent: string, id: string): AgentSession {
|
|
245
267
|
const key = `${agent}:${id}`;
|
|
246
268
|
const hit = this.actors.get(key);
|
|
269
|
+
// A closing runtime still serves the actors it has (their turns are finishing), but
|
|
270
|
+
// builds no new one.
|
|
271
|
+
if (!hit && this.closing) throw new Error(`NativeRuntime is closed: cannot open session "${key}"`);
|
|
247
272
|
if (hit) {
|
|
248
273
|
// most recently used → the tail (Map iteration order is insertion order)
|
|
249
274
|
this.actors.delete(key);
|
|
@@ -254,7 +279,9 @@ export class NativeRuntime implements Runtime {
|
|
|
254
279
|
if (!def) throw new Error(`unknown agent: ${agent}`);
|
|
255
280
|
const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
|
|
256
281
|
const sink = new InProcEventSink();
|
|
257
|
-
const
|
|
282
|
+
const built = new AgentSession(agent, id, new SqliteSessionStore(this.db, key), sink, model, def.tools, this, def.channelInstructions);
|
|
283
|
+
built.files = this.files;
|
|
284
|
+
const session = wireAnnouncements(built, def, this.announceRetry);
|
|
258
285
|
this.evictIdle();
|
|
259
286
|
this.actors.set(key, { session, sink });
|
|
260
287
|
return session;
|
|
@@ -276,6 +303,29 @@ export class NativeRuntime implements Runtime {
|
|
|
276
303
|
}
|
|
277
304
|
}
|
|
278
305
|
|
|
306
|
+
// Shut the runtime down (#317). Pending announcement retries are cancelled and no new
|
|
307
|
+
// session opens at once; then it waits until every actor is idle — a running turn or an
|
|
308
|
+
// announcement delivery in flight still writes the store after its hook or model call
|
|
309
|
+
// returns — before dropping the actors and closing the SQLite handle createNativeRuntime
|
|
310
|
+
// opened (a db passed to the constructor stays the caller's). So it resolves only after
|
|
311
|
+
// in-flight work finishes. Call it before discarding a runtime in a process that keeps
|
|
312
|
+
// running (a test suite, a host that swaps runtimes), or a retry fires later against a
|
|
313
|
+
// closed or deleted database. Safe to call twice: both calls resolve together.
|
|
314
|
+
close(): Promise<void> {
|
|
315
|
+
this.closing ??= (async () => {
|
|
316
|
+
this.announceRetry.stop();
|
|
317
|
+
while ([...this.actors.values()].some((a) => !a.session.idle())) {
|
|
318
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
319
|
+
}
|
|
320
|
+
this.actors.clear();
|
|
321
|
+
if (ownedDbs.has(this.db)) {
|
|
322
|
+
ownedDbs.delete(this.db);
|
|
323
|
+
this.db.close();
|
|
324
|
+
}
|
|
325
|
+
})();
|
|
326
|
+
return this.closing;
|
|
327
|
+
}
|
|
328
|
+
|
|
279
329
|
// Drop least recently used idle actors until one more fits under the cap. Runs before
|
|
280
330
|
// the new actor is inserted, so the actor being handed out is never a candidate.
|
|
281
331
|
private evictIdle() {
|
|
@@ -295,9 +345,24 @@ export async function createNativeRuntime(
|
|
|
295
345
|
path = ":memory:",
|
|
296
346
|
opts: NativeRuntimeOptions = {},
|
|
297
347
|
): Promise<NativeRuntime> {
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
348
|
+
const db = await openLocalSqliteSync(path);
|
|
349
|
+
ownedDbs.add(db);
|
|
350
|
+
let runtime: NativeRuntime | undefined;
|
|
351
|
+
try {
|
|
352
|
+
runtime = new NativeRuntime(agents, db, opts);
|
|
353
|
+
runtime.recoverAnnouncements(); // #260
|
|
354
|
+
return runtime;
|
|
355
|
+
} catch (err) {
|
|
356
|
+
// Nobody else holds this handle (a bad maxSessions, unreadable leftovers). A recovery
|
|
357
|
+
// that failed part-way may already have deliveries in flight, so go through close(),
|
|
358
|
+
// which waits for them before closing the db.
|
|
359
|
+
if (runtime) await runtime.close();
|
|
360
|
+
else {
|
|
361
|
+
ownedDbs.delete(db);
|
|
362
|
+
db.close();
|
|
363
|
+
}
|
|
364
|
+
throw err;
|
|
365
|
+
}
|
|
301
366
|
}
|
|
302
367
|
|
|
303
368
|
// ── memory backend — in-process, ephemeral (no DB, no disk) ───────────────────
|
|
@@ -340,26 +405,42 @@ export class MemoryRuntime implements Runtime {
|
|
|
340
405
|
private stores = new Map<string, MemorySessionStore>();
|
|
341
406
|
private readonly agents: Record<string, AgentDef>;
|
|
342
407
|
private readonly announceRetry = new AnnouncementRetry((agent, id) => { void this.session(agent, id).flushAnnouncements(); }); // #260
|
|
343
|
-
|
|
408
|
+
private closed = false; // set by close() (#317)
|
|
409
|
+
readonly files: FileStore | undefined;
|
|
410
|
+
constructor(agents: Record<string, AgentDef>, opts: { files?: FileStore } = {}) {
|
|
344
411
|
assertCoreRuntimeVersion("MemoryRuntime"); // #94: fail power-on, not mid-turn
|
|
345
412
|
this.agents = agents;
|
|
413
|
+
this.files = opts.files;
|
|
346
414
|
}
|
|
347
415
|
agentDef(name: string): AgentDef | undefined { return this.agents[name]; }
|
|
348
416
|
session(agent: string, id: string): AgentSession {
|
|
349
417
|
const key = `${agent}:${id}`;
|
|
350
418
|
let a = this.actors.get(key);
|
|
351
419
|
if (!a) {
|
|
420
|
+
if (this.closed) throw new Error(`MemoryRuntime is closed: cannot open session "${key}"`);
|
|
352
421
|
const def = this.agents[agent];
|
|
353
422
|
if (!def) throw new Error(`unknown agent: ${agent}`);
|
|
354
423
|
const store = new MemorySessionStore();
|
|
355
424
|
this.stores.set(key, store);
|
|
356
425
|
// Same def handling as NativeRuntime: switching backend must not change behavior.
|
|
357
426
|
const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
|
|
358
|
-
|
|
427
|
+
const built = new AgentSession(agent, id, store, new InProcEventSink(), model, def.tools, this, def.channelInstructions);
|
|
428
|
+
built.files = this.files;
|
|
429
|
+
a = wireAnnouncements(built, def, this.announceRetry);
|
|
359
430
|
this.actors.set(key, a);
|
|
360
431
|
}
|
|
361
432
|
return a;
|
|
362
433
|
}
|
|
434
|
+
// Shut the runtime down (#317): cancel pending announcement retries and open no new
|
|
435
|
+
// session. The state is the actors themselves, so they are dropped too; with no store
|
|
436
|
+
// to close there is nothing to wait for — work still in flight finishes against its own
|
|
437
|
+
// in-memory store. Safe to call twice.
|
|
438
|
+
async close(): Promise<void> {
|
|
439
|
+
this.closed = true;
|
|
440
|
+
this.announceRetry.stop();
|
|
441
|
+
this.actors.clear();
|
|
442
|
+
this.stores.clear();
|
|
443
|
+
}
|
|
363
444
|
}
|
|
364
445
|
|
|
365
446
|
// The selectable agent-runtime backends. `native` (SQLite via june/host, durable
|
|
@@ -371,13 +452,16 @@ export type AgentBackend = "native" | "memory" | "durable";
|
|
|
371
452
|
// Build an in-process runtime for the chosen backend. Throws for `durable` (that
|
|
372
453
|
// target is the DO the worker constructs, not an in-process object) — so the
|
|
373
454
|
// choice is explicit and a mis-selection fails loudly.
|
|
455
|
+
// An in-process runtime: a Runtime the host owns, so it can shut it down (#317).
|
|
456
|
+
export type InProcessRuntime = Runtime & { close(): Promise<void>; readonly files?: FileStore };
|
|
457
|
+
|
|
374
458
|
export async function createAgentRuntime(
|
|
375
459
|
agents: Record<string, AgentDef>,
|
|
376
|
-
opts: { backend?: AgentBackend; path?: string; maxSessions?: number } = {},
|
|
377
|
-
): Promise<
|
|
460
|
+
opts: { backend?: AgentBackend; path?: string; maxSessions?: number; files?: FileStore } = {},
|
|
461
|
+
): Promise<InProcessRuntime> {
|
|
378
462
|
const backend = opts.backend ?? "native";
|
|
379
|
-
if (backend === "memory") return new MemoryRuntime(agents);
|
|
380
|
-
if (backend === "native") return createNativeRuntime(agents, opts.path, { maxSessions: opts.maxSessions });
|
|
463
|
+
if (backend === "memory") return new MemoryRuntime(agents, { files: opts.files });
|
|
464
|
+
if (backend === "native") return createNativeRuntime(agents, opts.path, { maxSessions: opts.maxSessions, files: opts.files });
|
|
381
465
|
throw new Error("backend 'durable' is the Cloudflare Durable Object target — construct AgentDurableObject in your worker, not via createAgentRuntime");
|
|
382
466
|
}
|
|
383
467
|
|
|
@@ -414,7 +498,7 @@ function warnOnDrift(agent: AgentDefinition, runtime: Runtime) {
|
|
|
414
498
|
export function mountAgent(
|
|
415
499
|
agent: AgentDefinition,
|
|
416
500
|
runtime: Runtime,
|
|
417
|
-
opts: { chatPath?: string; channels?: boolean; services?: unknown } = {},
|
|
501
|
+
opts: { chatPath?: string; channels?: boolean; services?: unknown; files?: FileStore } = {},
|
|
418
502
|
): {
|
|
419
503
|
surface: (req: Request) => Promise<Response | null>;
|
|
420
504
|
fetch: (req: Request) => Promise<Response | null>;
|
|
@@ -427,6 +511,8 @@ export function mountAgent(
|
|
|
427
511
|
const ctx: ChannelContext = {
|
|
428
512
|
agent,
|
|
429
513
|
services: opts.services, // same DI bag reachable from channel hooks (parity with durableChannelSurface)
|
|
514
|
+
// The runtime's file store, so a channel can deliver the files the turn's tools stored.
|
|
515
|
+
files: opts.files ?? (runtime as { files?: FileStore }).files,
|
|
430
516
|
run: (message, o) =>
|
|
431
517
|
runtime.session(agent.name, o?.session ?? "default").turn({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger, replace: o?.replace }),
|
|
432
518
|
// FIRE-AND-FORGET (#77): start() without awaiting the result. Native has no waitUntil
|
package/src/app.ts
CHANGED
|
@@ -25,14 +25,18 @@ import {
|
|
|
25
25
|
} from "@junejs/core/config";
|
|
26
26
|
import type { DocumentConfig } from "@junejs/core/document";
|
|
27
27
|
import { runWithTrace, type RequestTrace } from "@junejs/core/instrumentation";
|
|
28
|
+
import { blobFileStore, memoryFileStore } from "@junejs/core/agent-files";
|
|
29
|
+
import type { JuneBlob } from "@junejs/core/resources";
|
|
28
30
|
|
|
29
31
|
import { findMiddlewareFile, isResourceFile, listRoutes, matchRouteTree, resolveNotFound, routeFiles, type SegmentMatch } from "./router";
|
|
32
|
+
import { routeProblems, scanRoutes } from "./route-scan";
|
|
30
33
|
import { createPipeline, type ExtraHandler, type LayoutComponent, type MiddlewareHandler, type Pipeline, type Resolved, type ResourceHandler } from "./pipeline";
|
|
31
34
|
import { discoverAgent } from "./agent-discover";
|
|
32
|
-
import { createAgentRuntime, mountAgent, toAgentDef } from "./agent-native";
|
|
35
|
+
import { createAgentRuntime, mountAgent, toAgentDef, type InProcessRuntime } from "./agent-native";
|
|
33
36
|
import { anthropic } from "@junejs/core/agent-models";
|
|
34
37
|
import { resolveBoundary } from "./segment";
|
|
35
38
|
import { memoizeResources } from "./resources";
|
|
39
|
+
import { localBlob } from "./blob";
|
|
36
40
|
import {
|
|
37
41
|
findClientEntry,
|
|
38
42
|
bundleClientSplit,
|
|
@@ -65,6 +69,10 @@ export type JuneApp = {
|
|
|
65
69
|
warmup(): Promise<void>;
|
|
66
70
|
routePaths(): Promise<string[]>;
|
|
67
71
|
earlyHints(): string[];
|
|
72
|
+
// Shut down what the app started (#317): the agent runtime built for an agent/
|
|
73
|
+
// directory — its pending announcement retries, its in-flight turns, its SQLite
|
|
74
|
+
// handle. Resolves once they finish. A no-op when no runtime was built.
|
|
75
|
+
close(): Promise<void>;
|
|
68
76
|
};
|
|
69
77
|
|
|
70
78
|
// Import a layout file's default export as a layout component (memoized), plus
|
|
@@ -226,6 +234,8 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
|
|
|
226
234
|
// pipeline is built lazily on first fetch, memoized after.
|
|
227
235
|
let pipelinePromise: Promise<Pipeline> | undefined;
|
|
228
236
|
const getPipeline = (): Promise<Pipeline> => (pipelinePromise ??= buildPipeline());
|
|
237
|
+
// The agent runtime buildPipeline creates, kept so close() can shut it down (#317).
|
|
238
|
+
let agentRuntime: InProcessRuntime | undefined;
|
|
229
239
|
async function buildPipeline(): Promise<Pipeline> {
|
|
230
240
|
// Wire CSS Modules BEFORE any route import: the runtime interceptor must be
|
|
231
241
|
// active so `import "x.module.css"` in a route resolves to the scoped map,
|
|
@@ -263,7 +273,16 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
|
|
|
263
273
|
const model = anthropic({ model: def.model });
|
|
264
274
|
// toAgentDef: tools, prompt AND the per-surface policies (#149) from the one
|
|
265
275
|
// definition — channelInstructions was previously dropped on this path (#173).
|
|
266
|
-
|
|
276
|
+
// Agent files (docs/rfc-agent-files.md) live in the app's blob resource; an app that
|
|
277
|
+
// declares none gets the local default directory. The memory backend keeps them in
|
|
278
|
+
// memory, like everything else it holds.
|
|
279
|
+
let blobOpened: Promise<JuneBlob> | undefined;
|
|
280
|
+
const files =
|
|
281
|
+
backend === "memory"
|
|
282
|
+
? memoryFileStore()
|
|
283
|
+
: blobFileStore(() => (blobOpened ??= resources().then(async (r) => r.blob ?? (await localBlob().open()))));
|
|
284
|
+
const rt = await createAgentRuntime({ [def.name]: toAgentDef(def, model) }, { backend, files });
|
|
285
|
+
agentRuntime = rt;
|
|
267
286
|
const mounted = mountAgent(def, rt, { chatPath: agent.runtime.chat.path, channels: agent.runtime.channels });
|
|
268
287
|
agentSurface = (req) => mounted.surface(req);
|
|
269
288
|
await mounted.startAll();
|
|
@@ -443,6 +462,13 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
|
|
|
443
462
|
return toPipeline();
|
|
444
463
|
},
|
|
445
464
|
async warmup() {
|
|
465
|
+
// What `june build` refuses (same-path files, a segment after an optional
|
|
466
|
+
// or catch-all), dev reports up front — it still serves what it can.
|
|
467
|
+
const problems = routeProblems(
|
|
468
|
+
[await scanRoutes(appDir), hasJuneRoutes ? await scanRoutes(juneRoutesDir) : []],
|
|
469
|
+
dirname(appDir),
|
|
470
|
+
);
|
|
471
|
+
if (problems) console.error(problems);
|
|
446
472
|
const files = await routeFiles(appDir, { pageConvention: true });
|
|
447
473
|
if (hasJuneRoutes) files.push(...(await routeFiles(juneRoutesDir, { pageConvention: true })));
|
|
448
474
|
for (const file of files) {
|
|
@@ -453,5 +479,11 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
|
|
|
453
479
|
},
|
|
454
480
|
routePaths,
|
|
455
481
|
earlyHints: () => config.earlyHints ?? [],
|
|
482
|
+
async close() {
|
|
483
|
+
// A pipeline still being built may be about to create the runtime: let it finish
|
|
484
|
+
// (its failure is the fetch's to report, not close()'s).
|
|
485
|
+
await pipelinePromise?.catch(() => {});
|
|
486
|
+
await agentRuntime?.close();
|
|
487
|
+
},
|
|
456
488
|
};
|
|
457
489
|
}
|
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
|
|
@@ -435,7 +441,7 @@ ${statics.join("\n")}
|
|
|
435
441
|
},
|
|
436
442
|
dynamicRoutes: [
|
|
437
443
|
${dynamics.join("\n")}
|
|
438
|
-
],${resourceRoutesField}
|
|
444
|
+
],${resourceRoutesField}${generatedRoutesField}
|
|
439
445
|
layoutChains: {
|
|
440
446
|
${chains.join("\n")}
|
|
441
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 = 7;
|
|
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
|