@ultimat3/cli 22.12.0 → 22.14.0
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/package.json +28 -28
- package/src/app-mcp.ts +129 -69
- package/src/budgets.ts +35 -2
- package/src/cmd-dev.ts +8 -3
- package/src/dev-child-watch.ts +144 -0
- package/src/dev-live-fixture.ts +122 -0
- package/src/dev-route-table.ts +4 -2
- package/src/dev-supervisor.ts +32 -3
- package/src/error-codes.ts +2 -0
- package/src/index.ts +1 -1
- package/src/island-bundle.ts +234 -170
- package/src/island-identity.ts +158 -0
- package/src/island-link.ts +138 -0
- package/src/island-realtime.ts +28 -11
- package/src/island-routes.ts +3 -1
- package/src/island-store.ts +65 -8
- package/src/mcp-errors.ts +1 -0
- package/src/prerender.ts +6 -1
- package/src/sw-precache-plan.ts +6 -0
- package/src/worker-bundle.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "22.
|
|
3
|
+
"version": "22.14.0",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -38,33 +38,33 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@babel/core": "^8.0.1",
|
|
41
|
-
"@ultimat3/action": "22.
|
|
42
|
-
"@ultimat3/admin": "22.
|
|
43
|
-
"@ultimat3/ai": "22.
|
|
44
|
-
"@ultimat3/auth": "22.
|
|
45
|
-
"@ultimat3/cache": "22.
|
|
46
|
-
"@ultimat3/core": "22.
|
|
47
|
-
"@ultimat3/db": "22.
|
|
48
|
-
"@ultimat3/entity": "22.
|
|
49
|
-
"@ultimat3/flags": "22.
|
|
50
|
-
"@ultimat3/http": "22.
|
|
51
|
-
"@ultimat3/i18n": "22.
|
|
52
|
-
"@ultimat3/jobs": "22.
|
|
53
|
-
"@ultimat3/mail": "22.
|
|
54
|
-
"@ultimat3/manifest": "22.
|
|
55
|
-
"@ultimat3/mcp": "22.
|
|
56
|
-
"@ultimat3/money": "22.
|
|
57
|
-
"@ultimat3/notify": "22.
|
|
58
|
-
"@ultimat3/policy": "22.
|
|
59
|
-
"@ultimat3/pwa": "22.
|
|
60
|
-
"@ultimat3/query": "22.
|
|
61
|
-
"@ultimat3/realtime": "22.
|
|
62
|
-
"@ultimat3/render": "22.
|
|
63
|
-
"@ultimat3/schema": "22.
|
|
64
|
-
"@ultimat3/seo": "22.
|
|
65
|
-
"@ultimat3/storage": "22.
|
|
66
|
-
"@ultimat3/testing": "22.
|
|
67
|
-
"@ultimat3/time": "22.
|
|
41
|
+
"@ultimat3/action": "22.14.0",
|
|
42
|
+
"@ultimat3/admin": "22.14.0",
|
|
43
|
+
"@ultimat3/ai": "22.14.0",
|
|
44
|
+
"@ultimat3/auth": "22.14.0",
|
|
45
|
+
"@ultimat3/cache": "22.14.0",
|
|
46
|
+
"@ultimat3/core": "22.14.0",
|
|
47
|
+
"@ultimat3/db": "22.14.0",
|
|
48
|
+
"@ultimat3/entity": "22.14.0",
|
|
49
|
+
"@ultimat3/flags": "22.14.0",
|
|
50
|
+
"@ultimat3/http": "22.14.0",
|
|
51
|
+
"@ultimat3/i18n": "22.14.0",
|
|
52
|
+
"@ultimat3/jobs": "22.14.0",
|
|
53
|
+
"@ultimat3/mail": "22.14.0",
|
|
54
|
+
"@ultimat3/manifest": "22.14.0",
|
|
55
|
+
"@ultimat3/mcp": "22.14.0",
|
|
56
|
+
"@ultimat3/money": "22.14.0",
|
|
57
|
+
"@ultimat3/notify": "22.14.0",
|
|
58
|
+
"@ultimat3/policy": "22.14.0",
|
|
59
|
+
"@ultimat3/pwa": "22.14.0",
|
|
60
|
+
"@ultimat3/query": "22.14.0",
|
|
61
|
+
"@ultimat3/realtime": "22.14.0",
|
|
62
|
+
"@ultimat3/render": "22.14.0",
|
|
63
|
+
"@ultimat3/schema": "22.14.0",
|
|
64
|
+
"@ultimat3/seo": "22.14.0",
|
|
65
|
+
"@ultimat3/storage": "22.14.0",
|
|
66
|
+
"@ultimat3/testing": "22.14.0",
|
|
67
|
+
"@ultimat3/time": "22.14.0",
|
|
68
68
|
"babel-preset-solid": "^1.9.15"
|
|
69
69
|
}
|
|
70
70
|
}
|
package/src/app-mcp.ts
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// `ai: { mcp: { expose: true, path: '/mcp' } }` by DEFAULT, and nothing between the two served it:
|
|
4
4
|
// neither `x dev` nor `runRole` mounted the route, so `POST /mcp` answered `X_ROUTE_NOT_FOUND` in
|
|
5
5
|
// every app ever scaffolded (measured 2026-09-05). The contract is one file: `apps/<app>/mcp.ts`
|
|
6
|
-
// exports `mcp
|
|
6
|
+
// exports `mcp` — one `AppMcp`, or an array of them, one per population (customers, staff,
|
|
7
|
+
// affiliates) — this module finds it, and both boots mount what it carries: endpoint #0 at
|
|
8
|
+
// `ai.mcp.path`, every other at its own `defineAppMcp({ path })`.
|
|
7
9
|
|
|
8
10
|
// why: a directory's existence — `Bun.file().exists()` answers for files, and `apps/` is a directory.
|
|
9
11
|
import { existsSync } from 'node:fs';
|
|
@@ -12,23 +14,38 @@ import { join } from 'node:path';
|
|
|
12
14
|
import { logger } from '@ultimat3/core';
|
|
13
15
|
import type { Route } from '@ultimat3/http';
|
|
14
16
|
import { json, selfOrigin } from '@ultimat3/http';
|
|
15
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
type AppMcp,
|
|
19
|
+
McpAppUnmountedError,
|
|
20
|
+
McpPathDuplicateError,
|
|
21
|
+
PROTECTED_RESOURCE_WELL_KNOWN,
|
|
22
|
+
} from '@ultimat3/mcp';
|
|
16
23
|
import { APP_CONFIG_EXPORT } from './app-auth';
|
|
17
24
|
import { APP_CONFIG_FILE } from './app-root';
|
|
18
25
|
|
|
19
26
|
/** The one file an app writes, per app directory. */
|
|
20
27
|
export const APP_MCP_GLOB = 'apps/*/mcp.ts';
|
|
21
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* The export that file makes — an `AppMcp`, the value `defineAppMcp` returns, or a non-empty array
|
|
30
|
+
* of them. ONE export name either way: a second named export per endpoint would be a second way
|
|
31
|
+
* to say the same thing, and the array's order is the one fact the mount needs (#0 is the default).
|
|
32
|
+
*/
|
|
22
33
|
export const APP_MCP_EXPORT = 'mcp';
|
|
23
|
-
/** What the boot line and `/_x` call
|
|
34
|
+
/** What the boot line and `/_x` call endpoint #0's route; endpoint #n is `mcp:<its path>`. */
|
|
24
35
|
export const APP_MCP_ROUTE_NAME = 'mcp';
|
|
25
36
|
|
|
26
37
|
export interface AppMcpMount {
|
|
27
|
-
/** `[]` when `expose` is false, when nothing exports `mcp`, or when
|
|
38
|
+
/** `[]` when `expose` is false, when nothing exports `mcp`, or when no endpoint has a route. */
|
|
28
39
|
readonly routes: readonly Route[];
|
|
29
|
-
/** `POST <path>` when mounted, else `null` — the
|
|
40
|
+
/** Endpoint #0's `POST <path>` when mounted, else `null` — the default, at `ai.mcp.path`. */
|
|
30
41
|
readonly path: string | null;
|
|
31
|
-
/**
|
|
42
|
+
/** Every mounted endpoint's `POST <path>`, in export order — the boot line prints one each. */
|
|
43
|
+
readonly paths: readonly string[];
|
|
44
|
+
/**
|
|
45
|
+
* Set when `expose` is true and an endpoint could not be mounted — nothing exports `mcp`, or the
|
|
46
|
+
* first endpoint built without `resolveToken`: the reason, as an instruction. The endpoints that
|
|
47
|
+
* could be mounted still are.
|
|
48
|
+
*/
|
|
32
49
|
readonly warning: McpAppUnmountedError | undefined;
|
|
33
50
|
}
|
|
34
51
|
|
|
@@ -65,6 +82,13 @@ async function exposeDeclaration(root: string): Promise<ExposeDeclaration | unde
|
|
|
65
82
|
const isAppMcp = (value: unknown): value is AppMcp =>
|
|
66
83
|
isRecord(value) && 'server' in value && 'tools' in value && 'route' in value;
|
|
67
84
|
|
|
85
|
+
/** The export as a list of endpoints, or `undefined` when it is not one (the "missing" case). */
|
|
86
|
+
function endpointsOf(exported: unknown): readonly AppMcp[] | undefined {
|
|
87
|
+
if (isAppMcp(exported)) return [exported];
|
|
88
|
+
if (!Array.isArray(exported) || exported.length === 0) return undefined;
|
|
89
|
+
return exported.every(isAppMcp) ? (exported as readonly AppMcp[]) : undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
68
92
|
/** Every `apps/<app>/mcp.ts`, app-root-relative and sorted, so two apps answer in one order. */
|
|
69
93
|
async function candidates(root: string): Promise<readonly string[]> {
|
|
70
94
|
// A root with no `apps/` is an app with no MCP file, never a boot failure — the scan's ENOENT
|
|
@@ -75,8 +99,10 @@ async function candidates(root: string): Promise<readonly string[]> {
|
|
|
75
99
|
return files.sort();
|
|
76
100
|
}
|
|
77
101
|
|
|
102
|
+
const NOTHING: AppMcpMount = { routes: [], path: null, paths: [], warning: undefined };
|
|
103
|
+
|
|
78
104
|
/**
|
|
79
|
-
* The
|
|
105
|
+
* The routes to mount, or the reason there are none. Pure over the filesystem it is pointed at;
|
|
80
106
|
* `mountAppMcp` below is the one place the warning becomes a log line.
|
|
81
107
|
*
|
|
82
108
|
* `meta.auth: 'public'` and `enforcedBy: 'handler'` — the http pipeline must not pre-judge:
|
|
@@ -87,72 +113,17 @@ async function candidates(root: string): Promise<readonly string[]> {
|
|
|
87
113
|
*/
|
|
88
114
|
export async function appMcpMount(root: string): Promise<AppMcpMount> {
|
|
89
115
|
const declared = await exposeDeclaration(root);
|
|
90
|
-
if (declared === undefined || !declared.expose)
|
|
91
|
-
return { routes: [], path: null, warning: undefined };
|
|
116
|
+
if (declared === undefined || !declared.expose) return NOTHING;
|
|
92
117
|
const files = await candidates(root);
|
|
93
118
|
const fallbackFile = 'apps/web/mcp.ts';
|
|
94
|
-
if (files.length === 0) {
|
|
95
|
-
return {
|
|
96
|
-
routes: [],
|
|
97
|
-
path: null,
|
|
98
|
-
warning: new McpAppUnmountedError({
|
|
99
|
-
reason: 'missing',
|
|
100
|
-
path: declared.path,
|
|
101
|
-
file: fallbackFile,
|
|
102
|
-
}),
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
119
|
for (const file of files) {
|
|
106
120
|
const module = (await import(join(root, file))) as Record<string, unknown>;
|
|
107
|
-
const
|
|
108
|
-
if (
|
|
109
|
-
|
|
110
|
-
if (route === undefined) {
|
|
111
|
-
return {
|
|
112
|
-
routes: [],
|
|
113
|
-
path: null,
|
|
114
|
-
warning: new McpAppUnmountedError({ reason: 'no-route', path: declared.path, file }),
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
// The PUBLIC origin, as the pipeline resolved it (`ctx.https` honours a trusted proxy's
|
|
118
|
-
// `x-forwarded-proto`): behind a TLS-terminating ingress the raw request URL is `http://` on
|
|
119
|
-
// an internal host, and a `resource_metadata` naming that is a URL no client can reach.
|
|
120
|
-
const resource = route.protectedResource;
|
|
121
|
-
return {
|
|
122
|
-
routes: [
|
|
123
|
-
{
|
|
124
|
-
method: 'POST',
|
|
125
|
-
path: declared.path,
|
|
126
|
-
handler: (request, ctx) =>
|
|
127
|
-
route.handle(request.raw, { origin: selfOrigin(ctx.url, ctx.https) }),
|
|
128
|
-
meta: { name: APP_MCP_ROUTE_NAME, auth: 'public', enforcedBy: 'handler' },
|
|
129
|
-
},
|
|
130
|
-
// RFC 9728 protected-resource metadata, when the app declared `oauth`: path-inserted and
|
|
131
|
-
// at the root, so a client probing either finds the authorization server.
|
|
132
|
-
...(resource === undefined
|
|
133
|
-
? []
|
|
134
|
-
: resource.paths.map(
|
|
135
|
-
(path): Route => ({
|
|
136
|
-
method: 'GET',
|
|
137
|
-
path,
|
|
138
|
-
handler: (_request, ctx) => {
|
|
139
|
-
const response = json(resource.document(selfOrigin(ctx.url, ctx.https)));
|
|
140
|
-
// A browser-hosted MCP client reads this cross-origin; it holds nothing secret.
|
|
141
|
-
response.headers.set('access-control-allow-origin', '*');
|
|
142
|
-
response.headers.set('cache-control', 'public, max-age=3600');
|
|
143
|
-
return response;
|
|
144
|
-
},
|
|
145
|
-
meta: { name: `${APP_MCP_ROUTE_NAME}.oauth-protected-resource`, auth: 'public' },
|
|
146
|
-
}),
|
|
147
|
-
)),
|
|
148
|
-
],
|
|
149
|
-
path: declared.path,
|
|
150
|
-
warning: undefined,
|
|
151
|
-
};
|
|
121
|
+
const endpoints = endpointsOf(module[APP_MCP_EXPORT]);
|
|
122
|
+
if (endpoints === undefined) continue;
|
|
123
|
+
return mountEndpoints(endpoints, declared.path, file);
|
|
152
124
|
}
|
|
153
125
|
return {
|
|
154
|
-
|
|
155
|
-
path: null,
|
|
126
|
+
...NOTHING,
|
|
156
127
|
warning: new McpAppUnmountedError({
|
|
157
128
|
reason: 'missing',
|
|
158
129
|
path: declared.path,
|
|
@@ -161,6 +132,95 @@ export async function appMcpMount(root: string): Promise<AppMcpMount> {
|
|
|
161
132
|
};
|
|
162
133
|
}
|
|
163
134
|
|
|
135
|
+
/**
|
|
136
|
+
* One file's endpoints onto one route table. An endpoint built without `resolveToken` has no
|
|
137
|
+
* route: it is skipped and the FIRST such is the warning, so one unfinished population never takes
|
|
138
|
+
* the others down with it. Two endpoints on one route is thrown — see `McpPathDuplicateError`.
|
|
139
|
+
*/
|
|
140
|
+
function mountEndpoints(
|
|
141
|
+
endpoints: readonly AppMcp[],
|
|
142
|
+
defaultPath: string,
|
|
143
|
+
file: string,
|
|
144
|
+
): AppMcpMount {
|
|
145
|
+
const routes: Route[] = [];
|
|
146
|
+
const paths: string[] = [];
|
|
147
|
+
const claimed = new Map<string, number>();
|
|
148
|
+
let warning: McpAppUnmountedError | undefined;
|
|
149
|
+
endpoints.forEach((endpoint, index) => {
|
|
150
|
+
const route = endpoint.route;
|
|
151
|
+
if (route === undefined) {
|
|
152
|
+
warning ??= new McpAppUnmountedError({
|
|
153
|
+
reason: 'no-route',
|
|
154
|
+
path: defaultPath,
|
|
155
|
+
file,
|
|
156
|
+
...(index === 0 ? {} : { endpoint: index }),
|
|
157
|
+
});
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const path = index === 0 ? defaultPath : route.path;
|
|
161
|
+
for (const mounted of endpointRoutes(route, path, index)) {
|
|
162
|
+
const key = `${mounted.method} ${mounted.path}`;
|
|
163
|
+
const first = claimed.get(key);
|
|
164
|
+
if (first !== undefined) {
|
|
165
|
+
throw new McpPathDuplicateError({
|
|
166
|
+
method: mounted.method,
|
|
167
|
+
path: mounted.path,
|
|
168
|
+
endpoints: [first, index],
|
|
169
|
+
file,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
claimed.set(key, index);
|
|
173
|
+
routes.push(mounted);
|
|
174
|
+
}
|
|
175
|
+
paths.push(path);
|
|
176
|
+
});
|
|
177
|
+
const path = endpoints[0]?.route === undefined ? null : defaultPath;
|
|
178
|
+
return { routes, path, paths, warning };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
type Descriptor = NonNullable<AppMcp['route']>;
|
|
182
|
+
|
|
183
|
+
/** One endpoint's `POST` and, when it declared `oauth`, its RFC 9728 metadata `GET`s. */
|
|
184
|
+
function endpointRoutes(route: Descriptor, path: string, index: number): readonly Route[] {
|
|
185
|
+
const name = index === 0 ? APP_MCP_ROUTE_NAME : `${APP_MCP_ROUTE_NAME}:${path}`;
|
|
186
|
+
const resource = route.protectedResource;
|
|
187
|
+
// The ROOT document belongs to endpoint #0 alone: a client that probes the bare well-known URL
|
|
188
|
+
// is asking about the default resource, and a second endpoint answering there would collide.
|
|
189
|
+
// Every endpoint keeps its path-inserted document (RFC 9728 §3.1), which its own 401 names.
|
|
190
|
+
const metadata: readonly Route[] =
|
|
191
|
+
resource === undefined
|
|
192
|
+
? []
|
|
193
|
+
: resource.paths
|
|
194
|
+
.filter((metadataPath) => index === 0 || metadataPath !== PROTECTED_RESOURCE_WELL_KNOWN)
|
|
195
|
+
.map(
|
|
196
|
+
(metadataPath): Route => ({
|
|
197
|
+
method: 'GET',
|
|
198
|
+
path: metadataPath,
|
|
199
|
+
handler: (_request, ctx) => {
|
|
200
|
+
const response = json(resource.document(selfOrigin(ctx.url, ctx.https)));
|
|
201
|
+
// A browser-hosted MCP client reads this cross-origin; it holds nothing secret.
|
|
202
|
+
response.headers.set('access-control-allow-origin', '*');
|
|
203
|
+
response.headers.set('cache-control', 'public, max-age=3600');
|
|
204
|
+
return response;
|
|
205
|
+
},
|
|
206
|
+
meta: { name: `${name}.oauth-protected-resource`, auth: 'public' },
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
return [
|
|
210
|
+
{
|
|
211
|
+
method: 'POST',
|
|
212
|
+
path,
|
|
213
|
+
// The PUBLIC origin, as the pipeline resolved it (`ctx.https` honours a trusted proxy's
|
|
214
|
+
// `x-forwarded-proto`): behind a TLS-terminating ingress the raw request URL is `http://`
|
|
215
|
+
// on an internal host, and a `resource_metadata` naming that is a URL no client can reach.
|
|
216
|
+
handler: (request, ctx) =>
|
|
217
|
+
route.handle(request.raw, { origin: selfOrigin(ctx.url, ctx.https) }),
|
|
218
|
+
meta: { name, auth: 'public', enforcedBy: 'handler' },
|
|
219
|
+
},
|
|
220
|
+
...metadata,
|
|
221
|
+
];
|
|
222
|
+
}
|
|
223
|
+
|
|
164
224
|
/**
|
|
165
225
|
* The boot's call: the routes to spread into the table, with the warning already logged ONCE and
|
|
166
226
|
* the mount announced. Both `x dev` and `runRole` go through here, so a developer's terminal and a
|
|
@@ -171,6 +231,6 @@ export async function mountAppMcp(root: string): Promise<AppMcpMount> {
|
|
|
171
231
|
if (mount.warning !== undefined) {
|
|
172
232
|
logger.warn(`${mount.warning.code}: ${mount.warning.cause} — fix: ${mount.warning.fix}`);
|
|
173
233
|
}
|
|
174
|
-
|
|
234
|
+
for (const path of mount.paths) logger.info('app mcp mounted', { method: 'POST', path });
|
|
175
235
|
return mount;
|
|
176
236
|
}
|
package/src/budgets.ts
CHANGED
|
@@ -60,8 +60,9 @@ export interface BuildStats {
|
|
|
60
60
|
* rule stops being read as a measurement. `.x/` survives across framework upgrades, and
|
|
61
61
|
* `examples/dummy` was charged 250 B for `/x-sw-register.js` by a file written before
|
|
62
62
|
* `FRAMEWORK_SCRIPTS` exempted it. `2`: that exemption and the page-boot decision (ledger #28).
|
|
63
|
+
* `3`: an island entry's imported chunks are charged, each once per document (shared chunks).
|
|
63
64
|
*/
|
|
64
|
-
export const BUILD_STATS_RULES =
|
|
65
|
+
export const BUILD_STATS_RULES = 3;
|
|
65
66
|
|
|
66
67
|
const chainOf = (stats: RouteStats): string =>
|
|
67
68
|
stats.heaviestChain === undefined ? 'unknown import chain' : stats.heaviestChain.join(' -> ');
|
|
@@ -252,6 +253,31 @@ const carriesJson = (attrs: string): boolean => {
|
|
|
252
253
|
*/
|
|
253
254
|
const ENTRY_ATTR = /\sdata-x-entry="(?<url>[^"]*)"/g;
|
|
254
255
|
|
|
256
|
+
/**
|
|
257
|
+
* Static imports and `import()` both. A chunk reached by `import()` was INLINED into its island
|
|
258
|
+
* before islands were split, so charging it keeps a route's number what it was; and it is fetched
|
|
259
|
+
* the moment the island needs it, which is before the page does what it was loaded for.
|
|
260
|
+
*/
|
|
261
|
+
const LOADS: ReadonlySet<string> = new Set(['import-statement', 'dynamic-import']);
|
|
262
|
+
|
|
263
|
+
/** Same-origin paths `code`, served at `url`, makes the browser load. Cross-origin and bare are not this build's. */
|
|
264
|
+
function loadedBy(url: string, code: string): readonly string[] {
|
|
265
|
+
let imports: readonly { readonly path: string; readonly kind: string }[];
|
|
266
|
+
try {
|
|
267
|
+
imports = new Bun.Transpiler({ loader: 'js' }).scanImports(code);
|
|
268
|
+
} catch {
|
|
269
|
+
// A file that does not parse as JavaScript imports nothing this gate can name; its own bytes
|
|
270
|
+
// are already charged.
|
|
271
|
+
return [];
|
|
272
|
+
}
|
|
273
|
+
const base = new URL(url, 'https://artifact.invalid');
|
|
274
|
+
return imports
|
|
275
|
+
.filter((one) => LOADS.has(one.kind) && /^\.{0,2}\//.test(one.path))
|
|
276
|
+
.map((one) => new URL(one.path, base))
|
|
277
|
+
.filter((target) => target.origin === base.origin)
|
|
278
|
+
.map((target) => target.pathname);
|
|
279
|
+
}
|
|
280
|
+
|
|
255
281
|
/** One executable module the document names, and what it weighs on disk. */
|
|
256
282
|
export interface MeasuredEntry {
|
|
257
283
|
readonly url: string;
|
|
@@ -337,7 +363,8 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
|
|
|
337
363
|
if (!url.startsWith('/') || fetched.has(url)) return;
|
|
338
364
|
fetched.add(url);
|
|
339
365
|
const file = Bun.file(join(out, url.slice(1)));
|
|
340
|
-
const
|
|
366
|
+
const exists = await file.exists();
|
|
367
|
+
const bytes = exists ? file.size : 0;
|
|
341
368
|
// Counted and set aside, not skipped: the bytes are real and a reader is owed the number.
|
|
342
369
|
// Kept out of `entries` as well as out of `jsBytes`, because `entries` is what a finding reads
|
|
343
370
|
// to name the heaviest import — and on a fresh scaffold every route's `heaviestChain` was
|
|
@@ -348,6 +375,12 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
|
|
|
348
375
|
}
|
|
349
376
|
entries.push({ url, bytes });
|
|
350
377
|
jsBytes += bytes;
|
|
378
|
+
// And every file THAT one loads: an island entry imports its shared chunks (`island-link.ts`),
|
|
379
|
+
// and each is a fetch the browser makes before the island is whole. Through `weigh`, so a chunk
|
|
380
|
+
// two islands share is charged once and a cycle ends at the first repeat.
|
|
381
|
+
if (exists) {
|
|
382
|
+
for (const next of loadedBy(url, await file.text())) await weigh(next);
|
|
383
|
+
}
|
|
351
384
|
};
|
|
352
385
|
|
|
353
386
|
for (const match of html.matchAll(SCRIPT_TAG)) {
|
package/src/cmd-dev.ts
CHANGED
|
@@ -79,8 +79,10 @@ export interface DevServer {
|
|
|
79
79
|
readonly runtime: RunningServices;
|
|
80
80
|
/** Panel keys `/_x` mounted, in tab order. Reported so `--json` names what is reachable. */
|
|
81
81
|
readonly panels: readonly string[];
|
|
82
|
-
/** `POST <path>` of the app's
|
|
82
|
+
/** `POST <path>` of the app's default MCP endpoint, or `null` when nothing was mounted. */
|
|
83
83
|
readonly mcp: string | null;
|
|
84
|
+
/** `POST <path>` of every mounted MCP endpoint, default first — `[]` when none was. */
|
|
85
|
+
readonly mcpPaths: readonly string[];
|
|
84
86
|
stop(): Promise<void>;
|
|
85
87
|
}
|
|
86
88
|
|
|
@@ -240,7 +242,7 @@ async function bootDev(
|
|
|
240
242
|
// `x dev`'s own, so a reload can empty it: an `isr` page's first render was otherwise served for
|
|
241
243
|
// its whole ttl after every save, whatever the modules behind it now said.
|
|
242
244
|
const isr = createIsrController({ buildId });
|
|
243
|
-
const { routes, theme, errorStyles, mcpPath } = await devRouteTable({
|
|
245
|
+
const { routes, theme, errorStyles, mcpPath, mcpPaths } = await devRouteTable({
|
|
244
246
|
isr,
|
|
245
247
|
root: options.root,
|
|
246
248
|
env: options.env,
|
|
@@ -328,6 +330,7 @@ async function bootDev(
|
|
|
328
330
|
services,
|
|
329
331
|
roles: running.roles,
|
|
330
332
|
mcp: mcpPath,
|
|
333
|
+
mcpPaths,
|
|
331
334
|
get buildId(): string {
|
|
332
335
|
return stamped ?? state.manifest.buildId;
|
|
333
336
|
},
|
|
@@ -447,6 +450,7 @@ export const devCommand: CliCommand = {
|
|
|
447
450
|
introspect: `${server.url}/_x`,
|
|
448
451
|
panels: [...server.panels],
|
|
449
452
|
mcp: server.mcp,
|
|
453
|
+
mcpPaths: [...server.mcpPaths],
|
|
450
454
|
},
|
|
451
455
|
lines: [
|
|
452
456
|
// A hard kill leaves the lock behind; clearing it is normal and worth one line, never a
|
|
@@ -458,7 +462,8 @@ export const devCommand: CliCommand = {
|
|
|
458
462
|
msg('cli.dev.introspect', { url: `${server.url}/_x` }),
|
|
459
463
|
// Only when something was mounted: the unmounted case has already said why, once, as a
|
|
460
464
|
// warning with a fix, and a summary line reading `mcp none` would be a second copy of it.
|
|
461
|
-
|
|
465
|
+
// One line per endpoint, so an app serving customers, staff and affiliates sees all three.
|
|
466
|
+
...server.mcpPaths.map((path) => msg('cli.dev.mcp', { path })),
|
|
462
467
|
],
|
|
463
468
|
};
|
|
464
469
|
await writeLock(services.stateDir, {
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// A supervised `x dev` child that outlives what it serves stops itself. Two facts nothing else in
|
|
2
|
+
// the child watched: its SUPERVISOR is gone (SIGKILLed, so it forwarded nothing — the child was
|
|
3
|
+
// reparented to init and served forever), and its APP ROOT is gone (the jobs worker, the outbox
|
|
4
|
+
// relay and the scheduler then poll an embedded database whose directory no longer exists, at
|
|
5
|
+
// 100% CPU, measured at ~50 MB/s of heap growth). Found on a laptop, 2026-09-29: two children of
|
|
6
|
+
// `cmd-dev-restart.live.test.ts`, six hours old, ppid 1, cwd deleted, one at 7 GB.
|
|
7
|
+
//
|
|
8
|
+
// And a stop that does not finish is finished for it. A drain is bounded by core's deadline, but a
|
|
9
|
+
// loop starved by a spin like the one above reaches its timers late or never, so every stop this
|
|
10
|
+
// child begins — a signal, the watch below, a restart — carries a hard exit past that bound.
|
|
11
|
+
|
|
12
|
+
import { existsSync } from 'node:fs'; // why: one synchronous stat per tick; Bun has no sync exists.
|
|
13
|
+
import { drain, drainDeadlineMs, ERROR_DOCS_URL, finiteCount, logger } from '@ultimat3/core';
|
|
14
|
+
import { MIN_RELEASE_MS } from './hold';
|
|
15
|
+
|
|
16
|
+
/** Set on the child by the supervisor: its own pid, so a child started after it died still knows. */
|
|
17
|
+
export const DEV_SUPERVISOR_PID_ENV = 'ULTIMATE_DEV_SUPERVISOR_PID';
|
|
18
|
+
|
|
19
|
+
/** How often the child asks. A second: a dead supervisor's child is gone about a second later. */
|
|
20
|
+
export const DEV_CHILD_WATCH_MS = 1_000;
|
|
21
|
+
|
|
22
|
+
/** Past the drain's own deadline and `hold.ts`'s release floor, a stop is not going to finish. */
|
|
23
|
+
export const devHardExitMs = (): number => drainDeadlineMs() + MIN_RELEASE_MS + 5_000;
|
|
24
|
+
|
|
25
|
+
export type DevChildGone = 'supervisor' | 'root';
|
|
26
|
+
|
|
27
|
+
export interface DevChildWatchInput {
|
|
28
|
+
readonly root: string;
|
|
29
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
30
|
+
/** Called once, when the child decides to leave. */
|
|
31
|
+
readonly leave: (why: DevChildGone) => void;
|
|
32
|
+
/** Test seams: the defaults read the process and the disk. */
|
|
33
|
+
readonly parentPid?: () => number;
|
|
34
|
+
readonly rootExists?: (root: string) => boolean;
|
|
35
|
+
readonly intervalMs?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The supervisor's pid, or `undefined` when this process was not started by one. */
|
|
39
|
+
export function supervisorPid(
|
|
40
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
41
|
+
): number | undefined {
|
|
42
|
+
const said = Number(env[DEV_SUPERVISOR_PID_ENV]);
|
|
43
|
+
return Number.isInteger(said) && said > 0 ? said : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Polls both facts until one fails, then calls `leave` once and stops. Answers the stop.
|
|
48
|
+
* `process.ppid` rather than a pipe from the supervisor: stdin is the terminal's (`inherit`), and
|
|
49
|
+
* a supervisor that died before the first tick is still caught — its pid is never ours again.
|
|
50
|
+
*/
|
|
51
|
+
export function watchDevChild(input: DevChildWatchInput): () => void {
|
|
52
|
+
const expected = supervisorPid(input.env);
|
|
53
|
+
const parent = input.parentPid ?? (() => process.ppid);
|
|
54
|
+
const exists = input.rootExists ?? existsSync;
|
|
55
|
+
let left = false;
|
|
56
|
+
const timer = setInterval(
|
|
57
|
+
() => {
|
|
58
|
+
if (left) return;
|
|
59
|
+
const why: DevChildGone | undefined =
|
|
60
|
+
expected !== undefined && parent() !== expected
|
|
61
|
+
? 'supervisor'
|
|
62
|
+
: exists(input.root)
|
|
63
|
+
? undefined
|
|
64
|
+
: 'root';
|
|
65
|
+
if (why === undefined) return;
|
|
66
|
+
left = true;
|
|
67
|
+
clearInterval(timer);
|
|
68
|
+
input.leave(why);
|
|
69
|
+
},
|
|
70
|
+
finiteCount('watchDevChild', 'intervalMs', input.intervalMs ?? DEV_CHILD_WATCH_MS, 1),
|
|
71
|
+
);
|
|
72
|
+
// Never the reason the process is alive — the hold is, and a watch outliving it would be a leak.
|
|
73
|
+
timer.unref();
|
|
74
|
+
return () => {
|
|
75
|
+
left = true;
|
|
76
|
+
clearInterval(timer);
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The line the child leaves on stderr (fd 1 may be `--json`'s one document). */
|
|
81
|
+
export function reportGone(root: string, why: DevChildGone): void {
|
|
82
|
+
if (why === 'root') {
|
|
83
|
+
logger.error('X_DEV_ROOT_GONE', {
|
|
84
|
+
code: 'X_DEV_ROOT_GONE',
|
|
85
|
+
cause: `${root}, the app root this x dev serves, no longer exists — nothing is left to serve, so it stops instead of polling a database whose directory is gone`,
|
|
86
|
+
fix: 'x dev # from the app root, once it exists again (restored, re-cloned or moved back)',
|
|
87
|
+
docs: ERROR_DOCS_URL,
|
|
88
|
+
});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
logger.warn('dev.supervisor.gone', {
|
|
92
|
+
cause:
|
|
93
|
+
'the x dev supervisor that started this child exited without stopping it (a SIGKILL cannot be forwarded), so the child stops itself',
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* One hard exit, armed by the first stop and never disarmed: it is NOT unref'd, because it is the
|
|
99
|
+
* one timer that must still fire once everything else is done or stuck. `exit` is the test seam.
|
|
100
|
+
*/
|
|
101
|
+
export function hardExit(
|
|
102
|
+
exit: (code: number) => void,
|
|
103
|
+
afterMs: () => number = devHardExitMs,
|
|
104
|
+
): (code: number) => void {
|
|
105
|
+
let armed = false;
|
|
106
|
+
return (code: number): void => {
|
|
107
|
+
if (armed) return;
|
|
108
|
+
armed = true;
|
|
109
|
+
const ms = afterMs();
|
|
110
|
+
setTimeout(() => {
|
|
111
|
+
logger.warn('dev.stop.forced', {
|
|
112
|
+
cause: `x dev was still stopping ${String(ms)}ms after it began, so it exits without finishing`,
|
|
113
|
+
});
|
|
114
|
+
exit(code);
|
|
115
|
+
}, ms);
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The child's watch, wired to core's drain: the supervisor's death or the root's, and SIGINT or
|
|
121
|
+
* SIGTERM, each begin a stop that ends within `devHardExitMs` however the drain fares. `onGone`
|
|
122
|
+
* marks why, so the hold's exit can answer a code.
|
|
123
|
+
*/
|
|
124
|
+
export function startDevChildWatch(
|
|
125
|
+
root: string,
|
|
126
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
127
|
+
onGone: (why: DevChildGone) => void,
|
|
128
|
+
): { readonly stopping: (code: number) => void } {
|
|
129
|
+
const stopping = hardExit((code) => process.exit(code));
|
|
130
|
+
// Beside core's own handlers, never instead of them: the drain is theirs, the deadline is this.
|
|
131
|
+
process.on('SIGINT', () => stopping(130));
|
|
132
|
+
process.on('SIGTERM', () => stopping(143));
|
|
133
|
+
watchDevChild({
|
|
134
|
+
root,
|
|
135
|
+
env,
|
|
136
|
+
leave: (why) => {
|
|
137
|
+
reportGone(root, why);
|
|
138
|
+
onGone(why);
|
|
139
|
+
stopping(why === 'root' ? 1 : 0);
|
|
140
|
+
void drain(why === 'root' ? 'dev-root-gone' : 'dev-supervisor-gone');
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
return { stopping };
|
|
144
|
+
}
|