@entrinsik/vite-plugin-informer 2.10.0 → 2.12.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 +7 -3
- package/bin/init.js +13 -2
- package/bin/workspace.js +15 -7
- package/index.d.ts +14 -0
- package/package.json +1 -1
- package/src/agent-dev.js +8 -11
- package/src/dev-bag.js +241 -0
- package/src/dev-channel-handlers.js +335 -0
- package/src/dev-channel-shim.js +276 -39
- package/src/dev-channels.js +138 -28
- package/src/dev-dependencies.js +140 -4
- package/src/dev-platform.js +81 -5
- package/src/dev-streams.js +176 -27
- package/src/env.js +17 -1
- package/src/index.js +87 -9
- package/src/server-routes.js +79 -211
- package/src/streams-client.js +121 -8
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parse as parseUrl } from 'node:url';
|
|
4
|
+
import { createDevBagBuilder, buildDevUser } from './dev-bag.js';
|
|
5
|
+
import { devPlatform } from './dev-platform.js';
|
|
6
|
+
import { filePathToRoute, matchRoute, walkJsFiles } from './server-routes.js';
|
|
7
|
+
import { createDevChannels, isChannelName, isWildcardName, isEventName, isReservedEvent, channelError, USER_CHANNEL_PREFIX } from './dev-channels.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The dev counterpart of app-channel-handlers.js: a page's `channel()` mock
|
|
11
|
+
* subscribes, unsubscribes and sends over plain POSTs to DEV_CHANNEL_API, and
|
|
12
|
+
* the matching `channels/` file runs in-process the way a deployed handler
|
|
13
|
+
* runs in the sandbox — `join` decides admission, `joined` runs after it,
|
|
14
|
+
* `leave` runs on unsubscribe, and an event-named export answers `send()`.
|
|
15
|
+
* Frames still ride Vite's websocket to every page; admission is what tells
|
|
16
|
+
* the page which frames it may dispatch.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const CHANNELS_DIR = 'channels';
|
|
20
|
+
// The synthetic method channel handlers are matched under (deploy.js rebuildRouteTable).
|
|
21
|
+
export const CHANNEL_METHOD = 'CHANNEL';
|
|
22
|
+
// The deployment's joinTimeoutMs cap; a file's `config.timeout` may only lower it.
|
|
23
|
+
export const HANDLER_TIMEOUT_MS = 5000;
|
|
24
|
+
// Per-user inbound budget (token bucket): send() and the replay reads a reconnect makes.
|
|
25
|
+
export const SEND_RATE = Object.freeze({ perSecond: 10, burst: 30 });
|
|
26
|
+
// Exports with a fixed meaning; every other export must be an event name.
|
|
27
|
+
export const LIFECYCLE_EXPORTS = Object.freeze(['config', 'join', 'joined', 'leave']);
|
|
28
|
+
|
|
29
|
+
// One literal segment of a channel name (app-channel-broadcast.js CHANNEL_SEGMENT).
|
|
30
|
+
const CHANNEL_SEGMENT = /^[\w.-]+$/;
|
|
31
|
+
const USER_SEGMENT = USER_CHANNEL_PREFIX.slice(0, -1);
|
|
32
|
+
|
|
33
|
+
// Named exports as the deploy's scanner would list them: declarations and
|
|
34
|
+
// `export { a, b as c }` lists (`export default` lands as "default").
|
|
35
|
+
const EXPORT_DECL = /^\s*export\s+(?:async\s+)?(?:function\s*\*?\s*|const\s+|let\s+|var\s+|class\s+)([A-Za-z_$][\w$]*)/gm;
|
|
36
|
+
const EXPORT_LIST = /^\s*export\s*\{([^}]*)\}/gm;
|
|
37
|
+
const EXPORT_DEFAULT = /^\s*export\s+default\b/m;
|
|
38
|
+
|
|
39
|
+
/** The export names a channel handler source declares. */
|
|
40
|
+
export function exportNames(source) {
|
|
41
|
+
const names = new Set();
|
|
42
|
+
for (const m of source.matchAll(EXPORT_DECL)) names.add(m[1]);
|
|
43
|
+
for (const m of source.matchAll(EXPORT_LIST)) {
|
|
44
|
+
for (const entry of m[1].split(',')) {
|
|
45
|
+
const parts = entry.trim().split(/\s+as\s+/);
|
|
46
|
+
const name = (parts[1] || parts[0]).trim();
|
|
47
|
+
if (name) names.add(name);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (EXPORT_DEFAULT.test(source)) names.add('default');
|
|
51
|
+
return [...names].sort();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Scan the app's channels/ directory into handler routes, the same
|
|
56
|
+
* file-convention mapping server/ uses: `channels/rooms/[room].js` → `/rooms/:room`.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} projectRoot
|
|
59
|
+
* @returns {Promise<Array<{ path: string, relPath: string, filePath: string }>>}
|
|
60
|
+
*/
|
|
61
|
+
export async function scanChannelHandlers(projectRoot) {
|
|
62
|
+
const files = await walkJsFiles(join(projectRoot, CHANNELS_DIR), CHANNELS_DIR);
|
|
63
|
+
return files.map(({ relPath, absPath }) => ({
|
|
64
|
+
path: filePathToRoute(relPath, CHANNELS_DIR),
|
|
65
|
+
relPath,
|
|
66
|
+
filePath: absPath
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The deploy-time rules for channels/ files (channel-scanner.js), as boot
|
|
72
|
+
* diagnostics: a file may export `config`, `join`, `joined`, `leave` and
|
|
73
|
+
* event-named handlers; it must export something; its path must name a
|
|
74
|
+
* channel a page can subscribe to; two files may not share a path.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} projectRoot
|
|
77
|
+
* @returns {Promise<string[]>} one message per problem, prefixed with the file
|
|
78
|
+
*/
|
|
79
|
+
export async function validateChannelHandlers(projectRoot) {
|
|
80
|
+
const problems = [];
|
|
81
|
+
const byPath = new Map();
|
|
82
|
+
for (const handler of await scanChannelHandlers(projectRoot)) {
|
|
83
|
+
const source = await readFile(handler.filePath, 'utf8');
|
|
84
|
+
const names = exportNames(source);
|
|
85
|
+
if (names.length === 0) {
|
|
86
|
+
problems.push(`${handler.relPath}: exports nothing — a channel handler exports join, joined, leave and/or event handlers (and optionally config)`);
|
|
87
|
+
}
|
|
88
|
+
const invalid = names.filter(name => !LIFECYCLE_EXPORTS.includes(name) && (name === 'default' || !isEventName(name) || isReservedEvent(name)));
|
|
89
|
+
if (invalid.length) {
|
|
90
|
+
problems.push(`${handler.relPath}: exports ${invalid.join(', ')} — only config, join, joined, leave and event-named handlers (not error/connected) are allowed`);
|
|
91
|
+
}
|
|
92
|
+
const segments = handler.path === '/' ? [] : handler.path.slice(1).split('/');
|
|
93
|
+
const subscribable = segments.length > 0 && segments.every((segment, i) =>
|
|
94
|
+
segment.startsWith(':') || CHANNEL_SEGMENT.test(segment) || (i === 0 && segment === USER_SEGMENT));
|
|
95
|
+
if (!subscribable) {
|
|
96
|
+
problems.push(`${handler.relPath}: maps to "${handler.path}", a channel no page can subscribe to — name segments use letters, digits, "_", "." and "-" (or a [param]), and channels/index.js names no channel at all`);
|
|
97
|
+
}
|
|
98
|
+
const dup = byPath.get(handler.path);
|
|
99
|
+
if (dup) problems.push(`${handler.relPath}: channel "${handler.path}" is also defined by ${dup}`);
|
|
100
|
+
byPath.set(handler.path, handler.relPath);
|
|
101
|
+
}
|
|
102
|
+
return problems;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Does a handler route cover some channel deeper than `minDepth` segments
|
|
106
|
+
// under `prefix`? (`/rooms/:room` and `/rooms/east/members` both cover names
|
|
107
|
+
// under `rooms/`.) `minDepth` is what the wildcard's own handler already gates:
|
|
108
|
+
// the prefix length for an unmatched wildcard, one more for a matched one.
|
|
109
|
+
function coversUnder(routePath, prefix, minDepth) {
|
|
110
|
+
const segments = routePath.split('/').filter(Boolean);
|
|
111
|
+
return segments.length > minDepth && prefix.every((seg, i) => segments[i].startsWith(':') || segments[i] === seg);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function readJson(req) {
|
|
115
|
+
return new Promise((resolve, reject) => {
|
|
116
|
+
const chunks = [];
|
|
117
|
+
req.on('data', chunk => chunks.push(chunk));
|
|
118
|
+
req.on('error', reject);
|
|
119
|
+
req.on('end', () => {
|
|
120
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
121
|
+
if (!text) return resolve({});
|
|
122
|
+
try { resolve(JSON.parse(text)); } catch { reject(channelError('app_channel_invalid_body', 400)); }
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function sendJson(res, status, body) {
|
|
128
|
+
res.statusCode = status;
|
|
129
|
+
res.setHeader('Content-Type', 'application/json');
|
|
130
|
+
res.end(JSON.stringify(body));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Create the Connect middleware behind DEV_CHANNEL_API.
|
|
135
|
+
*
|
|
136
|
+
* POST /subscribe { clientId, channel } → { ok: true } | error
|
|
137
|
+
* POST /unsubscribe { clientId, channel } → { ok: true }
|
|
138
|
+
* POST /send { clientId, channel, event, payload } → { result } | error
|
|
139
|
+
* GET /replay?channel=&since= → { frames, oldest, current }
|
|
140
|
+
*
|
|
141
|
+
* Errors are `{ error: <code> }` with the status the server would use.
|
|
142
|
+
*
|
|
143
|
+
* @param {Object} viteServer - Vite dev server (ssrLoadModule loads the handler files)
|
|
144
|
+
* @param {Object} opts - createDevBagBuilder options plus:
|
|
145
|
+
* @param {Object} [opts.user] - the mocked viewer (window.__INFORMER__.user)
|
|
146
|
+
* @param {string[]} [opts.roles] - the mocked viewer's roles
|
|
147
|
+
* @param {string} [opts.logPrefix]
|
|
148
|
+
* @param {number} [opts.timeoutMs] - the handler wall-clock cap
|
|
149
|
+
* @param {{ perSecond: number, burst: number }} [opts.rate] - the per-user send budget
|
|
150
|
+
* @param {() => number} [opts.now] - clock, for the rate limiter
|
|
151
|
+
* @returns {Function} Connect middleware
|
|
152
|
+
*/
|
|
153
|
+
export function createDevChannelHandlers(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings = {}, appToken = null, channels = createDevChannels(), user, roles = [], logPrefix = '[app-channel]', timeoutMs = HANDLER_TIMEOUT_MS, rate = SEND_RATE, now = Date.now, platform = devPlatform(), appId = null }) {
|
|
154
|
+
const devUser = buildDevUser(user);
|
|
155
|
+
// The same merged descriptor the page and the server/ bag see: a channels/
|
|
156
|
+
// handler reading platform.capabilities.embeddings must not disagree with
|
|
157
|
+
// the route beside it, and its embed() must be bound when they agree.
|
|
158
|
+
const bagBuilder = createDevBagBuilder({ serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings, appToken, channels, logPrefix: '[app]', platform, appId });
|
|
159
|
+
// clientId → channel name → { clientId, channel, params, file }
|
|
160
|
+
const subscriptions = new Map();
|
|
161
|
+
// username → { tokens, ts }
|
|
162
|
+
const buckets = new Map();
|
|
163
|
+
|
|
164
|
+
function recordsFor(clientId) {
|
|
165
|
+
let records = subscriptions.get(clientId);
|
|
166
|
+
if (!records) {
|
|
167
|
+
records = new Map();
|
|
168
|
+
subscriptions.set(clientId, records);
|
|
169
|
+
}
|
|
170
|
+
return records;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function takeSendToken(username) {
|
|
174
|
+
const at = now();
|
|
175
|
+
let bucket = buckets.get(username);
|
|
176
|
+
if (!bucket) {
|
|
177
|
+
bucket = { tokens: rate.burst, ts: at };
|
|
178
|
+
buckets.set(username, bucket);
|
|
179
|
+
}
|
|
180
|
+
bucket.tokens = Math.min(rate.burst, bucket.tokens + (Math.max(0, at - bucket.ts) / 1000) * rate.perSecond);
|
|
181
|
+
bucket.ts = at;
|
|
182
|
+
if (bucket.tokens < 1) return false;
|
|
183
|
+
bucket.tokens -= 1;
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// The handler file covering a channel name, or null for an open channel.
|
|
188
|
+
// A wildcard resolves with `*` as the param and is admitted only when
|
|
189
|
+
// nothing sits deeper than the file that took it (or, unmatched, deeper
|
|
190
|
+
// than its prefix): a deeper file's gate would otherwise be listened around.
|
|
191
|
+
async function resolveChannel(channel) {
|
|
192
|
+
const table = (await scanChannelHandlers(projectRoot)).map(h => ({ ...h, method: CHANNEL_METHOD }));
|
|
193
|
+
let match;
|
|
194
|
+
try {
|
|
195
|
+
match = matchRoute(table, CHANNEL_METHOD, `/${channel}`);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
if (err instanceof URIError) throw channelError('app_channel_invalid_name', 400);
|
|
198
|
+
throw err;
|
|
199
|
+
}
|
|
200
|
+
if (isWildcardName(channel)) {
|
|
201
|
+
const prefix = channel.slice(0, -2).split('/');
|
|
202
|
+
// an unmatched wildcard gates nothing; a matched one gates its own
|
|
203
|
+
// depth, and a file deeper than that would go ungated either way
|
|
204
|
+
const gatedTo = match ? prefix.length + 1 : prefix.length;
|
|
205
|
+
if (table.some(route => coversUnder(route.path, prefix, gatedTo))) throw channelError('app_channel_wildcard_gated', 403);
|
|
206
|
+
}
|
|
207
|
+
return match ? { params: match.params, file: match.route } : null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Run one export with the channel bag under the handler's timeout. A thrown
|
|
211
|
+
// channel error keeps its status; anything else is 500 app_channel_<export>_failed.
|
|
212
|
+
async function runExport(mod, exportName, { channel, params, payload }) {
|
|
213
|
+
const { bag } = await bagBuilder.build();
|
|
214
|
+
const invocation = {
|
|
215
|
+
...bag,
|
|
216
|
+
channel: { name: channel, params, broadcast: async (event, body, options) => await bag.broadcast(channel, event, body, options) },
|
|
217
|
+
payload: payload === undefined ? null : payload,
|
|
218
|
+
request: { user: { ...devUser }, roles }
|
|
219
|
+
};
|
|
220
|
+
const cap = Math.min(Number(mod.config && mod.config.timeout) || timeoutMs, timeoutMs);
|
|
221
|
+
let timer;
|
|
222
|
+
const timeout = new Promise((_, reject) => {
|
|
223
|
+
timer = setTimeout(() => reject(channelError(`app_channel_${exportName}_timeout`, 504)), cap);
|
|
224
|
+
});
|
|
225
|
+
try {
|
|
226
|
+
return await Promise.race([Promise.resolve().then(() => mod[exportName](invocation)), timeout]);
|
|
227
|
+
} catch (err) {
|
|
228
|
+
if (err && typeof err.statusCode === 'number') throw err;
|
|
229
|
+
console.error(`${logPrefix} ${exportName}("${channel}") failed:`, err);
|
|
230
|
+
throw channelError(`app_channel_${exportName}_failed`, 500);
|
|
231
|
+
} finally {
|
|
232
|
+
clearTimeout(timer);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function subscribe({ clientId, channel }) {
|
|
237
|
+
if (!isChannelName(channel)) throw channelError('app_channel_invalid_name', 400);
|
|
238
|
+
// `@user/<username>` is private to that user; the whole remainder is the username.
|
|
239
|
+
if (channel.startsWith(USER_CHANNEL_PREFIX) && channel.slice(USER_CHANNEL_PREFIX.length) !== devUser.username) {
|
|
240
|
+
throw channelError('app_channel_user_mismatch', 403);
|
|
241
|
+
}
|
|
242
|
+
const resolved = await resolveChannel(channel);
|
|
243
|
+
const record = { clientId, channel, params: resolved ? resolved.params : {}, file: resolved ? resolved.file : null };
|
|
244
|
+
if (!resolved) {
|
|
245
|
+
recordsFor(clientId).set(channel, record);
|
|
246
|
+
console.log(`${logPrefix} join("${channel}") admitted (open channel)`);
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const mod = await viteServer.ssrLoadModule(resolved.file.filePath);
|
|
251
|
+
const required = mod.config && mod.config.roles;
|
|
252
|
+
if (Array.isArray(required) && required.length > 0 && !required.some(r => roles.includes(r))) {
|
|
253
|
+
throw channelError('app_channel_role_required', 403);
|
|
254
|
+
}
|
|
255
|
+
if (typeof mod.join === 'function') {
|
|
256
|
+
// admitted only when the handler returned exactly true
|
|
257
|
+
if (await runExport(mod, 'join', record) !== true) throw channelError('app_channel_join_refused', 403);
|
|
258
|
+
}
|
|
259
|
+
recordsFor(clientId).set(channel, record);
|
|
260
|
+
console.log(`${logPrefix} join("${channel}") admitted${typeof mod.join === 'function' ? '' : ' (no join export)'}`);
|
|
261
|
+
return mod;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function unsubscribe({ clientId, channel }) {
|
|
265
|
+
const records = subscriptions.get(clientId);
|
|
266
|
+
const record = records && records.get(channel);
|
|
267
|
+
if (!record) return;
|
|
268
|
+
records.delete(channel);
|
|
269
|
+
if (!record.file) return;
|
|
270
|
+
try {
|
|
271
|
+
const mod = await viteServer.ssrLoadModule(record.file.filePath);
|
|
272
|
+
if (typeof mod.leave === 'function') await runExport(mod, 'leave', record);
|
|
273
|
+
} catch (err) {
|
|
274
|
+
console.warn(`${logPrefix} leave("${channel}") failed: ${err.message}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function send({ clientId, channel, event, payload }) {
|
|
279
|
+
const records = subscriptions.get(clientId);
|
|
280
|
+
const record = records && records.get(channel);
|
|
281
|
+
if (!record) throw channelError('app_channel_not_subscribed', 403);
|
|
282
|
+
if (!isEventName(event)) throw channelError('app_channel_invalid_event', 400);
|
|
283
|
+
if (isReservedEvent(event)) throw channelError('app_channel_reserved_event', 400);
|
|
284
|
+
if (!record.file) throw channelError('app_channel_no_handler', 404);
|
|
285
|
+
const mod = await viteServer.ssrLoadModule(record.file.filePath);
|
|
286
|
+
if (LIFECYCLE_EXPORTS.includes(event) || typeof mod[event] !== 'function') throw channelError('app_channel_no_handler', 404);
|
|
287
|
+
if (!takeSendToken(devUser.username)) throw channelError('app_channel_rate_limited', 429);
|
|
288
|
+
const result = await runExport(mod, event, { ...record, payload });
|
|
289
|
+
console.log(`${logPrefix} send("${channel}", "${event}") handled`);
|
|
290
|
+
return result === undefined ? null : result;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return async function devChannelHandlersMiddleware(req, res, next) {
|
|
294
|
+
const parsed = parseUrl(req.url, true);
|
|
295
|
+
const route = `${req.method} ${parsed.pathname}`;
|
|
296
|
+
let body = {};
|
|
297
|
+
try {
|
|
298
|
+
if (route === 'GET /replay') {
|
|
299
|
+
const { channel, since } = parsed.query;
|
|
300
|
+
if (!isChannelName(channel)) throw channelError('app_channel_invalid_name', 400);
|
|
301
|
+
// the same per-user bucket as send(), spent before anything else, as on the server
|
|
302
|
+
if (!takeSendToken(devUser.username)) throw channelError('app_channel_rate_limited', 429);
|
|
303
|
+
return sendJson(res, 200, channels.replay(channel, Number(since) || 0));
|
|
304
|
+
}
|
|
305
|
+
if (!['POST /subscribe', 'POST /unsubscribe', 'POST /send'].includes(route)) return next();
|
|
306
|
+
|
|
307
|
+
body = await readJson(req);
|
|
308
|
+
if (typeof body.clientId !== 'string' || !body.clientId) throw channelError('app_channel_client_required', 400);
|
|
309
|
+
|
|
310
|
+
if (route === 'POST /subscribe') {
|
|
311
|
+
const mod = await subscribe(body);
|
|
312
|
+
sendJson(res, 200, { ok: true });
|
|
313
|
+
// joined runs once the page has its answer; a failure is the
|
|
314
|
+
// author's to read in the terminal, never the subscribe's.
|
|
315
|
+
if (mod && typeof mod.joined === 'function') {
|
|
316
|
+
const record = recordsFor(body.clientId).get(body.channel);
|
|
317
|
+
setImmediate(() => {
|
|
318
|
+
runExport(mod, 'joined', record).catch(err => console.warn(`${logPrefix} joined("${body.channel}") failed: ${err.message}`));
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (route === 'POST /unsubscribe') {
|
|
324
|
+
await unsubscribe(body);
|
|
325
|
+
return sendJson(res, 200, { ok: true });
|
|
326
|
+
}
|
|
327
|
+
return sendJson(res, 200, { result: await send(body) });
|
|
328
|
+
} catch (err) {
|
|
329
|
+
const status = typeof err.statusCode === 'number' ? err.statusCode : 500;
|
|
330
|
+
if (status === 500) console.error(logPrefix, err);
|
|
331
|
+
else if (route === 'POST /subscribe') console.log(`${logPrefix} join("${body.channel}") refused (${err.code || err.message})`);
|
|
332
|
+
sendJson(res, status, { error: err.code || err.message });
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
}
|