@feasibleone/blong-gogo 1.31.3 → 1.32.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/CHANGELOG.md +13 -0
- package/package.json +5 -3
- package/src/AdapterBase.ts +3 -0
- package/src/Gateway.ts +20 -0
- package/src/Mcp.ts +227 -0
- package/src/Registry.ts +39 -0
- package/src/Watch.ts +57 -7
- package/src/cli.ts +209 -0
- package/src/cliHelp.ts +2 -1
- package/src/exit.intent.test.ts +98 -0
- package/src/load.ts +157 -94
- package/src/loadBrowser.ts +3 -0
- package/src/loadServer.ts +2 -1
- package/src/runServer.ts +7 -2
- package/src/template-files.ts +10 -49
- package/template/browser/test/test/test$Object.flow.ts +14 -4
- package/template/server/test/test/test$Object.ts +16 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.32.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.31.3...blong-gogo-v1.32.0) (2026-09-12)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* blong-kukum scaffolding as api ([abb1e0b](https://github.com/feasibleone/blong/commit/abb1e0b4836f25e4a04b035cbc3f2b4bbab94a9b))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* small test fixes ([4adb7eb](https://github.com/feasibleone/blong/commit/4adb7eb1b899099fe71b05b96cd210af7d7ce51a))
|
|
14
|
+
* small test/lint fixes ([0f52e7a](https://github.com/feasibleone/blong/commit/0f52e7a30fa479c1a91f95ba5d8bb1ae4706856c))
|
|
15
|
+
|
|
3
16
|
## [1.31.3](https://github.com/feasibleone/blong/compare/blong-gogo-v1.31.2...blong-gogo-v1.31.3) (2026-09-10)
|
|
4
17
|
|
|
5
18
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feasibleone/blong-gogo",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.32.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"url": "git+https://github.com/feasibleone/blong.git"
|
|
6
6
|
},
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"browser": "./src/loadBrowser.ts",
|
|
11
11
|
"import": "./src/loadServer.ts"
|
|
12
12
|
},
|
|
13
|
-
"./ConfigRuntime.js": "./src/ConfigRuntime.ts"
|
|
13
|
+
"./ConfigRuntime.js": "./src/ConfigRuntime.ts",
|
|
14
|
+
"./cli.ts": "./src/cli.ts"
|
|
14
15
|
},
|
|
15
16
|
"bin": {
|
|
16
17
|
"blong": "./bin/blong.ts",
|
|
@@ -19,6 +20,7 @@
|
|
|
19
20
|
},
|
|
20
21
|
"dependencies": {
|
|
21
22
|
"@apidevtools/swagger-parser": "^12.1.0",
|
|
23
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
22
24
|
"@aws-sdk/client-s3": "^3.1037.0",
|
|
23
25
|
"@fastify/basic-auth": "^6.2.0",
|
|
24
26
|
"@fastify/bearer-auth": "^10.1.2",
|
|
@@ -88,7 +90,7 @@
|
|
|
88
90
|
"tap": "^21.6.3",
|
|
89
91
|
"typescript": "^6.0.3",
|
|
90
92
|
"vite": "^8.2.1",
|
|
91
|
-
"@feasibleone/blong-dev": "1.
|
|
93
|
+
"@feasibleone/blong-dev": "1.3.0"
|
|
92
94
|
},
|
|
93
95
|
"scripts": {
|
|
94
96
|
"browser-check": "node scripts/browser-compat-check.mjs",
|
package/src/AdapterBase.ts
CHANGED
|
@@ -87,6 +87,7 @@ export class AdapterBase<T, C extends IContext> implements AdapterHandlerContext
|
|
|
87
87
|
log: unknown = null;
|
|
88
88
|
importedMap?: Map<string, Record<string, (...args: unknown[]) => unknown>>;
|
|
89
89
|
platform: IApi['platform'];
|
|
90
|
+
registry: IApi['registry'];
|
|
90
91
|
|
|
91
92
|
// These are prefixed with _ rather than using # private class fields.
|
|
92
93
|
// The adapter uses Object.setPrototypeOf(current, base) to set the base as
|
|
@@ -125,6 +126,7 @@ export class AdapterBase<T, C extends IContext> implements AdapterHandlerContext
|
|
|
125
126
|
| 'attachCheckpoint'
|
|
126
127
|
| 'render'
|
|
127
128
|
| 'platform'
|
|
129
|
+
| 'registry'
|
|
128
130
|
| 'attach'
|
|
129
131
|
>,
|
|
130
132
|
configBase: string,
|
|
@@ -137,6 +139,7 @@ export class AdapterBase<T, C extends IContext> implements AdapterHandlerContext
|
|
|
137
139
|
this._methodId = api.methodId;
|
|
138
140
|
this._getPath = api.getPath;
|
|
139
141
|
this.platform = api.platform;
|
|
142
|
+
this.registry = api.registry;
|
|
140
143
|
this._api = api;
|
|
141
144
|
this._createLog = api.createLog;
|
|
142
145
|
this._attachCheckpoint = api.attachCheckpoint;
|
package/src/Gateway.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
IErrorFactory,
|
|
6
6
|
IErrorMap,
|
|
7
7
|
IGateway,
|
|
8
|
+
IGatewayRoute,
|
|
8
9
|
ILocal,
|
|
9
10
|
ILog,
|
|
10
11
|
IManifest,
|
|
@@ -194,6 +195,25 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
194
195
|
#rpcClient: IRpcClient;
|
|
195
196
|
#routes: RouteOptions[] = [];
|
|
196
197
|
#local: ILocal;
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The registered routes as plain data.
|
|
201
|
+
*
|
|
202
|
+
* Derived from the same list that is handed to fastify, so callers (for
|
|
203
|
+
* example the MCP server) see exactly the surface that is served — including
|
|
204
|
+
* the per-route auth requirement, which is not inferable from the method name.
|
|
205
|
+
*/
|
|
206
|
+
public describe(): IGatewayRoute[] {
|
|
207
|
+
return this.#routes.map(route => {
|
|
208
|
+
const config = route.config as {methodName?: string; auth?: unknown} | undefined;
|
|
209
|
+
return {
|
|
210
|
+
method: config?.methodName ?? '',
|
|
211
|
+
url: route.url,
|
|
212
|
+
httpMethod: String(route.method ?? 'POST'),
|
|
213
|
+
auth: config?.auth ?? 'jwt',
|
|
214
|
+
};
|
|
215
|
+
});
|
|
216
|
+
}
|
|
197
217
|
#errorFields: [string, unknown][] = [];
|
|
198
218
|
#plugins: {plugin: unknown; options: unknown}[] = [];
|
|
199
219
|
#platform: IPlatformApi;
|
package/src/Mcp.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import type {IGateway, IGatewayRoute, ILog, IRegistry, IRemote} from '@feasibleone/blong/types';
|
|
2
|
+
import {Internal} from '@feasibleone/blong/types';
|
|
3
|
+
import type {FastifyInstance} from 'fastify';
|
|
4
|
+
import fp from 'fastify-plugin';
|
|
5
|
+
|
|
6
|
+
interface IConfig {
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
routePrefix: string;
|
|
9
|
+
auth: false | 'jwt';
|
|
10
|
+
/** Server identity reported during the MCP initialise handshake. */
|
|
11
|
+
name: string;
|
|
12
|
+
version: string;
|
|
13
|
+
/**
|
|
14
|
+
* Method-name patterns to expose as tools. Defaults to the kukum API —
|
|
15
|
+
* widen this to expose other namespaces, or set `['.']` to expose every
|
|
16
|
+
* registered route.
|
|
17
|
+
*/
|
|
18
|
+
tools: string[];
|
|
19
|
+
/** Free-text guidance sent to the client during initialise. */
|
|
20
|
+
instructions?: string;
|
|
21
|
+
logLevel?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface IApiRef {
|
|
25
|
+
log?: ILog;
|
|
26
|
+
gateway?: IGateway;
|
|
27
|
+
registry?: IRegistry;
|
|
28
|
+
remote?: IRemote;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* MCP (Model Context Protocol) server for a Blong gateway, served over
|
|
33
|
+
* Streamable HTTP at `routePrefix` (default `/mcp`).
|
|
34
|
+
*
|
|
35
|
+
* Tools are derived from the gateway's own route table, so the MCP surface can
|
|
36
|
+
* never drift from the API: whatever `kukum.*` (or another configured namespace)
|
|
37
|
+
* route is registered becomes a callable tool, and invoking it dispatches
|
|
38
|
+
* through the same `remote` path an HTTP call would use.
|
|
39
|
+
*
|
|
40
|
+
* Off by default — this exposes an API to any MCP client that can reach the
|
|
41
|
+
* gateway, so it must be enabled deliberately (a suite can enable it in its own
|
|
42
|
+
* `dev` config without affecting anyone else). The SDK is imported lazily so the
|
|
43
|
+
* dependency is not loaded on the default path.
|
|
44
|
+
*/
|
|
45
|
+
export default class Mcp extends Internal {
|
|
46
|
+
#config: IConfig = {
|
|
47
|
+
enabled: false,
|
|
48
|
+
routePrefix: '/mcp',
|
|
49
|
+
auth: false,
|
|
50
|
+
name: 'blong',
|
|
51
|
+
version: '0.0.0',
|
|
52
|
+
tools: ['^kukum\\.'],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
#apiRef: IApiRef;
|
|
56
|
+
/** Sanitised tool name → gateway method name. */
|
|
57
|
+
#tools: Map<string, string> = new Map();
|
|
58
|
+
|
|
59
|
+
public constructor(config: IConfig, apiRef: IApiRef) {
|
|
60
|
+
super({log: apiRef.log});
|
|
61
|
+
this.merge(this.#config, config);
|
|
62
|
+
this.#apiRef = apiRef;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** MCP tool names must be `[A-Za-z0-9_-]`; the dotted method maps 1:1. */
|
|
66
|
+
static toolName(method: string): string {
|
|
67
|
+
return method.replaceAll('.', '_').replaceAll('/', '_');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Routes to expose as tools.
|
|
72
|
+
*
|
|
73
|
+
* `tools` entries are regular expressions matched against the wired method
|
|
74
|
+
* name, so the default `^kukum\\.` selects the whole kukum API. They are used
|
|
75
|
+
* verbatim — anchoring is the caller's choice.
|
|
76
|
+
*/
|
|
77
|
+
public routes(): IGatewayRoute[] {
|
|
78
|
+
const patterns = this.#config.tools.map(pattern => new RegExp(pattern));
|
|
79
|
+
return (this.#apiRef.gateway?.describe?.() ?? []).filter(
|
|
80
|
+
route => route.method && patterns.some(pattern => pattern.test(route.method)),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
public async init(): Promise<void> {
|
|
85
|
+
if (!this.#config.enabled || !this.#apiRef.gateway) return;
|
|
86
|
+
|
|
87
|
+
const remote = this.#apiRef.remote;
|
|
88
|
+
if (!remote) {
|
|
89
|
+
this.log?.warn?.('mcp is enabled but no remote dispatcher is available; skipping');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const prefix = this.#config.routePrefix;
|
|
94
|
+
const authConfig = this.#config.auth;
|
|
95
|
+
const logger = this.log;
|
|
96
|
+
const {name, version, instructions, tools: toolPatterns} = this.#config;
|
|
97
|
+
|
|
98
|
+
// Deferred into the plugin factory on purpose: fastify runs plugin
|
|
99
|
+
// factories during `gateway.start()`, which is AFTER `registry.start()`
|
|
100
|
+
// has published the validations. Reading `gateway.describe()` in
|
|
101
|
+
// `init()` would see an empty route table.
|
|
102
|
+
const plugin = fp(
|
|
103
|
+
async (instance: FastifyInstance) => {
|
|
104
|
+
const routes = this.routes();
|
|
105
|
+
if (!routes.length) {
|
|
106
|
+
logger?.warn?.(
|
|
107
|
+
`mcp is enabled but no routes match ${JSON.stringify(toolPatterns)}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Lazy: nothing on the default path should pay for the SDK.
|
|
112
|
+
const {McpServer} = await import('@modelcontextprotocol/sdk/server/mcp.js');
|
|
113
|
+
const {StreamableHTTPServerTransport} =
|
|
114
|
+
await import('@modelcontextprotocol/sdk/server/streamableHttp.js');
|
|
115
|
+
const {randomUUID} = await import('node:crypto');
|
|
116
|
+
|
|
117
|
+
const server = new McpServer({name, version}, {instructions});
|
|
118
|
+
for (const route of routes) {
|
|
119
|
+
const tool = Mcp.toolName(route.method);
|
|
120
|
+
this.#tools.set(tool, route.method);
|
|
121
|
+
server.registerTool(
|
|
122
|
+
tool,
|
|
123
|
+
{
|
|
124
|
+
title: route.method,
|
|
125
|
+
description:
|
|
126
|
+
`${route.method} — POST ${route.url}` +
|
|
127
|
+
(route.auth === false ? ' (no auth required)' : ''),
|
|
128
|
+
},
|
|
129
|
+
async (args: unknown) => {
|
|
130
|
+
const params = (args ?? {}) as Record<string, unknown>;
|
|
131
|
+
const result = await remote.remote(route.method)(params, {
|
|
132
|
+
method: route.method,
|
|
133
|
+
});
|
|
134
|
+
return {
|
|
135
|
+
content: [
|
|
136
|
+
{type: 'text' as const, text: JSON.stringify(result, null, 2)},
|
|
137
|
+
],
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* One transport per MCP session, keyed by `Mcp-Session-Id`.
|
|
145
|
+
*
|
|
146
|
+
* Sessions are stateful because the protocol requires
|
|
147
|
+
* `initialize` to precede other methods on the same session: a
|
|
148
|
+
* stateless per-request server answers every follow-up with
|
|
149
|
+
* "Method not found".
|
|
150
|
+
*/
|
|
151
|
+
const sessions = new Map<
|
|
152
|
+
string,
|
|
153
|
+
InstanceType<typeof StreamableHTTPServerTransport>
|
|
154
|
+
>();
|
|
155
|
+
|
|
156
|
+
const sessionFor = async (sessionId?: string) => {
|
|
157
|
+
const existing = sessionId ? sessions.get(sessionId) : undefined;
|
|
158
|
+
if (existing) return existing;
|
|
159
|
+
const transport = new StreamableHTTPServerTransport({
|
|
160
|
+
sessionIdGenerator: () => randomUUID(),
|
|
161
|
+
onsessioninitialized: id => {
|
|
162
|
+
sessions.set(id, transport);
|
|
163
|
+
},
|
|
164
|
+
// Plain JSON responses rather than SSE streams: kukum
|
|
165
|
+
// calls are request/response with no server-initiated
|
|
166
|
+
// messages, which keeps the endpoint usable from curl and
|
|
167
|
+
// simple CLI clients.
|
|
168
|
+
enableJsonResponse: true,
|
|
169
|
+
});
|
|
170
|
+
transport.onclose = () => {
|
|
171
|
+
if (transport.sessionId) sessions.delete(transport.sessionId);
|
|
172
|
+
};
|
|
173
|
+
await server.connect(transport);
|
|
174
|
+
return transport;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const handle = async (
|
|
178
|
+
request: Parameters<Parameters<FastifyInstance['route']>[0]['handler']>[0],
|
|
179
|
+
reply: Parameters<Parameters<FastifyInstance['route']>[0]['handler']>[1],
|
|
180
|
+
) => {
|
|
181
|
+
// The transport writes the response itself (including SSE
|
|
182
|
+
// streams), so hand it the raw Node objects and tell fastify
|
|
183
|
+
// not to touch the reply.
|
|
184
|
+
reply.hijack();
|
|
185
|
+
const sessionId = request.headers['mcp-session-id'] as string | undefined;
|
|
186
|
+
try {
|
|
187
|
+
const transport = await sessionFor(sessionId);
|
|
188
|
+
await transport.handleRequest(request.raw, reply.raw, request.body);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
// `reply.hijack()` means fastify will not produce an error
|
|
191
|
+
// response — without this the client just sees an empty 500.
|
|
192
|
+
logger?.error?.({error}, 'mcp request failed');
|
|
193
|
+
if (!reply.raw.headersSent) {
|
|
194
|
+
reply.raw.writeHead(500, {'content-type': 'application/json'});
|
|
195
|
+
}
|
|
196
|
+
reply.raw.end(
|
|
197
|
+
JSON.stringify({
|
|
198
|
+
jsonrpc: '2.0',
|
|
199
|
+
error: {code: -32603, message: (error as Error).message},
|
|
200
|
+
id: null,
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
instance.route({
|
|
207
|
+
method: ['GET', 'POST', 'DELETE'],
|
|
208
|
+
url: prefix,
|
|
209
|
+
config: {auth: authConfig},
|
|
210
|
+
handler: handle,
|
|
211
|
+
});
|
|
212
|
+
instance.addHook('onClose', async () => {
|
|
213
|
+
for (const transport of sessions.values()) await transport.close();
|
|
214
|
+
sessions.clear();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
logger?.warn?.(
|
|
218
|
+
`mcp server enabled at ${prefix} — exposing ${routes.length} tool(s); ` +
|
|
219
|
+
'do not enable in production',
|
|
220
|
+
);
|
|
221
|
+
},
|
|
222
|
+
{name: 'mcp'},
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
this.#apiRef.gateway.registerPlugin(plugin);
|
|
226
|
+
}
|
|
227
|
+
}
|
package/src/Registry.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
IObjectSchema,
|
|
15
15
|
IPlatformApi,
|
|
16
16
|
IRegistry,
|
|
17
|
+
IRegistryDescription,
|
|
17
18
|
IRemote,
|
|
18
19
|
IRpcServer,
|
|
19
20
|
} from '@feasibleone/blong/types';
|
|
@@ -375,6 +376,19 @@ export default class Registry extends Internal implements IRegistry {
|
|
|
375
376
|
renderAll(Array.isArray(what) ? merge(...what) : what, this.#platform.context),
|
|
376
377
|
timing: this.#platform.timing,
|
|
377
378
|
...blongLib,
|
|
379
|
+
// The runtime services a `library()` function needs, mirroring the
|
|
380
|
+
// port API (`registry: this`, `platform: …`).
|
|
381
|
+
//
|
|
382
|
+
// A `library()` *factory* is not given the platform: `layerApi`
|
|
383
|
+
// carries only config/handler/errors/schema/gateway/apiSchema. And
|
|
384
|
+
// the lib proxy returns an attached function **raw**, so `this`
|
|
385
|
+
// inside it is this object — not the port, which is only bound in the
|
|
386
|
+
// proxy's not-yet-attached fallback path. Without these keys a
|
|
387
|
+
// library function therefore cannot reach the platform or registry at
|
|
388
|
+
// all, which is what a library needs to be reusable outside a
|
|
389
|
+
// request.
|
|
390
|
+
platform: this.#platform,
|
|
391
|
+
registry: this,
|
|
378
392
|
};
|
|
379
393
|
const local = {};
|
|
380
394
|
const literals: object[] = [];
|
|
@@ -469,6 +483,31 @@ export default class Registry extends Internal implements IRegistry {
|
|
|
469
483
|
return await this.#watch.test(framework);
|
|
470
484
|
}
|
|
471
485
|
|
|
486
|
+
/**
|
|
487
|
+
* Structural snapshot of the loaded realm graph.
|
|
488
|
+
*
|
|
489
|
+
* Combines the live registry (realms, ports, registered method groups) with
|
|
490
|
+
* the on-disk view owned by `Watch` (handler folders and files). This is the
|
|
491
|
+
* runtime source of truth that tooling and agents should query instead of
|
|
492
|
+
* re-deriving layout from conventions.
|
|
493
|
+
*/
|
|
494
|
+
public describe(): IRegistryDescription {
|
|
495
|
+
const disk = this.#watch?.describe?.();
|
|
496
|
+
return {
|
|
497
|
+
realms: Array.from(this.modules.keys()).filter(
|
|
498
|
+
(key): key is string => typeof key === 'string',
|
|
499
|
+
),
|
|
500
|
+
ports: Array.from(this.ports.keys()),
|
|
501
|
+
groups: Array.from(this.methods.entries()).map(([name, handlers]) => ({
|
|
502
|
+
name,
|
|
503
|
+
handlerCount: handlers.length,
|
|
504
|
+
})),
|
|
505
|
+
folders: disk?.folders ?? [],
|
|
506
|
+
files: disk?.files ?? [],
|
|
507
|
+
layerFiles: disk?.layerFiles ?? [],
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
472
511
|
public async stop(): Promise<IRegistry> {
|
|
473
512
|
if (this.#stopped) return this;
|
|
474
513
|
await this.#watch?.stop();
|
package/src/Watch.ts
CHANGED
|
@@ -36,6 +36,18 @@ export interface IWatch {
|
|
|
36
36
|
setConfigRuntime?(configRuntime: IConfigRuntime): void;
|
|
37
37
|
/** Attach the registry objectSchema so hot-reload paths can pass it to layerProxy */
|
|
38
38
|
setObjectSchema?(schema: IObjectSchema): void;
|
|
39
|
+
/**
|
|
40
|
+
* Structural snapshot of the handler folders/files discovered on disk.
|
|
41
|
+
* Data-only so it can be returned straight from an API endpoint.
|
|
42
|
+
*/
|
|
43
|
+
describe(): IWatchDescription;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Handler folders/files discovered from disk, keyed by their owning realm. */
|
|
47
|
+
export interface IWatchDescription {
|
|
48
|
+
folders: Array<{group: string; realm: string; dir: string}>;
|
|
49
|
+
files: Array<{file: string; realm: string}>;
|
|
50
|
+
layerFiles: Array<{file: string; realm: string}>;
|
|
39
51
|
}
|
|
40
52
|
|
|
41
53
|
interface ITestContext {
|
|
@@ -97,6 +109,13 @@ export default class Watch extends Internal implements IWatch {
|
|
|
97
109
|
new Map();
|
|
98
110
|
#handlerFiles: Map<string, {name: string; pkg: IModuleConfig['pkg']; base: string}> = new Map();
|
|
99
111
|
#layerFiles: Map<string, {name: string; pkg: IModuleConfig['pkg']; base: string}> = new Map();
|
|
112
|
+
/**
|
|
113
|
+
* Every code file discovered inside a handler folder, mapped to its realm.
|
|
114
|
+
* Kept separate from `#handlerFiles` (which drives hot-reload decisions and
|
|
115
|
+
* is deliberately populated only for single-file loads) so introspection can
|
|
116
|
+
* report the full file set without changing reload behaviour.
|
|
117
|
+
*/
|
|
118
|
+
#discoveredFiles: Map<string, string> = new Map();
|
|
100
119
|
#watchers: IWatcher[] = [];
|
|
101
120
|
#port: () => unknown;
|
|
102
121
|
#error: IErrorFactory;
|
|
@@ -138,6 +157,35 @@ export default class Watch extends Internal implements IWatch {
|
|
|
138
157
|
this.#objectSchema = schema;
|
|
139
158
|
}
|
|
140
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Structural snapshot of what is loaded from disk.
|
|
162
|
+
*
|
|
163
|
+
* Group ids follow the same convention the loader registers with
|
|
164
|
+
* (`<realm>.<folder>`, see `_loadHandlers`). Paths are made relative to the
|
|
165
|
+
* process cwd so the result is stable across machines.
|
|
166
|
+
*/
|
|
167
|
+
public describe(): IWatchDescription {
|
|
168
|
+
const relative = (file: string): string => this.#platform.relative('.', file);
|
|
169
|
+
const folderEntries = Array.from(this.#handlerFolders.entries()).map(([dir, config]) => ({
|
|
170
|
+
group: `${config.name}.${this.#platform.basename(dir)}`,
|
|
171
|
+
realm: config.name,
|
|
172
|
+
dir: relative(dir),
|
|
173
|
+
}));
|
|
174
|
+
const toFiles = (map: Map<string, {name: string}>): Array<{file: string; realm: string}> =>
|
|
175
|
+
Array.from(map.entries()).map(([file, config]) => ({
|
|
176
|
+
file: relative(file),
|
|
177
|
+
realm: config.name,
|
|
178
|
+
}));
|
|
179
|
+
return {
|
|
180
|
+
folders: folderEntries,
|
|
181
|
+
files: Array.from(this.#discoveredFiles.entries()).map(([file, realm]) => ({
|
|
182
|
+
file: relative(file),
|
|
183
|
+
realm,
|
|
184
|
+
})),
|
|
185
|
+
layerFiles: toFiles(this.#layerFiles),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
141
189
|
/** Attach a ConfigRuntime so config-file changes trigger in-process reloads */
|
|
142
190
|
public setConfigRuntime(configRuntime: IConfigRuntime): void {
|
|
143
191
|
this.#configRuntime = configRuntime;
|
|
@@ -327,6 +375,7 @@ export default class Watch extends Internal implements IWatch {
|
|
|
327
375
|
filename,
|
|
328
376
|
this.#platform.extname(filename),
|
|
329
377
|
);
|
|
378
|
+
this.#discoveredFiles.set(filename, config.name);
|
|
330
379
|
const name = expectedName;
|
|
331
380
|
(kind(item) === 'validation'
|
|
332
381
|
? validations
|
|
@@ -659,13 +708,14 @@ export default class Watch extends Internal implements IWatch {
|
|
|
659
708
|
remote: IRemote,
|
|
660
709
|
configOverride: object,
|
|
661
710
|
): Promise<void> {
|
|
662
|
-
this.
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
711
|
+
if (this.#config.enabled)
|
|
712
|
+
this.log?.debug?.({
|
|
713
|
+
$meta: {mtid: 'event', method: 'watch.start'},
|
|
714
|
+
dir: Array.from(this.#handlerFolders.keys())
|
|
715
|
+
.concat(Array.from(this.#handlerFiles.keys()))
|
|
716
|
+
.concat(Array.from(this.#layerFiles.keys()))
|
|
717
|
+
.map(folder => this.#platform.relative('.', folder)),
|
|
718
|
+
});
|
|
669
719
|
if (this.#config.test) {
|
|
670
720
|
this.#emit.addEventListener('test', async (event: Event) => {
|
|
671
721
|
const {done, test} = ((event as CustomEvent).detail ?? {}) as {
|