@junejs/server 1.0.0-dev.42 → 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/router.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { compareSegments, parseSegment } from "./route-rank.js";
1
2
  import { existsSync } from "node:fs";
2
3
  import { readdir } from "node:fs/promises";
3
4
  import { join, relative, sep } from "node:path";
@@ -42,11 +43,11 @@ function isSpecialFile(file) {
42
43
  return SPECIAL_FILES.has(baseName(file));
43
44
  }
44
45
  const isRouteGroup = (name) => /^\(.+\)$/.test(name);
45
- const isParamDir = (name) => /^\[([A-Za-z_][A-Za-z0-9_]*)\]$/.test(name);
46
- const isOptionalDir = (name) => /^\[\[([A-Za-z_][A-Za-z0-9_]*)\]\]$/.test(name);
47
- const isOptionalCatchAllDir = (name) => /^\[\[\.\.\.([A-Za-z_][A-Za-z0-9_]*)\]\]$/.test(name);
48
- const isCatchAllDir = (name) => /^\[\.\.\.([A-Za-z_][A-Za-z0-9_]*)\]$/.test(name);
49
- const paramName = (name) => name.replace(/^\[+(\.\.\.)?|\]+$/g, "");
46
+ const isParamDir = (name) => parseSegment(name).kind === "param";
47
+ const isOptionalDir = (name) => parseSegment(name).kind === "optional";
48
+ const isOptionalCatchAllDir = (name) => parseSegment(name).kind === "optionalCatchAll";
49
+ const isCatchAllDir = (name) => parseSegment(name).kind === "catchAll";
50
+ const paramName = (name) => parseSegment(name).name;
50
51
  async function listDir(dir) {
51
52
  return (await readdir(dir, { withFileTypes: true }).catch(() => [])).filter((e) => !e.name.startsWith("_") && !e.name.startsWith(".")).map((e) => ({
52
53
  name: e.name,
@@ -84,63 +85,76 @@ function routePath(appDir, file) {
84
85
  }
85
86
  async function matchRouteTree(appDir, pathname, options = {}) {
86
87
  const urlSegments = pathname.split("/").filter(Boolean).map(decodeURIComponent);
87
- async function descend(dir, rest, params, chain) {
88
- const entries = await listDir(dir);
88
+ async function levelAt(dir, chain) {
89
+ const entries = (await listDir(dir)).sort((a, b) => compareSegments(a.name, b.name));
89
90
  const segments = [...chain, segmentAt(dir, entries)];
90
- if (rest.length === 0) {
91
- const page = fileFor(entries, dir, "page") ?? fileFor(entries, dir, "index") ?? fileFor(entries, dir, "route");
92
- if (page) return {
93
- file: page,
91
+ const levels = [{
92
+ dir,
93
+ entries,
94
+ segments
95
+ }];
96
+ for (const e of entries) if (e.dir && isRouteGroup(e.name)) levels.push(...await levelAt(join(dir, e.name), segments));
97
+ return levels;
98
+ }
99
+ async function descend(nodes, rest, params) {
100
+ const levels = (await Promise.all(nodes.map((n) => levelAt(n.dir, n.chain)))).flat();
101
+ if (rest.length === 0) for (const base of [["page", "index"], ["route"]]) for (const { dir: d, entries, segments } of levels) {
102
+ const file = base.map((b) => fileFor(entries, d, b)).find(Boolean);
103
+ if (file) return {
104
+ file,
94
105
  params,
95
106
  segments
96
107
  };
97
- } else if (!options.pageConvention) {
98
- if (rest.length === 1) {
99
- const leaf = fileFor(entries, dir, rest[0]);
100
- if (leaf && !isSpecialFile(leaf) && !isPageFile(leaf)) return {
101
- file: leaf,
102
- params,
103
- segments
104
- };
105
- }
106
108
  }
107
- for (const e of entries) {
108
- if (!e.dir || !isRouteGroup(e.name)) continue;
109
- const hit = await descend(join(dir, e.name), rest, params, segments);
110
- if (hit) return hit;
109
+ else if (!options.pageConvention && rest.length === 1) for (const { dir: d, entries, segments } of levels) {
110
+ const leaf = fileFor(entries, d, rest[0]);
111
+ if (leaf && !isSpecialFile(leaf) && !isPageFile(leaf)) return {
112
+ file: leaf,
113
+ params,
114
+ segments
115
+ };
116
+ }
117
+ const children = /* @__PURE__ */ new Map();
118
+ for (const lv of levels) for (const e of lv.entries) {
119
+ if (!e.dir || isRouteGroup(e.name)) continue;
120
+ const node = {
121
+ dir: join(lv.dir, e.name),
122
+ chain: lv.segments
123
+ };
124
+ const same = children.get(e.name);
125
+ if (same) same.push(node);
126
+ else children.set(e.name, [node]);
111
127
  }
128
+ const names = [...children.keys()].sort(compareSegments);
112
129
  if (rest.length === 0) {
113
- for (const e of entries) {
114
- if (!e.dir || !(isOptionalDir(e.name) || isOptionalCatchAllDir(e.name))) continue;
115
- const hit = await descend(join(dir, e.name), [], params, segments);
130
+ for (const name of names) {
131
+ if (!(isOptionalDir(name) || isOptionalCatchAllDir(name))) continue;
132
+ const hit = await descend(children.get(name), [], params);
116
133
  if (hit) return hit;
117
134
  }
118
135
  return null;
119
136
  }
120
137
  const [head, ...tail] = rest;
121
- if (entries.find((e) => e.dir && e.name === head)) {
122
- const hit = await descend(join(dir, head), tail, params, segments);
123
- if (hit) return hit;
124
- }
125
- for (const e of entries) {
126
- if (!e.dir || !(isParamDir(e.name) || isOptionalDir(e.name))) continue;
127
- const hit = await descend(join(dir, e.name), tail, {
138
+ for (const name of names) {
139
+ const into = children.get(name);
140
+ let hit = null;
141
+ if (isParamDir(name) || isOptionalDir(name)) hit = await descend(into, tail, {
128
142
  ...params,
129
- [paramName(e.name)]: head
130
- }, segments);
131
- if (hit) return hit;
132
- }
133
- for (const e of entries) {
134
- if (!e.dir || !(isCatchAllDir(e.name) || isOptionalCatchAllDir(e.name))) continue;
135
- const hit = await descend(join(dir, e.name), [], {
143
+ [paramName(name)]: head
144
+ });
145
+ else if (isCatchAllDir(name) || isOptionalCatchAllDir(name)) hit = await descend(into, [], {
136
146
  ...params,
137
- [paramName(e.name)]: rest.join("/")
138
- }, segments);
147
+ [paramName(name)]: rest.join("/")
148
+ });
149
+ else if (name === head) hit = await descend(into, tail, params);
139
150
  if (hit) return hit;
140
151
  }
141
152
  return null;
142
153
  }
143
- return descend(appDir, urlSegments, {}, []);
154
+ return descend([{
155
+ dir: appDir,
156
+ chain: []
157
+ }], urlSegments, {});
144
158
  }
145
159
  async function resolveNotFound(appDir, pathname) {
146
160
  const urlSegments = pathname.split("/").filter(Boolean).map(decodeURIComponent);
@@ -20,6 +20,7 @@ type WorkerManifest = {
20
20
  pattern: string;
21
21
  handler: ResourceHandler;
22
22
  }>;
23
+ generatedRoutes?: string[];
23
24
  layoutChains?: Record<string, LayoutComponent[]>;
24
25
  layoutBoundaries?: Record<string, {
25
26
  index: number;
package/dist/worker.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import { a as withDenoAssets, i as withAssets, n as WorkerManifest, r as createWorker, t as WorkerExecutionContext } from "./worker-Dv4l6Ybs.js";
1
+ import { a as withDenoAssets, i as withAssets, n as WorkerManifest, r as createWorker, t as WorkerExecutionContext } from "./worker-2qbQjj2c.js";
2
2
  export { WorkerExecutionContext, WorkerManifest, createWorker, withAssets, withDenoAssets };
package/dist/worker.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { durableAgentSurface, durableChannelSurface } from "./agent-durable.js";
2
+ import { compareRoutePatterns, parseSegment } from "./route-rank.js";
2
3
  import { acceptTarget } from "./negotiate.js";
3
4
  import { createPipeline } from "./pipeline.js";
4
5
  import { RESERVED_PREFIX, contentTypeFor, safeRelativePath } from "./static-files.js";
@@ -8,34 +9,16 @@ function compilePattern(pattern) {
8
9
  let source = "";
9
10
  let allOptional = true;
10
11
  for (const seg of pattern.split("/").filter(Boolean)) {
11
- const oca = seg.match(/^\[\[\.\.\.(\w+)\]\]$/);
12
- if (oca) {
13
- names.push(oca[1]);
14
- source += "(?:/(.+))?";
15
- continue;
16
- }
17
- const ca = seg.match(/^\[\.\.\.(\w+)\]$/);
18
- if (ca) {
19
- names.push(ca[1]);
20
- source += "/(.+)";
21
- allOptional = false;
22
- continue;
23
- }
24
- const op = seg.match(/^\[\[(\w+)\]\]$/);
25
- if (op) {
26
- names.push(op[1]);
27
- source += "(?:/([^/]+))?";
28
- continue;
29
- }
30
- const p = seg.match(/^\[(\w+)\]$/);
31
- if (p) {
32
- names.push(p[1]);
33
- source += "/([^/]+)";
12
+ const { kind, name } = parseSegment(seg);
13
+ if (name) names.push(name);
14
+ if (kind === "optionalCatchAll") source += "(?:/(.+))?";
15
+ else if (kind === "optional") source += "(?:/([^/]+))?";
16
+ else {
17
+ if (kind === "catchAll") source += "/(.+)";
18
+ else if (kind === "param") source += "/([^/]+)";
19
+ else source += "/" + seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
34
20
  allOptional = false;
35
- continue;
36
21
  }
37
- source += "/" + seg.replace(/[.*+?^${}()|\\]/g, "\\$&");
38
- allOptional = false;
39
22
  }
40
23
  if (source === "") return {
41
24
  regex: /^\/$/,
@@ -47,20 +30,42 @@ function compilePattern(pattern) {
47
30
  };
48
31
  }
49
32
  function createWorker(manifest, opts = {}) {
50
- const dynamic = (manifest.dynamicRoutes ?? []).map((d) => ({
51
- ...compilePattern(d.pattern),
52
- def: d.def,
53
- pattern: d.pattern
54
- }));
55
- const resources = (manifest.resourceRoutes ?? []).map((r) => ({
56
- ...compilePattern(r.pattern),
57
- handler: r.handler,
58
- pattern: r.pattern
59
- }));
33
+ const generated = new Set(manifest.generatedRoutes ?? []);
34
+ const row = (pattern) => ({
35
+ ...compilePattern(pattern),
36
+ pattern,
37
+ generated: generated.has(pattern)
38
+ });
39
+ const table = [
40
+ ...Object.entries(manifest.routes).map(([pattern, def]) => ({
41
+ ...row(pattern),
42
+ def
43
+ })),
44
+ ...(manifest.dynamicRoutes ?? []).map((d) => ({
45
+ ...row(d.pattern),
46
+ def: d.def
47
+ })),
48
+ ...(manifest.resourceRoutes ?? []).map((r) => ({
49
+ ...row(r.pattern),
50
+ handler: r.handler
51
+ }))
52
+ ];
53
+ table.sort((a, b) => Number(a.generated) - Number(b.generated) || compareRoutePatterns(a.pattern, b.pattern) || Number(!!a.handler) - Number(!!b.handler));
60
54
  const routeList = [...Object.keys(manifest.routes), ...(manifest.dynamicRoutes ?? []).map((d) => d.pattern)].sort();
61
55
  const chainFor = (key) => manifest.layoutChains?.[key] ?? [];
62
56
  const loadingFor = (key) => manifest.loadings?.[key];
63
57
  const boundaryFor = (key) => manifest.layoutBoundaries?.[key] ?? null;
58
+ const resolvedPage = (def, key, params) => {
59
+ const b = boundaryFor(key);
60
+ return {
61
+ def,
62
+ params,
63
+ chain: chainFor(key),
64
+ boundaryIndex: b?.index ?? null,
65
+ boundaryKey: b?.key ?? null,
66
+ loading: loadingFor(key)
67
+ };
68
+ };
64
69
  let currentEnv;
65
70
  const requestLocals = /* @__PURE__ */ new WeakMap();
66
71
  const provider = manifest.resources;
@@ -102,47 +107,18 @@ function createWorker(manifest, opts = {}) {
102
107
  services: servicesProvider ? () => (servicesCache ??= { v: servicesProvider(currentEnv) }).v : void 0,
103
108
  resolve: async (pathname) => {
104
109
  const staticDef = manifest.routes[pathname];
105
- if (staticDef) {
106
- const b = boundaryFor(pathname);
107
- return {
108
- def: staticDef,
109
- params: {},
110
- chain: chainFor(pathname),
111
- boundaryIndex: b?.index ?? null,
112
- boundaryKey: b?.key ?? null,
113
- loading: loadingFor(pathname)
114
- };
115
- }
116
- for (const d of dynamic) {
117
- const m = pathname.match(d.regex);
118
- if (m) {
119
- const params = Object.fromEntries(d.names.flatMap((n, i) => {
120
- const v = m[i + 1];
121
- return v === void 0 ? [] : [[n, decodeURIComponent(v)]];
122
- }));
123
- const b = boundaryFor(d.pattern);
124
- return {
125
- def: d.def,
126
- params,
127
- chain: chainFor(d.pattern),
128
- boundaryIndex: b?.index ?? null,
129
- boundaryKey: b?.key ?? null,
130
- loading: loadingFor(d.pattern)
131
- };
132
- }
133
- }
134
- for (const r of resources) {
110
+ if (staticDef && !generated.has(pathname)) return resolvedPage(staticDef, pathname, {});
111
+ for (const r of table) {
135
112
  const m = pathname.match(r.regex);
136
- if (m) {
137
- const params = Object.fromEntries(r.names.flatMap((n, i) => {
138
- const v = m[i + 1];
139
- return v === void 0 ? [] : [[n, decodeURIComponent(v)]];
140
- }));
141
- return {
142
- handler: r.handler,
143
- params
144
- };
145
- }
113
+ if (!m) continue;
114
+ const params = Object.fromEntries(r.names.flatMap((n, i) => {
115
+ const v = m[i + 1];
116
+ return v === void 0 ? [] : [[n, decodeURIComponent(v)]];
117
+ }));
118
+ return r.handler ? {
119
+ handler: r.handler,
120
+ params
121
+ } : resolvedPage(r.def, r.pattern, params);
146
122
  }
147
123
  return null;
148
124
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junejs/server",
3
- "version": "1.0.0-dev.42",
3
+ "version": "1.0.0-dev.45",
4
4
  "description": "June host adapters + dev server. Host-coupled; depends on the pure @junejs/core contract layer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -136,11 +136,11 @@
136
136
  "@momiji-rs/sparkdown": "^0.0.4",
137
137
  "@resvg/resvg-wasm": "^2.6.2",
138
138
  "lightningcss": "^1.27.0",
139
- "oxc-parser": "^0.151.0",
139
+ "oxc-parser": "^0.152.0",
140
140
  "rolldown": "^1.1.0"
141
141
  },
142
142
  "peerDependencies": {
143
- "@junejs/core": ">=0.2.0-dev.63",
143
+ "@junejs/core": ">=0.2.0-dev.65",
144
144
  "react": "^19.2.0",
145
145
  "react-dom": "^19.2.0",
146
146
  "react-server-dom-webpack": "19.3.0",
@@ -141,6 +141,7 @@ class AnnouncementRetry {
141
141
  // Explicit fields (not parameter properties) — keep the shipped source erasable.
142
142
  private readonly flush: (agent: string, id: string) => void;
143
143
  private readonly state = new Map<string, { failures: number; timer?: ReturnType<typeof setTimeout> }>();
144
+ private stopped = false;
144
145
  constructor(flush: (agent: string, id: string) => void) {
145
146
  this.flush = flush;
146
147
  }
@@ -150,6 +151,7 @@ class AnnouncementRetry {
150
151
  this.state.delete(key);
151
152
  }
152
153
  failed(key: string, agent: string, id: string) {
154
+ if (this.stopped) return; // the runtime is closed: nothing retries into it
153
155
  const s = this.state.get(key) ?? { failures: 0 };
154
156
  this.state.set(key, s);
155
157
  if (s.timer) return; // one retry pending at a time
@@ -158,8 +160,20 @@ class AnnouncementRetry {
158
160
  s.timer = setTimeout(() => { s.timer = undefined; this.flush(agent, id); }, delay);
159
161
  (s.timer as { unref?: () => void }).unref?.();
160
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
+ }
161
171
  }
162
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
+
163
177
  // Install the announcement hook on a freshly built session and deliver what an earlier life
164
178
  // of it recorded but never delivered (#260). The flush never rejects; it logs — and a
165
179
  // failure schedules a retry.
@@ -223,6 +237,7 @@ export class NativeRuntime implements Runtime {
223
237
  // Retries failed announcement deliveries (#260), across actor rebuilds.
224
238
  private readonly announceRetry = new AnnouncementRetry((agent, id) => { void this.session(agent, id).flushAnnouncements(); });
225
239
  private readonly maxSessions: number;
240
+ private closing: Promise<void> | undefined; // set by close() (#317)
226
241
 
227
242
  constructor(agents: Record<string, AgentDef>, db: SyncSqlite, opts: NativeRuntimeOptions = {}) {
228
243
  assertCoreRuntimeVersion("NativeRuntime"); // #94: fail power-on, not mid-turn
@@ -244,6 +259,9 @@ export class NativeRuntime implements Runtime {
244
259
  session(agent: string, id: string): AgentSession {
245
260
  const key = `${agent}:${id}`;
246
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}"`);
247
265
  if (hit) {
248
266
  // most recently used → the tail (Map iteration order is insertion order)
249
267
  this.actors.delete(key);
@@ -276,6 +294,29 @@ export class NativeRuntime implements Runtime {
276
294
  }
277
295
  }
278
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
+
279
320
  // Drop least recently used idle actors until one more fits under the cap. Runs before
280
321
  // the new actor is inserted, so the actor being handed out is never a candidate.
281
322
  private evictIdle() {
@@ -295,9 +336,24 @@ export async function createNativeRuntime(
295
336
  path = ":memory:",
296
337
  opts: NativeRuntimeOptions = {},
297
338
  ): Promise<NativeRuntime> {
298
- const runtime = new NativeRuntime(agents, await openLocalSqliteSync(path), opts);
299
- runtime.recoverAnnouncements(); // #260
300
- return runtime;
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
+ }
301
357
  }
302
358
 
303
359
  // ── memory backend — in-process, ephemeral (no DB, no disk) ───────────────────
@@ -340,6 +396,7 @@ export class MemoryRuntime implements Runtime {
340
396
  private stores = new Map<string, MemorySessionStore>();
341
397
  private readonly agents: Record<string, AgentDef>;
342
398
  private readonly announceRetry = new AnnouncementRetry((agent, id) => { void this.session(agent, id).flushAnnouncements(); }); // #260
399
+ private closed = false; // set by close() (#317)
343
400
  constructor(agents: Record<string, AgentDef>) {
344
401
  assertCoreRuntimeVersion("MemoryRuntime"); // #94: fail power-on, not mid-turn
345
402
  this.agents = agents;
@@ -349,6 +406,7 @@ export class MemoryRuntime implements Runtime {
349
406
  const key = `${agent}:${id}`;
350
407
  let a = this.actors.get(key);
351
408
  if (!a) {
409
+ if (this.closed) throw new Error(`MemoryRuntime is closed: cannot open session "${key}"`);
352
410
  const def = this.agents[agent];
353
411
  if (!def) throw new Error(`unknown agent: ${agent}`);
354
412
  const store = new MemorySessionStore();
@@ -360,6 +418,16 @@ export class MemoryRuntime implements Runtime {
360
418
  }
361
419
  return a;
362
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
+ }
363
431
  }
364
432
 
365
433
  // The selectable agent-runtime backends. `native` (SQLite via june/host, durable
@@ -371,10 +439,13 @@ export type AgentBackend = "native" | "memory" | "durable";
371
439
  // Build an in-process runtime for the chosen backend. Throws for `durable` (that
372
440
  // target is the DO the worker constructs, not an in-process object) — so the
373
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
+
374
445
  export async function createAgentRuntime(
375
446
  agents: Record<string, AgentDef>,
376
447
  opts: { backend?: AgentBackend; path?: string; maxSessions?: number } = {},
377
- ): Promise<Runtime> {
448
+ ): Promise<InProcessRuntime> {
378
449
  const backend = opts.backend ?? "native";
379
450
  if (backend === "memory") return new MemoryRuntime(agents);
380
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
@@ -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}