@entrinsik/vite-plugin-informer 2.7.0 → 2.10.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/README.md +129 -0
- package/bin/ci.js +0 -0
- package/package.json +1 -1
- package/src/agent-dev.js +26 -12
- package/src/assemble.js +5 -1
- package/src/compat.js +252 -0
- package/src/deploy.js +116 -43
- package/src/dev-channel-shim.js +150 -0
- package/src/dev-channels.js +191 -0
- package/src/dev-dependencies.js +44 -17
- package/src/dev-platform.js +41 -0
- package/src/dev-streams.js +660 -0
- package/src/index.js +131 -16
- package/src/server-routes.js +79 -21
- package/src/streams-client.js +200 -0
package/src/deploy.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { createClient } from './client.js';
|
|
2
2
|
import { collectAppFiles } from './assemble.js';
|
|
3
|
+
import { describeServer, planDeploy } from './compat.js';
|
|
4
|
+
import { loadManifest } from './dev-dependencies.js';
|
|
3
5
|
import { readFile } from 'node:fs/promises';
|
|
4
6
|
import { basename, dirname } from 'node:path';
|
|
5
7
|
|
|
@@ -22,6 +24,20 @@ const CHUNK_THRESHOLD = 512 * 1024; // 512KB
|
|
|
22
24
|
*/
|
|
23
25
|
export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, description, icon, id }) {
|
|
24
26
|
const api = createClient({ baseUrl, apiKey, user, pass });
|
|
27
|
+
const projectRoot = dirname(distDir);
|
|
28
|
+
|
|
29
|
+
// 0. Assemble and version-check before touching the app. Both steps below
|
|
30
|
+
// are destructive — the snapshot rotates and _clear empties the library —
|
|
31
|
+
// so a missing dist/ or a refused version floor has to surface here,
|
|
32
|
+
// while the deployed app is still whole.
|
|
33
|
+
const collected = await collectAppFiles({ distDir, projectRoot });
|
|
34
|
+
const server = await describeServer(api);
|
|
35
|
+
const plan = planDeploy({ server, files: collected, manifest: await loadManifest(projectRoot) });
|
|
36
|
+
|
|
37
|
+
for (const warning of plan.warnings) {
|
|
38
|
+
console.warn(` ! ${warning}`);
|
|
39
|
+
}
|
|
40
|
+
if (plan.refusal) throw new Error(plan.refusal);
|
|
25
41
|
|
|
26
42
|
let entity = null;
|
|
27
43
|
let naturalId = null;
|
|
@@ -104,12 +120,14 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
|
|
|
104
120
|
console.log('Clearing existing files...');
|
|
105
121
|
await api.post(`${entityPath}/files/_clear`);
|
|
106
122
|
|
|
107
|
-
// 6. Upload the app-library file set: dist output at the
|
|
108
|
-
// informer.yaml / data-access.yaml and the
|
|
109
|
-
// source trees
|
|
110
|
-
//
|
|
123
|
+
// 6. Upload the app-library file set assembled in step 0: dist output at the
|
|
124
|
+
// library root, plus informer.yaml / data-access.yaml and the
|
|
125
|
+
// server/tools/mcp/migrations/webhooks/embeddings source trees, less any
|
|
126
|
+
// tree this server is too old to keep out of browsers (see compat.js).
|
|
127
|
+
// Sourced from the shared collectAppFiles() so a deploy and a marketplace
|
|
128
|
+
// publish package byte-identical contents.
|
|
111
129
|
console.log('Uploading files...');
|
|
112
|
-
const files =
|
|
130
|
+
const files = plan.files;
|
|
113
131
|
|
|
114
132
|
for (const { abs, rel } of files) {
|
|
115
133
|
const content = await readFile(abs);
|
|
@@ -136,45 +154,9 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
|
|
|
136
154
|
}
|
|
137
155
|
}
|
|
138
156
|
|
|
139
|
-
// 12. Deploy: run migrations + scan/bundle server routes + webhooks + tools + agents
|
|
157
|
+
// 12. Deploy: run migrations + scan/bundle server routes + webhooks + embeddings + tools + agents
|
|
140
158
|
if (apiPrefix === 'apps') {
|
|
141
|
-
|
|
142
|
-
console.log('Deploying...');
|
|
143
|
-
const result = await api.post(`${entityPath}/_deploy`);
|
|
144
|
-
if (result) {
|
|
145
|
-
if (result.migrated && result.migrated.length > 0) {
|
|
146
|
-
console.log(` Ran ${result.migrated.length} migration(s): ${result.migrated.join(', ')}`);
|
|
147
|
-
}
|
|
148
|
-
if (result.routes && result.routes.length > 0) {
|
|
149
|
-
console.log(` Registered ${result.routes.length} server route(s):`);
|
|
150
|
-
for (const r of result.routes) {
|
|
151
|
-
console.log(` ${r}`);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
if (result.webhooks && result.webhooks.length > 0) {
|
|
155
|
-
console.log(` Registered ${result.webhooks.length} webhook(s):`);
|
|
156
|
-
for (const r of result.webhooks) {
|
|
157
|
-
console.log(` ${r}`);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
if (result.tools && result.tools.length > 0) {
|
|
161
|
-
console.log(` Registered ${result.tools.length} tool(s): ${result.tools.join(', ')}`);
|
|
162
|
-
}
|
|
163
|
-
if (result.mcpTools && result.mcpTools.length > 0) {
|
|
164
|
-
console.log(` Registered ${result.mcpTools.length} MCP tool(s): ${result.mcpTools.join(', ')}`);
|
|
165
|
-
}
|
|
166
|
-
if (result.agents && result.agents.length > 0) {
|
|
167
|
-
console.log(` Deployed ${result.agents.length} agent(s): ${result.agents.join(', ')}`);
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
} catch (err) {
|
|
171
|
-
if (err.status === 404) {
|
|
172
|
-
// Server may not support _deploy yet — ignore
|
|
173
|
-
} else {
|
|
174
|
-
const detail = err.body || err.message;
|
|
175
|
-
console.error(` Deploy failed: ${detail}`);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
159
|
+
await runServerDeploy(api, entityPath);
|
|
178
160
|
}
|
|
179
161
|
|
|
180
162
|
// 13. Print URL and return UUID for saving
|
|
@@ -188,6 +170,97 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
|
|
|
188
170
|
return { id: entity.id, url: entityUrl };
|
|
189
171
|
}
|
|
190
172
|
|
|
173
|
+
/**
|
|
174
|
+
* POST `_deploy`: run migrations, scan/bundle server routes, webhooks, tools and
|
|
175
|
+
* agents, and claim the app as platform-managed.
|
|
176
|
+
*
|
|
177
|
+
* Exported for its own tests. This is the step whose failure modes are silent —
|
|
178
|
+
* everything it does is invisible in the CLI output when it doesn't happen.
|
|
179
|
+
*
|
|
180
|
+
* @param {{post: (path: string, body?: unknown) => Promise<unknown>}} api
|
|
181
|
+
* @param {string} entityPath e.g. `apps/my-app`
|
|
182
|
+
*/
|
|
183
|
+
export async function runServerDeploy(api, entityPath) {
|
|
184
|
+
try {
|
|
185
|
+
console.log('Deploying...');
|
|
186
|
+
// `managed: true` claims the app as platform-managed (origin
|
|
187
|
+
// 'deployed'), which locks builder editing so a UI edit can't drift
|
|
188
|
+
// from this project. Opt-in: the server leaves an app editable when the
|
|
189
|
+
// flag is absent, so the GO admin panel's Redeploy and the builder's
|
|
190
|
+
// Save can share this route without converting user-built apps. See
|
|
191
|
+
// app/routes/deploy.js.
|
|
192
|
+
const result = await api.post(`${entityPath}/_deploy`, { managed: true });
|
|
193
|
+
if (result === null) {
|
|
194
|
+
// api.post turns a 404 into null instead of throwing, so this is the
|
|
195
|
+
// only place it can be caught. Files uploaded, but migrations, server
|
|
196
|
+
// routes, webhooks and the managed claim did NOT run — and a missing
|
|
197
|
+
// route is far more often a wrong app id or a proxy path rewrite than
|
|
198
|
+
// a server too old to have _deploy. Falling through here prints
|
|
199
|
+
// "Published ..." and exits 0 on a half-finished deploy.
|
|
200
|
+
throw new Error(
|
|
201
|
+
`Deploy endpoint not found at ${entityPath}/_deploy. The app may not exist on this server, `
|
|
202
|
+
+ `the path may be rewritten by a proxy, or the server may predate _deploy. `
|
|
203
|
+
+ `Files were uploaded, but migrations, server routes and the managed claim did not run.`
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
if (result.migrated && result.migrated.length > 0) {
|
|
207
|
+
console.log(` Ran ${result.migrated.length} migration(s): ${result.migrated.join(', ')}`);
|
|
208
|
+
}
|
|
209
|
+
if (result.routes && result.routes.length > 0) {
|
|
210
|
+
console.log(` Registered ${result.routes.length} server route(s):`);
|
|
211
|
+
for (const r of result.routes) {
|
|
212
|
+
console.log(` ${r}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (result.webhooks && result.webhooks.length > 0) {
|
|
216
|
+
console.log(` Registered ${result.webhooks.length} webhook(s):`);
|
|
217
|
+
for (const r of result.webhooks) {
|
|
218
|
+
console.log(` ${r}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (result.channelHandlers && result.channelHandlers.length > 0) {
|
|
222
|
+
console.log(` Registered ${result.channelHandlers.length} channel handler(s):`);
|
|
223
|
+
for (const c of result.channelHandlers) {
|
|
224
|
+
console.log(` ${c}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (result.channels && result.channels.length > 0) {
|
|
228
|
+
console.log(` Relaying events to ${result.channels.length} channel(s): ${result.channels.join(', ')}`);
|
|
229
|
+
}
|
|
230
|
+
if (result.tools && result.tools.length > 0) {
|
|
231
|
+
console.log(` Registered ${result.tools.length} tool(s): ${result.tools.join(', ')}`);
|
|
232
|
+
}
|
|
233
|
+
if (result.mcpTools && result.mcpTools.length > 0) {
|
|
234
|
+
console.log(` Registered ${result.mcpTools.length} MCP tool(s): ${result.mcpTools.join(', ')}`);
|
|
235
|
+
}
|
|
236
|
+
if (result.agents && result.agents.length > 0) {
|
|
237
|
+
console.log(` Deployed ${result.agents.length} agent(s): ${result.agents.join(', ')}`);
|
|
238
|
+
}
|
|
239
|
+
// Advisory findings the server accumulated. The deploy still
|
|
240
|
+
// succeeded — but silently dropping these leaves authors debugging
|
|
241
|
+
// mystery responses the server already diagnosed for them.
|
|
242
|
+
if (result.routeShadowWarnings && result.routeShadowWarnings.length > 0) {
|
|
243
|
+
console.warn(` ⚠ ${result.routeShadowWarnings.length} route shadow warning(s):`);
|
|
244
|
+
for (const w of result.routeShadowWarnings) {
|
|
245
|
+
console.warn(` ${w}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (result.partialFailures && result.partialFailures.length > 0) {
|
|
249
|
+
console.warn(` ⚠ ${result.partialFailures.length} non-fatal deploy phase failure(s):`);
|
|
250
|
+
for (const f of result.partialFailures) {
|
|
251
|
+
console.warn(` [${f.phase}] ${f.error}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return result;
|
|
255
|
+
} catch (err) {
|
|
256
|
+
// Rethrow: a failed deploy is a failed publish. Logging and falling
|
|
257
|
+
// through prints "Published ... files" and exits 0, so CI goes green on
|
|
258
|
+
// an app whose migrations never ran. bin/deploy.js turns this into exit 1.
|
|
259
|
+
const detail = err.body || err.message;
|
|
260
|
+
throw new Error(`Deploy failed: ${detail}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
191
264
|
function formatSize(bytes) {
|
|
192
265
|
if (bytes < 1024) return `${bytes} B`;
|
|
193
266
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { CHANNEL_NAME, CHANNEL_NAME_MAX_LENGTH, DEV_CHANNEL_EVENT } from './dev-channels.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The dev `__INFORMER__.channel(name)` mock: the App Channels client surface
|
|
5
|
+
* (App API v2 §1.9) with the same synchronous name check, `on(event, fn)` →
|
|
6
|
+
* `fn(payload, frame)` dispatch by `frame.event`, unsubscribe fn, `error`
|
|
7
|
+
* codes (`join_refused` | `disconnected` | `not_supported`; `rate_limited`
|
|
8
|
+
* never happens locally), phase-1 `send()` rejection and idempotent
|
|
9
|
+
* `close()` as the origin-mode shim in html-utils.js generateChannelShim.
|
|
10
|
+
*
|
|
11
|
+
* Transport: production subscribes each channel over a nes socket minted
|
|
12
|
+
* from `/_socket`. Dev has no such socket, and opening one would mean a
|
|
13
|
+
* second websocket, a `ws: true` proxy and a credential the dev server
|
|
14
|
+
* cannot mint. Frames instead ride Vite's own dev websocket as the custom
|
|
15
|
+
* event DEV_CHANNEL_EVENT (`server.ws.send({ type: 'custom', ... })` on the
|
|
16
|
+
* node side, `import.meta.hot.on(...)` here) and are filtered by channel
|
|
17
|
+
* name on the page. Vite owns reconnection; a drop surfaces to every open
|
|
18
|
+
* channel as `disconnected`, the way a lost nes socket does.
|
|
19
|
+
*
|
|
20
|
+
* Returned as source (an `installDevChannels(hot, informer)` function
|
|
21
|
+
* declaration) so it can be evaluated in a test `vm` against a fake hot
|
|
22
|
+
* context, the same way the server spec drives the production shim.
|
|
23
|
+
*/
|
|
24
|
+
export function generateDevChannelShim() {
|
|
25
|
+
return `
|
|
26
|
+
// --- App Channels (dev): frames arrive on Vite's dev websocket ---
|
|
27
|
+
function installDevChannels(hot, informer, unavailableReason) {
|
|
28
|
+
var CHANNEL_NAME = /${CHANNEL_NAME.source}/;
|
|
29
|
+
var CHANNEL_NAME_MAX_LENGTH = ${CHANNEL_NAME_MAX_LENGTH};
|
|
30
|
+
var FRAME_EVENT = '${DEV_CHANNEL_EVENT}';
|
|
31
|
+
var open = []; // channels holding at least one handler
|
|
32
|
+
|
|
33
|
+
function channelError(code, message) {
|
|
34
|
+
var e = new Error(message);
|
|
35
|
+
e.code = code;
|
|
36
|
+
return e;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function deliver(frame) {
|
|
40
|
+
if (!frame || typeof frame.channel !== 'string') return;
|
|
41
|
+
open.slice().forEach(function (ch) {
|
|
42
|
+
if (ch.name === frame.channel) ch._deliver(frame);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function dropAll() {
|
|
47
|
+
open.slice().forEach(function (ch) {
|
|
48
|
+
ch._emitError(channelError('disconnected', 'The dev server connection was lost'));
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (hot) {
|
|
53
|
+
hot.on(FRAME_EVENT, deliver);
|
|
54
|
+
hot.on('vite:ws:disconnect', dropAll);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function Channel(name) {
|
|
58
|
+
this.name = name;
|
|
59
|
+
this._handlers = {};
|
|
60
|
+
this._closed = false;
|
|
61
|
+
this._unavailable = false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
Channel.prototype._deliver = function (frame) {
|
|
65
|
+
if (this._closed) return;
|
|
66
|
+
var list = this._handlers[frame.event];
|
|
67
|
+
if (!list) return;
|
|
68
|
+
list.slice().forEach(function (fn) {
|
|
69
|
+
try { fn(frame.payload, frame); }
|
|
70
|
+
catch (e) { console.error('[Informer] channel handler failed:', e); }
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
Channel.prototype._emitError = function (err) {
|
|
75
|
+
if (this._closed) return;
|
|
76
|
+
var list = this._handlers.error;
|
|
77
|
+
if (!list || !list.length) {
|
|
78
|
+
console.warn('[Informer] channel "' + this.name + '" ' + err.code + ': ' + err.message);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
list.slice().forEach(function (fn) {
|
|
82
|
+
try { fn(err); }
|
|
83
|
+
catch (e) { console.error('[Informer] channel error handler failed:', e); }
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
Channel.prototype.on = function (event, fn) {
|
|
88
|
+
if (this._closed) throw channelError('disconnected', 'The channel is closed');
|
|
89
|
+
if (typeof event !== 'string' || !event) throw new TypeError('channel.on: event name required');
|
|
90
|
+
if (typeof fn !== 'function') throw new TypeError('channel.on: handler must be a function');
|
|
91
|
+
var list = this._handlers[event] || (this._handlers[event] = []);
|
|
92
|
+
list.push(fn);
|
|
93
|
+
if (open.indexOf(this) === -1) open.push(this);
|
|
94
|
+
if (!hot && !this._unavailable) {
|
|
95
|
+
// Not served by the Vite dev server: no frames can ever arrive.
|
|
96
|
+
// Reported once, asynchronously, like a failed connection.
|
|
97
|
+
this._unavailable = true;
|
|
98
|
+
var self = this;
|
|
99
|
+
Promise.resolve().then(function () {
|
|
100
|
+
self._emitError(channelError('disconnected', unavailableReason || 'The Vite dev websocket is not available'));
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return function () {
|
|
104
|
+
var i = list.indexOf(fn);
|
|
105
|
+
if (i !== -1) list.splice(i, 1);
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
Channel.prototype.send = function () {
|
|
110
|
+
return Promise.reject(channelError('not_supported', 'Sending on a channel is not supported yet'));
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
Channel.prototype.close = function () {
|
|
114
|
+
if (this._closed) return;
|
|
115
|
+
this._closed = true;
|
|
116
|
+
var i = open.indexOf(this);
|
|
117
|
+
if (i !== -1) open.splice(i, 1);
|
|
118
|
+
this._handlers = {};
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
informer.channel = function (name) {
|
|
122
|
+
if (typeof name !== 'string' || name.length > CHANNEL_NAME_MAX_LENGTH || !CHANNEL_NAME.test(name)) {
|
|
123
|
+
throw channelError('join_refused', 'Invalid channel name: ' + name);
|
|
124
|
+
}
|
|
125
|
+
return new Channel(name);
|
|
126
|
+
};
|
|
127
|
+
}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The `<script type="module">` tag that installs the mock on the dev page.
|
|
132
|
+
* Vite turns an inline module script into an `?html-proxy` module, which is
|
|
133
|
+
* what gives it a live `import.meta.hot`. Module scripts run in document
|
|
134
|
+
* order after parsing, so placed at the top of <head> this installs
|
|
135
|
+
* `channel()` before the app's own module executes.
|
|
136
|
+
*/
|
|
137
|
+
export function renderDevChannelScript({ hub = true } = {}) {
|
|
138
|
+
// Without INFORMER_URL, configureServer returns before building the channel
|
|
139
|
+
// hub, so nothing can ever push a frame — but import.meta.hot is still live,
|
|
140
|
+
// which would leave the shim silent and the author staring at a channel that
|
|
141
|
+
// simply never delivers. Passing no `hot` routes it through the same
|
|
142
|
+
// report-once diagnostic, with the reason that actually applies.
|
|
143
|
+
const install = hub
|
|
144
|
+
? 'installDevChannels(import.meta.hot, window.__INFORMER__);'
|
|
145
|
+
: `installDevChannels(null, window.__INFORMER__, 'Dev channels need INFORMER_URL set for the dev server to relay frames');`;
|
|
146
|
+
return `<script type="module">
|
|
147
|
+
${generateDevChannelShim()}
|
|
148
|
+
${install}
|
|
149
|
+
</script>`;
|
|
150
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* App Channels in dev: the `broadcast(channel, event, payload)` verb for
|
|
5
|
+
* in-process handlers, and the `channels:` manifest relay that mirrors
|
|
6
|
+
* `emitAppEvent` on the server.
|
|
7
|
+
*
|
|
8
|
+
* A broadcast validates exactly like the server's `broadcastAppMessage`
|
|
9
|
+
* (app-channel-broadcast.js) — same regexes, limits and error messages — and
|
|
10
|
+
* publishes one frame on a plugin-local emitter. The plugin forwards every
|
|
11
|
+
* frame to the page over Vite's own dev websocket (see index.js); nothing
|
|
12
|
+
* here touches the network. Rate limiting and the `enabled` switch are
|
|
13
|
+
* server-cluster concerns and have no dev counterpart.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// Mirrors of app-channel-broadcast.js: `orders`, `orders/east`, `@user/brad`.
|
|
17
|
+
// Everything after `@user/` is one username, verbatim — the server compares the
|
|
18
|
+
// whole remainder to the socket's own.
|
|
19
|
+
export const CHANNEL_NAME = /^(@user\/\S+|[\w.-]+(\/[\w.-]+)*)$/;
|
|
20
|
+
export const CHANNEL_NAME_MAX_LENGTH = 128;
|
|
21
|
+
// `created`, `order_created`
|
|
22
|
+
export const EVENT_NAME = /^[\w.-]+$/;
|
|
23
|
+
export const EVENT_NAME_MAX_LENGTH = 64;
|
|
24
|
+
// config-factory.js app.channels.maxFrameBytes default
|
|
25
|
+
export const MAX_FRAME_BYTES = 65536;
|
|
26
|
+
// deploy.js CHANNELS_SCHEMA description cap
|
|
27
|
+
const DESCRIPTION_MAX_LENGTH = 500;
|
|
28
|
+
|
|
29
|
+
// The Vite custom event a frame rides to the page (`hot.on(DEV_CHANNEL_EVENT, ...)`).
|
|
30
|
+
export const DEV_CHANNEL_EVENT = 'informer:channel';
|
|
31
|
+
// The plugin has no tenant identity; frames carry this until it does.
|
|
32
|
+
export const DEV_TENANT = 'dev';
|
|
33
|
+
|
|
34
|
+
/** A short, single-line preview of a payload for the dev console (frames may be 64 KiB). */
|
|
35
|
+
export function previewPayload(value, max = 200) {
|
|
36
|
+
let text;
|
|
37
|
+
try { text = JSON.stringify(value); } catch { text = String(value); }
|
|
38
|
+
if (text === undefined) text = 'undefined';
|
|
39
|
+
return text.length > max ? `${text.slice(0, max)}… (${text.length} chars)` : text;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The emitter event a published frame is raised on. */
|
|
43
|
+
export const BROADCAST_EVENT = 'broadcast';
|
|
44
|
+
|
|
45
|
+
export function isChannelName(name) {
|
|
46
|
+
return typeof name === 'string' && name.length <= CHANNEL_NAME_MAX_LENGTH && CHANNEL_NAME.test(name);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isEventName(name) {
|
|
50
|
+
return typeof name === 'string' && name.length <= EVENT_NAME_MAX_LENGTH && EVENT_NAME.test(name);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Shaped like the boom error the server throws: message === code, plus the
|
|
54
|
+
// HTTP status it would carry, so a handler can branch on either in dev.
|
|
55
|
+
function channelError(code, statusCode) {
|
|
56
|
+
const err = new Error(code);
|
|
57
|
+
err.code = code;
|
|
58
|
+
err.statusCode = statusCode;
|
|
59
|
+
return err;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Validate the shape of a parsed `channels:` block. Returns human-readable
|
|
64
|
+
* error strings (empty when valid). Mirrors deploy.js CHANNELS_SCHEMA so an
|
|
65
|
+
* author sees at boot what the deploy would 400 on.
|
|
66
|
+
*
|
|
67
|
+
* @param {*} block - The raw `channels:` value
|
|
68
|
+
* @returns {string[]} Error messages, one per problem
|
|
69
|
+
*/
|
|
70
|
+
export function validateChannels(block) {
|
|
71
|
+
const errors = [];
|
|
72
|
+
if (block === undefined || block === null) return errors;
|
|
73
|
+
if (typeof block !== 'object' || Array.isArray(block)) {
|
|
74
|
+
return ['channels: must be a map of channel name → { description?, on? }'];
|
|
75
|
+
}
|
|
76
|
+
for (const [name, def] of Object.entries(block)) {
|
|
77
|
+
if (!isChannelName(name)) {
|
|
78
|
+
errors.push(`channels: invalid channel name "${name}" (use segments of letters, digits, _ . -, joined by /, max ${CHANNEL_NAME_MAX_LENGTH} chars)`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (def === null || def === undefined) continue;
|
|
82
|
+
if (typeof def !== 'object' || Array.isArray(def)) {
|
|
83
|
+
errors.push(`channels.${name}: must be a map with optional "description" and "on"`);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
for (const key of Object.keys(def)) {
|
|
87
|
+
if (key !== 'description' && key !== 'on') errors.push(`channels.${name}: unknown key "${key}"`);
|
|
88
|
+
}
|
|
89
|
+
if (def.description !== undefined && (typeof def.description !== 'string' || def.description.length > DESCRIPTION_MAX_LENGTH)) {
|
|
90
|
+
errors.push(`channels.${name}.description: must be a string of at most ${DESCRIPTION_MAX_LENGTH} chars`);
|
|
91
|
+
}
|
|
92
|
+
if (def.on !== undefined) {
|
|
93
|
+
const events = Array.isArray(def.on) ? def.on : [def.on];
|
|
94
|
+
for (const event of events) {
|
|
95
|
+
if (!isEventName(event)) {
|
|
96
|
+
errors.push(`channels.${name}.on: invalid event name ${JSON.stringify(event)} (letters, digits, _ . -, max ${EVENT_NAME_MAX_LENGTH} chars)`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return errors;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Create the dev channels hub shared by every dev handler bag.
|
|
106
|
+
*
|
|
107
|
+
* @param {Object} [opts]
|
|
108
|
+
* @param {string} [opts.tenant] - frame tenant (the plugin knows none; defaults to 'dev')
|
|
109
|
+
* @param {string} [opts.appId] - the dev app id (the mocked `report.id`)
|
|
110
|
+
* @param {string} [opts.logPrefix] - console prefix
|
|
111
|
+
* @returns {{ emitter: EventEmitter, broadcast: Function, relay: Function, tenant: string, appId: string }}
|
|
112
|
+
*/
|
|
113
|
+
export function createDevChannels({ tenant = DEV_TENANT, appId = 'dev-local', logPrefix = '[app-channel]' } = {}) {
|
|
114
|
+
const emitter = new EventEmitter();
|
|
115
|
+
|
|
116
|
+
// Validate and publish one §1.1 frame. Synchronous so the manifest relay
|
|
117
|
+
// can run inside a synchronous emit(); throws the server's error codes.
|
|
118
|
+
function publish(channel, event, payload) {
|
|
119
|
+
if (!isChannelName(channel)) throw channelError('app_channel_invalid_name', 400);
|
|
120
|
+
if (!isEventName(event)) throw channelError('app_channel_invalid_event', 400);
|
|
121
|
+
|
|
122
|
+
const message = payload === undefined ? null : payload;
|
|
123
|
+
let serialized;
|
|
124
|
+
try {
|
|
125
|
+
serialized = JSON.stringify(message);
|
|
126
|
+
} catch {
|
|
127
|
+
throw channelError('app_channel_invalid_payload', 400);
|
|
128
|
+
}
|
|
129
|
+
// A function/symbol serializes to nothing at all; the sandbox membrane
|
|
130
|
+
// would have refused it before the server ever saw it.
|
|
131
|
+
if (serialized === undefined) throw channelError('app_channel_invalid_payload', 400);
|
|
132
|
+
if (Buffer.byteLength(serialized) > MAX_FRAME_BYTES) throw channelError('app_channel_frame_too_large', 413);
|
|
133
|
+
|
|
134
|
+
const frame = { tenant, appId, channel, event, payload: message, at: Date.now() };
|
|
135
|
+
emitter.emit(BROADCAST_EVENT, frame);
|
|
136
|
+
return frame;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The bag member. Async like the sandbox's, so a bad name/event/payload
|
|
140
|
+
// rejects rather than throws, exactly as it does in production.
|
|
141
|
+
async function broadcast(channel, event, payload) {
|
|
142
|
+
const frame = publish(channel, event, payload);
|
|
143
|
+
console.log(`${logPrefix} broadcast("${channel}", "${event}", ${previewPayload(frame.payload)})`);
|
|
144
|
+
return { ok: true };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The `channels:` relay (mirror of emitAppEvent): an emit() of an event
|
|
149
|
+
* listed in a channel's `on` is also broadcast to that channel, same event
|
|
150
|
+
* name and payload. A dropped relay warns and never fails the emit.
|
|
151
|
+
*
|
|
152
|
+
* @param {Object} manifestChannels - the parsed `channels:` block
|
|
153
|
+
* @param {string} event
|
|
154
|
+
* @param {*} payload
|
|
155
|
+
*/
|
|
156
|
+
function relay(manifestChannels, event, payload) {
|
|
157
|
+
if (!manifestChannels || typeof manifestChannels !== 'object') return;
|
|
158
|
+
for (const [channel, def] of Object.entries(manifestChannels)) {
|
|
159
|
+
if (![].concat((def && def.on) || []).includes(event)) continue;
|
|
160
|
+
try {
|
|
161
|
+
publish(channel, event, payload);
|
|
162
|
+
console.log(`${logPrefix} relayed emit("${event}") → channel "${channel}"`);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
console.warn(`${logPrefix} relay dropped: channel "${channel}" event "${event}": ${err.message}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { emitter, broadcast, relay, tenant, appId };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Build the dev `emit(event, payload)` bag member: a console-logged no-op
|
|
174
|
+
* (no app_event row in dev) that still runs the manifest relay, so a page
|
|
175
|
+
* subscribed to a relayed channel sees the frame locally.
|
|
176
|
+
*
|
|
177
|
+
* @param {Object} opts
|
|
178
|
+
* @param {ReturnType<typeof createDevChannels>} opts.channels
|
|
179
|
+
* @param {Object} opts.manifestChannels - the parsed `channels:` block
|
|
180
|
+
* @param {string} [opts.logPrefix]
|
|
181
|
+
* @returns {(event: string, payload?: *) => { ok: true }}
|
|
182
|
+
*/
|
|
183
|
+
export function createDevEmit({ channels, manifestChannels, logPrefix = '[app-event]' }) {
|
|
184
|
+
return (event, payload) => {
|
|
185
|
+
// The sandbox bootstrap sends `payload || {}` across the membrane.
|
|
186
|
+
const body = payload || {};
|
|
187
|
+
console.log(`${logPrefix} emit("${event}", ${previewPayload(body)})`);
|
|
188
|
+
channels.relay(manifestChannels, event, body);
|
|
189
|
+
return { ok: true };
|
|
190
|
+
};
|
|
191
|
+
}
|
package/src/dev-dependencies.js
CHANGED
|
@@ -173,14 +173,16 @@ const METHOD_SURFACE = {
|
|
|
173
173
|
const REQUEST_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
174
174
|
|
|
175
175
|
/**
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
176
|
+
* Read and parse informer.yaml once. Returns `{}` when the file is missing or
|
|
177
|
+
* its top level is not a map, so callers can select blocks without guarding.
|
|
178
|
+
* Per-request callers (server routes, agent tools) load it fresh on every
|
|
179
|
+
* request so edits take effect without a dev-server restart, and select every
|
|
180
|
+
* block they need from this one parse via manifestBlock().
|
|
179
181
|
*
|
|
180
182
|
* @param {string} projectRoot
|
|
181
|
-
* @returns {Promise<Object>} The
|
|
183
|
+
* @returns {Promise<Object>} The parsed manifest document
|
|
182
184
|
*/
|
|
183
|
-
export async function
|
|
185
|
+
export async function loadManifest(projectRoot) {
|
|
184
186
|
const yamlPath = join(projectRoot, 'informer.yaml');
|
|
185
187
|
try {
|
|
186
188
|
await access(yamlPath);
|
|
@@ -189,8 +191,42 @@ export async function loadDependencies(projectRoot) {
|
|
|
189
191
|
}
|
|
190
192
|
const content = await readFile(yamlPath, 'utf8');
|
|
191
193
|
const parsed = parseYaml(content);
|
|
192
|
-
|
|
193
|
-
|
|
194
|
+
return (parsed && typeof parsed === 'object') ? parsed : {};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* One top-level map block of a parsed manifest, or {} when absent or not a map.
|
|
199
|
+
*
|
|
200
|
+
* @param {Object} manifest - A document from loadManifest()
|
|
201
|
+
* @param {string} key - Top-level block name (`dependencies`, `channels`, `env`, ...)
|
|
202
|
+
* @returns {Object}
|
|
203
|
+
*/
|
|
204
|
+
export function manifestBlock(manifest, key) {
|
|
205
|
+
const block = manifest && manifest[key];
|
|
206
|
+
return (block && typeof block === 'object' && !Array.isArray(block)) ? block : {};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Load the `dependencies:` map from informer.yaml. Returns `{}` if the file
|
|
211
|
+
* is missing or the section is absent — both are valid (an app may have no
|
|
212
|
+
* declared dependencies).
|
|
213
|
+
*
|
|
214
|
+
* @param {string} projectRoot
|
|
215
|
+
* @returns {Promise<Object>} The raw dependencies object as authored
|
|
216
|
+
*/
|
|
217
|
+
export async function loadDependencies(projectRoot) {
|
|
218
|
+
return manifestBlock(await loadManifest(projectRoot), 'dependencies');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Read the `channels:` relay block from informer.yaml: channel name →
|
|
223
|
+
* { description?, on? }. Returns {} when there is no manifest or no block.
|
|
224
|
+
*
|
|
225
|
+
* @param {string} projectRoot
|
|
226
|
+
* @returns {Promise<Object>}
|
|
227
|
+
*/
|
|
228
|
+
export async function loadChannels(projectRoot) {
|
|
229
|
+
return manifestBlock(await loadManifest(projectRoot), 'channels');
|
|
194
230
|
}
|
|
195
231
|
|
|
196
232
|
/**
|
|
@@ -203,16 +239,7 @@ export async function loadDependencies(projectRoot) {
|
|
|
203
239
|
* @returns {Promise<Object>} The raw env object as authored
|
|
204
240
|
*/
|
|
205
241
|
export async function loadAppEnv(projectRoot) {
|
|
206
|
-
|
|
207
|
-
try {
|
|
208
|
-
await access(yamlPath);
|
|
209
|
-
} catch {
|
|
210
|
-
return {};
|
|
211
|
-
}
|
|
212
|
-
const content = await readFile(yamlPath, 'utf8');
|
|
213
|
-
const parsed = parseYaml(content);
|
|
214
|
-
if (!parsed || typeof parsed !== 'object') return {};
|
|
215
|
-
return (parsed.env && typeof parsed.env === 'object') ? parsed.env : {};
|
|
242
|
+
return manifestBlock(await loadManifest(projectRoot), 'env');
|
|
216
243
|
}
|
|
217
244
|
|
|
218
245
|
/**
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `platform` descriptor the dev mirror hands to app code — the same shape
|
|
3
|
+
* the server injects on `window.__INFORMER__.platform` and on the server
|
|
4
|
+
* handler / tool bag: the Informer build version and the capability flags.
|
|
5
|
+
*
|
|
6
|
+
* Every capability the dev server mirrors is on. `embeddings` is off: the
|
|
7
|
+
* pump and query-time `embed()` need a real Informer, so an app that
|
|
8
|
+
* feature-detects on it sees locally exactly what it sees on an install
|
|
9
|
+
* without the feature. `version` is `'dev'` (not semver) so a floor check
|
|
10
|
+
* treats the dev mirror as "unknown" rather than as any particular release.
|
|
11
|
+
*
|
|
12
|
+
* Override any of it per project with `informer({ mock: { platform: {…} } })`.
|
|
13
|
+
*/
|
|
14
|
+
export const DEV_CAPABILITIES = Object.freeze({
|
|
15
|
+
serverRoutes: true,
|
|
16
|
+
webhooks: true,
|
|
17
|
+
tools: true,
|
|
18
|
+
mcp: true,
|
|
19
|
+
agents: true,
|
|
20
|
+
automations: true,
|
|
21
|
+
messages: true,
|
|
22
|
+
storage: true,
|
|
23
|
+
snapshots: true,
|
|
24
|
+
customApis: true,
|
|
25
|
+
integrationDependencies: true,
|
|
26
|
+
datasourceDependencies: true,
|
|
27
|
+
aiCompletions: true,
|
|
28
|
+
// live channels: broadcast() in the sandbox, the `channels:` relay block,
|
|
29
|
+
// and channels/ join/leave handlers — the dev server mirrors all three.
|
|
30
|
+
channels: true,
|
|
31
|
+
embeddings: false
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export function devPlatform(overrides = {}) {
|
|
35
|
+
const { capabilities = {}, ...rest } = overrides || {};
|
|
36
|
+
return {
|
|
37
|
+
version: 'dev',
|
|
38
|
+
...rest,
|
|
39
|
+
capabilities: { ...DEV_CAPABILITIES, ...capabilities }
|
|
40
|
+
};
|
|
41
|
+
}
|