@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/agent-native.d.ts +8 -1
- package/dist/agent-native.js +45 -3
- package/dist/app.d.ts +1 -0
- package/dist/app.js +10 -1
- 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/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-native.ts +75 -4
- package/src/app.ts +22 -1
- package/src/build.ts +7 -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/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) =>
|
|
46
|
-
const isOptionalDir = (name) =>
|
|
47
|
-
const isOptionalCatchAllDir = (name) =>
|
|
48
|
-
const isCatchAllDir = (name) =>
|
|
49
|
-
const paramName = (name) => name.
|
|
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
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
114
|
-
if (!
|
|
115
|
-
const hit = await descend(
|
|
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
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
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(
|
|
130
|
-
}
|
|
131
|
-
if (
|
|
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(
|
|
138
|
-
}
|
|
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(
|
|
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);
|
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-
|
|
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
|
|
12
|
-
if (
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
pattern
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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.
|
|
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.
|
|
139
|
+
"oxc-parser": "^0.152.0",
|
|
140
140
|
"rolldown": "^1.1.0"
|
|
141
141
|
},
|
|
142
142
|
"peerDependencies": {
|
|
143
|
-
"@junejs/core": ">=0.2.0-dev.
|
|
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",
|
package/src/agent-native.ts
CHANGED
|
@@ -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
|
|
299
|
-
|
|
300
|
-
|
|
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<
|
|
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}
|