@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.
@@ -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
+ }