@entrinsik/vite-plugin-informer 2.11.0 → 2.13.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.
@@ -1,9 +1,11 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import { join } from 'node:path';
2
+ import { join, relative, sep } from 'node:path';
3
3
  import { parse as parseUrl } from 'node:url';
4
4
  import { createDevBagBuilder, buildDevUser } from './dev-bag.js';
5
+ import { devPlatform } from './dev-platform.js';
5
6
  import { filePathToRoute, matchRoute, walkJsFiles } from './server-routes.js';
6
7
  import { createDevChannels, isChannelName, isWildcardName, isEventName, isReservedEvent, channelError, USER_CHANNEL_PREFIX } from './dev-channels.js';
8
+ import { createDevActors, isActorModule, memberIdFor, ACTOR_LIFECYCLE } from './dev-channel-actors.js';
7
9
 
8
10
  /**
9
11
  * The dev counterpart of app-channel-handlers.js: a page's `channel()` mock
@@ -11,8 +13,9 @@ import { createDevChannels, isChannelName, isWildcardName, isEventName, isReserv
11
13
  * the matching `channels/` file runs in-process the way a deployed handler
12
14
  * runs in the sandbox — `join` decides admission, `joined` runs after it,
13
15
  * `leave` runs on unsubscribe, and an event-named export answers `send()`.
14
- * Frames still ride Vite's websocket to every page; admission is what tells
15
- * the page which frames it may dispatch.
16
+ * A file with `config.actor` runs as a channel actor instead (see
17
+ * dev-channel-actors.js). Frames still ride Vite's websocket to every page;
18
+ * admission is what tells the page which frames it may dispatch.
16
19
  */
17
20
 
18
21
  export const CHANNELS_DIR = 'channels';
@@ -20,8 +23,12 @@ export const CHANNELS_DIR = 'channels';
20
23
  export const CHANNEL_METHOD = 'CHANNEL';
21
24
  // The deployment's joinTimeoutMs cap; a file's `config.timeout` may only lower it.
22
25
  export const HANDLER_TIMEOUT_MS = 5000;
23
- // Per-user inbound send budget (token bucket).
26
+ // Per-user inbound budget (token bucket): send() and the replay reads a reconnect makes.
24
27
  export const SEND_RATE = Object.freeze({ perSecond: 10, burst: 30 });
28
+ // The server's separate, larger send() budget for actor channels (a game's inputs).
29
+ export const ACTOR_SEND_RATE = Object.freeze({ perSecond: 30, burst: 60 });
30
+ // Edits under these directories can change what an actor runs: its file, or what it imports.
31
+ const ACTOR_SOURCE_DIRS = ['channels', 'shared', 'lib', 'server'];
25
32
  // Exports with a fixed meaning; every other export must be an event name.
26
33
  export const LIFECYCLE_EXPORTS = Object.freeze(['config', 'join', 'joined', 'leave']);
27
34
 
@@ -101,11 +108,13 @@ export async function validateChannelHandlers(projectRoot) {
101
108
  return problems;
102
109
  }
103
110
 
104
- // Does a handler route cover some channel strictly under `prefix` segments?
105
- // (`/rooms/:room` and `/rooms/east/members` both cover names under `rooms/`.)
106
- function coversUnder(routePath, prefix) {
111
+ // Does a handler route cover some channel deeper than `minDepth` segments
112
+ // under `prefix`? (`/rooms/:room` and `/rooms/east/members` both cover names
113
+ // under `rooms/`.) `minDepth` is what the wildcard's own handler already gates:
114
+ // the prefix length for an unmatched wildcard, one more for a matched one.
115
+ function coversUnder(routePath, prefix, minDepth) {
107
116
  const segments = routePath.split('/').filter(Boolean);
108
- return segments.length > prefix.length && prefix.every((seg, i) => segments[i].startsWith(':') || segments[i] === seg);
117
+ return segments.length > minDepth && prefix.every((seg, i) => segments[i].startsWith(':') || segments[i] === seg);
109
118
  }
110
119
 
111
120
  function readJson(req) {
@@ -147,13 +156,45 @@ function sendJson(res, status, body) {
147
156
  * @param {() => number} [opts.now] - clock, for the rate limiter
148
157
  * @returns {Function} Connect middleware
149
158
  */
150
- 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 }) {
159
+ 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 }) {
151
160
  const devUser = buildDevUser(user);
152
- const bagBuilder = createDevBagBuilder({ serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings, appToken, channels, logPrefix: '[app]' });
153
- // clientId → channel name → { clientId, channel, params, file }
161
+ // The same merged descriptor the page and the server/ bag see: a channels/
162
+ // handler reading platform.capabilities.embeddings must not disagree with
163
+ // the route beside it, and its embed() must be bound when they agree.
164
+ const bagBuilder = createDevBagBuilder({ serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings, appToken, channels, logPrefix: '[app]', platform, appId });
165
+ // clientId → channel name → { clientId, channel, params, file, actor }
154
166
  const subscriptions = new Map();
155
- // username → { tokens, ts }
167
+ // username (or `actor:` + username) → { tokens, ts }
156
168
  const buckets = new Map();
169
+ const actors = createDevActors({
170
+ viteServer,
171
+ bagFor: async () => (await bagBuilder.build()).bag,
172
+ logPrefix,
173
+ callTimeoutMs: Math.min(timeoutMs, 1000)
174
+ });
175
+ // A Vite restart (a config edit) closes this server and builds another:
176
+ // without this the old server's actors keep their tick loops and module
177
+ // instances running forever, so two config edits leave three copies of the
178
+ // same game ticking.
179
+ if (viteServer.httpServer && typeof viteServer.httpServer.once === 'function') {
180
+ viteServer.httpServer.once('close', () => {
181
+ actors.stopAll().catch(err => console.error(`${logPrefix} stopping actors on shutdown failed:`, err));
182
+ });
183
+ }
184
+ // An edit to a file actors load restarts them on the new code, from their snapshots.
185
+ if (viteServer.watcher && typeof viteServer.watcher.on === 'function') {
186
+ viteServer.watcher.on('change', file => {
187
+ const rel = relative(projectRoot, file);
188
+ if (rel.startsWith('..') || !ACTOR_SOURCE_DIRS.includes(rel.split(sep)[0])) return;
189
+ for (const mod of (viteServer.moduleGraph && viteServer.moduleGraph.getModulesByFile(file)) || []) {
190
+ viteServer.moduleGraph.invalidateModule(mod);
191
+ }
192
+ actors.restartAll().catch(err => console.error(`${logPrefix} restarting actors after an edit failed:`, err));
193
+ });
194
+ }
195
+
196
+ // The `request` a handler sees: the page's member id, and the mocked viewer.
197
+ const requestFor = clientId => ({ member: memberIdFor(clientId), user: { ...devUser }, roles });
157
198
 
158
199
  function recordsFor(clientId) {
159
200
  let records = subscriptions.get(clientId);
@@ -164,14 +205,15 @@ export function createDevChannelHandlers(viteServer, { serverOrigin, authHeader,
164
205
  return records;
165
206
  }
166
207
 
167
- function takeSendToken(username) {
208
+ function takeSendToken(username, budget = rate) {
168
209
  const at = now();
169
- let bucket = buckets.get(username);
210
+ const key = budget === rate ? username : `actor:${username}`;
211
+ let bucket = buckets.get(key);
170
212
  if (!bucket) {
171
- bucket = { tokens: rate.burst, ts: at };
172
- buckets.set(username, bucket);
213
+ bucket = { tokens: budget.burst, ts: at };
214
+ buckets.set(key, bucket);
173
215
  }
174
- bucket.tokens = Math.min(rate.burst, bucket.tokens + (Math.max(0, at - bucket.ts) / 1000) * rate.perSecond);
216
+ bucket.tokens = Math.min(budget.burst, bucket.tokens + (Math.max(0, at - bucket.ts) / 1000) * budget.perSecond);
175
217
  bucket.ts = at;
176
218
  if (bucket.tokens < 1) return false;
177
219
  bucket.tokens -= 1;
@@ -179,9 +221,9 @@ export function createDevChannelHandlers(viteServer, { serverOrigin, authHeader,
179
221
  }
180
222
 
181
223
  // The handler file covering a channel name, or null for an open channel.
182
- // A wildcard resolves with `*` as the param; when no file takes the
183
- // wildcard itself but some file gates a name under its prefix, the
184
- // wildcard is refused rather than letting it listen around the gate.
224
+ // A wildcard resolves with `*` as the param and is admitted only when
225
+ // nothing sits deeper than the file that took it (or, unmatched, deeper
226
+ // than its prefix): a deeper file's gate would otherwise be listened around.
185
227
  async function resolveChannel(channel) {
186
228
  const table = (await scanChannelHandlers(projectRoot)).map(h => ({ ...h, method: CHANNEL_METHOD }));
187
229
  let match;
@@ -191,23 +233,25 @@ export function createDevChannelHandlers(viteServer, { serverOrigin, authHeader,
191
233
  if (err instanceof URIError) throw channelError('app_channel_invalid_name', 400);
192
234
  throw err;
193
235
  }
194
- if (match) return { params: match.params, file: match.route };
195
236
  if (isWildcardName(channel)) {
196
237
  const prefix = channel.slice(0, -2).split('/');
197
- if (table.some(route => coversUnder(route.path, prefix))) throw channelError('app_channel_wildcard_gated', 403);
238
+ // an unmatched wildcard gates nothing; a matched one gates its own
239
+ // depth, and a file deeper than that would go ungated either way
240
+ const gatedTo = match ? prefix.length + 1 : prefix.length;
241
+ if (table.some(route => coversUnder(route.path, prefix, gatedTo))) throw channelError('app_channel_wildcard_gated', 403);
198
242
  }
199
- return null;
243
+ return match ? { params: match.params, file: match.route } : null;
200
244
  }
201
245
 
202
246
  // Run one export with the channel bag under the handler's timeout. A thrown
203
247
  // channel error keeps its status; anything else is 500 app_channel_<export>_failed.
204
- async function runExport(mod, exportName, { channel, params, payload }) {
248
+ async function runExport(mod, exportName, { channel, params, payload, clientId }) {
205
249
  const { bag } = await bagBuilder.build();
206
250
  const invocation = {
207
251
  ...bag,
208
252
  channel: { name: channel, params, broadcast: async (event, body, options) => await bag.broadcast(channel, event, body, options) },
209
253
  payload: payload === undefined ? null : payload,
210
- request: { user: { ...devUser }, roles }
254
+ request: requestFor(clientId)
211
255
  };
212
256
  const cap = Math.min(Number(mod.config && mod.config.timeout) || timeoutMs, timeoutMs);
213
257
  let timer;
@@ -244,12 +288,17 @@ export function createDevChannelHandlers(viteServer, { serverOrigin, authHeader,
244
288
  if (Array.isArray(required) && required.length > 0 && !required.some(r => roles.includes(r))) {
245
289
  throw channelError('app_channel_role_required', 403);
246
290
  }
247
- if (typeof mod.join === 'function') {
291
+ if (isActorModule(mod)) {
292
+ // the channel's actor runs join itself (and starts on the first one)
293
+ if (isWildcardName(channel)) throw channelError('app_channel_actor_wildcard', 403);
294
+ await actors.join({ channel, params: record.params, filePath: resolved.file.filePath, clientId, request: requestFor(clientId) });
295
+ record.actor = true;
296
+ } else if (typeof mod.join === 'function') {
248
297
  // admitted only when the handler returned exactly true
249
298
  if (await runExport(mod, 'join', record) !== true) throw channelError('app_channel_join_refused', 403);
250
299
  }
251
300
  recordsFor(clientId).set(channel, record);
252
- console.log(`${logPrefix} join("${channel}") admitted${typeof mod.join === 'function' ? '' : ' (no join export)'}`);
301
+ console.log(`${logPrefix} join("${channel}") admitted${record.actor ? ' (actor)' : typeof mod.join === 'function' ? '' : ' (no join export)'}`);
253
302
  return mod;
254
303
  }
255
304
 
@@ -259,6 +308,10 @@ export function createDevChannelHandlers(viteServer, { serverOrigin, authHeader,
259
308
  if (!record) return;
260
309
  records.delete(channel);
261
310
  if (!record.file) return;
311
+ if (record.actor) {
312
+ await actors.leave({ channel, clientId }).catch(err => console.warn(`${logPrefix} leave("${channel}") failed: ${err.message}`));
313
+ return;
314
+ }
262
315
  try {
263
316
  const mod = await viteServer.ssrLoadModule(record.file.filePath);
264
317
  if (typeof mod.leave === 'function') await runExport(mod, 'leave', record);
@@ -274,6 +327,12 @@ export function createDevChannelHandlers(viteServer, { serverOrigin, authHeader,
274
327
  if (!isEventName(event)) throw channelError('app_channel_invalid_event', 400);
275
328
  if (isReservedEvent(event)) throw channelError('app_channel_reserved_event', 400);
276
329
  if (!record.file) throw channelError('app_channel_no_handler', 404);
330
+ if (record.actor) {
331
+ if (LIFECYCLE_EXPORTS.includes(event) || ACTOR_LIFECYCLE.includes(event)) throw channelError('app_channel_no_handler', 404);
332
+ if (!takeSendToken(devUser.username, ACTOR_SEND_RATE)) throw channelError('app_channel_rate_limited', 429);
333
+ const result = await actors.send({ channel, params: record.params, filePath: record.file.filePath, clientId, request: requestFor(clientId), event, payload });
334
+ return result === undefined ? null : result;
335
+ }
277
336
  const mod = await viteServer.ssrLoadModule(record.file.filePath);
278
337
  if (LIFECYCLE_EXPORTS.includes(event) || typeof mod[event] !== 'function') throw channelError('app_channel_no_handler', 404);
279
338
  if (!takeSendToken(devUser.username)) throw channelError('app_channel_rate_limited', 429);
@@ -282,7 +341,7 @@ export function createDevChannelHandlers(viteServer, { serverOrigin, authHeader,
282
341
  return result === undefined ? null : result;
283
342
  }
284
343
 
285
- return async function devChannelHandlersMiddleware(req, res, next) {
344
+ async function devChannelHandlersMiddleware(req, res, next) {
286
345
  const parsed = parseUrl(req.url, true);
287
346
  const route = `${req.method} ${parsed.pathname}`;
288
347
  let body = {};
@@ -290,6 +349,8 @@ export function createDevChannelHandlers(viteServer, { serverOrigin, authHeader,
290
349
  if (route === 'GET /replay') {
291
350
  const { channel, since } = parsed.query;
292
351
  if (!isChannelName(channel)) throw channelError('app_channel_invalid_name', 400);
352
+ // the same per-user bucket as send(), spent before anything else, as on the server
353
+ if (!takeSendToken(devUser.username)) throw channelError('app_channel_rate_limited', 429);
293
354
  return sendJson(res, 200, channels.replay(channel, Number(since) || 0));
294
355
  }
295
356
  if (!['POST /subscribe', 'POST /unsubscribe', 'POST /send'].includes(route)) return next();
@@ -305,7 +366,10 @@ export function createDevChannelHandlers(viteServer, { serverOrigin, authHeader,
305
366
  if (mod && typeof mod.joined === 'function') {
306
367
  const record = recordsFor(body.clientId).get(body.channel);
307
368
  setImmediate(() => {
308
- runExport(mod, 'joined', record).catch(err => console.warn(`${logPrefix} joined("${body.channel}") failed: ${err.message}`));
369
+ const run = record.actor
370
+ ? actors.joined({ channel: body.channel, clientId: body.clientId, request: requestFor(body.clientId) })
371
+ : runExport(mod, 'joined', record);
372
+ run.catch(err => console.warn(`${logPrefix} joined("${body.channel}") failed: ${err.message}`));
309
373
  });
310
374
  }
311
375
  return;
@@ -314,12 +378,16 @@ export function createDevChannelHandlers(viteServer, { serverOrigin, authHeader,
314
378
  await unsubscribe(body);
315
379
  return sendJson(res, 200, { ok: true });
316
380
  }
317
- return sendJson(res, 200, { result: await send(body) });
381
+ // `now`, as the server stamps it: the page's serverNow() reads it
382
+ return sendJson(res, 200, { result: await send(body), now: Date.now() });
318
383
  } catch (err) {
319
384
  const status = typeof err.statusCode === 'number' ? err.statusCode : 500;
320
385
  if (status === 500) console.error(logPrefix, err);
321
386
  else if (route === 'POST /subscribe') console.log(`${logPrefix} join("${body.channel}") refused (${err.code || err.message})`);
322
387
  sendJson(res, status, { error: err.code || err.message });
323
388
  }
324
- };
389
+ }
390
+ // the running actors, for tests and the terminal
391
+ devChannelHandlersMiddleware.actors = actors;
392
+ return devChannelHandlersMiddleware;
325
393
  }
@@ -8,8 +8,8 @@ import { CHANNEL_NAME, CHANNEL_NAME_MAX_LENGTH, EVENT_NAME, EVENT_NAME_MAX_LENGT
8
8
  * var ch = __INFORMER__.channel('rooms/east', { since: 12 }); // sync; throws join_refused on a bad name
9
9
  * ch.on('created', function (payload, frame) {}); // first handler subscribes
10
10
  * ch.on('connected', function (info) {}); // { replayed: n } after each (re)subscribe
11
- * ch.on('error', function (err) {}); // err.code: join_refused | rate_limited | disconnected | replay_gap
12
- * ch.send(event, payload).then(result); // rejects: send_refused | rate_limited | disconnected
11
+ * ch.on('error', function (err) {}); // err.code: join_refused | rate_limited | budget_exhausted | handler_failed | disconnected | replay_gap
12
+ * ch.send(event, payload).then(result); // rejects: send_refused | rate_limited | budget_exhausted | handler_failed | disconnected
13
13
  * ch.close();
14
14
  *
15
15
  * Transport: production subscribes each channel over a nes socket; dev POSTs
@@ -75,10 +75,14 @@ export function generateDevChannelShim() {
75
75
  }
76
76
 
77
77
  // A refused subscribe maps the server's status the way nes errors do.
78
+ // A handler that threw is its own code: 'disconnected' would make a
79
+ // typo in a join handler read exactly like a dropped network.
78
80
  function mapStatus(status) {
79
81
  if (status === 403) return 'join_refused';
80
82
  if (status === 429) return 'rate_limited';
83
+ if (status === 402) return 'budget_exhausted';
81
84
  if (status >= 400 && status < 500) return 'join_refused';
85
+ if (status >= 500) return 'handler_failed';
82
86
  return 'disconnected';
83
87
  }
84
88
 
@@ -130,16 +134,43 @@ export function generateDevChannelShim() {
130
134
  hot.on('vite:ws:connect', subscribeAll);
131
135
  }
132
136
 
137
+ // A page that goes away (closed, reloaded, navigated off) leaves its
138
+ // channels, as its socket closing does on the server, or an actor
139
+ // would keep it as a member and never go idle. One the browser keeps
140
+ // and brings back (the back/forward cache) joins them again.
141
+ if (hot && typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
142
+ window.addEventListener('pagehide', function () {
143
+ open.forEach(function (ch) {
144
+ if (!ch._admitted) return;
145
+ ch._admitted = false;
146
+ ch._resume = true;
147
+ try {
148
+ fetch(API + '/unsubscribe', {
149
+ method: 'POST', keepalive: true, credentials: 'same-origin',
150
+ headers: { 'Content-Type': 'application/json' },
151
+ body: JSON.stringify({ clientId: clientId, channel: ch.name })
152
+ }).catch(function () {});
153
+ } catch (e) { /* the page is going regardless */ }
154
+ });
155
+ });
156
+ window.addEventListener('pageshow', function (e) {
157
+ if (e.persisted) subscribeAll();
158
+ });
159
+ }
160
+
133
161
  function Channel(name, opts) {
134
162
  this.name = name;
135
- this._handlers = {};
163
+ // null-prototype: an event name admits 'constructor' and 'toString',
164
+ // which resolve through Object.prototype on a plain object
165
+ this._handlers = Object.create(null);
136
166
  this._closed = false;
137
167
  this._unavailable = false;
138
168
  this._admitted = false;
139
169
  this._subscribing = null; // in-flight subscribe (never rejects)
140
170
  this._replaying = false; // live frames queue behind an in-flight replay
141
171
  this._pending = [];
142
- this._lastSeq = {}; // concrete channel → last seq delivered
172
+ this._lastSeq = Object.create(null); // concrete channel → highest seq seen
173
+ this._replayedThrough = Object.create(null); // → how far a replay caught it up
143
174
  this._since = (opts && typeof opts.since === 'number') ? opts.since : null;
144
175
  this._resume = this._since !== null; // replay on the next subscribe
145
176
  }
@@ -153,12 +184,15 @@ export function generateDevChannelShim() {
153
184
  });
154
185
  };
155
186
 
156
- // A frame at or below the last seq delivered for its channel is a
157
- // duplicate. Returns whether it was dispatched.
187
+ // Only a frame the replay already handed over is a duplicate. Seq is
188
+ // allocated atomically but published separately, so on a real server two
189
+ // concurrent broadcasts can arrive out of seq order; deduping against
190
+ // the running max would drop the lower one for good.
191
+ // Returns whether it was dispatched.
158
192
  Channel.prototype._accept = function (frame) {
159
193
  if (typeof frame.seq === 'number') {
160
- if (frame.seq <= (this._lastSeq[frame.channel] || 0)) return false;
161
- this._lastSeq[frame.channel] = frame.seq;
194
+ if (frame.seq <= (this._replayedThrough[frame.channel] || 0)) return false;
195
+ if (frame.seq > (this._lastSeq[frame.channel] || 0)) this._lastSeq[frame.channel] = frame.seq;
162
196
  }
163
197
  this._dispatch(frame.event, frame.payload, frame);
164
198
  return true;
@@ -229,15 +263,29 @@ export function generateDevChannelShim() {
229
263
  var current = typeof data.current === 'number' ? data.current : 0;
230
264
  var oldest = typeof data.oldest === 'number' ? data.oldest : null;
231
265
  if (current < last) {
232
- // the channel's counter restarted (idle channel): nothing seen applies
266
+ // the counter restarted (an idle channel's key expired,
267
+ // or redis was flushed): nothing seen applies
233
268
  delete self._lastSeq[concrete];
269
+ delete self._replayedThrough[concrete];
234
270
  last = 0;
235
271
  } else if (current > last && (oldest === null || oldest > last + 1)) {
236
272
  self._emitError(channelError('replay_gap', 'Frames on "' + concrete + '" after seq ' + last + ' are no longer buffered', concrete));
237
273
  }
274
+ // the mark queued live frames dedupe against: what the
275
+ // page already held, raised as the replay hands frames
276
+ // over. Set before the loop, so a frame at or below
277
+ // that seq is dropped rather than dispatched twice.
278
+ self._replayedThrough[concrete] = last;
238
279
  (data.frames || []).forEach(function (frame) {
239
- if (!self._closed && frame && frame.channel === concrete && self._accept(frame)) delivered++;
280
+ if (self._closed || !frame || frame.channel !== concrete) return;
281
+ if (self._accept(frame)) delivered++;
282
+ if (typeof frame.seq === 'number' && frame.seq > self._replayedThrough[concrete]) self._replayedThrough[concrete] = frame.seq;
240
283
  });
284
+ }).catch(function (err) {
285
+ // a read refused for one channel (over the inbound rate,
286
+ // say) is reported on that channel and must not throw
287
+ // away the others, as on the server
288
+ self._emitError(err && err.code ? err : channelError('disconnected', String((err && err.message) || err), concrete));
241
289
  });
242
290
  });
243
291
  }, Promise.resolve()).then(function () {
@@ -307,7 +355,9 @@ export function generateDevChannelShim() {
307
355
  if (res.ok) return res.body ? res.body.result : null;
308
356
  var message = (res.body && res.body.error) || ('Send refused (' + res.status + ')');
309
357
  if (res.status === 429) throw channelError('rate_limited', message, self.name);
358
+ if (res.status === 402) throw channelError('budget_exhausted', message, self.name);
310
359
  if (res.status >= 400 && res.status < 500) throw channelError('send_refused', message, self.name);
360
+ if (res.status >= 500) throw channelError('handler_failed', message, self.name);
311
361
  throw channelError('disconnected', message, self.name);
312
362
  });
313
363
  };
@@ -317,7 +367,7 @@ export function generateDevChannelShim() {
317
367
  this._closed = true;
318
368
  var i = open.indexOf(this);
319
369
  if (i !== -1) open.splice(i, 1);
320
- this._handlers = {};
370
+ this._handlers = Object.create(null);
321
371
  var pending = this._subscribing;
322
372
  var subscribed = this._admitted || pending;
323
373
  this._admitted = false;
@@ -329,6 +379,10 @@ export function generateDevChannelShim() {
329
379
  }).catch(function () {});
330
380
  };
331
381
 
382
+ // the page and the dev server share this machine's clock
383
+ informer.serverNow = function () { return Date.now(); };
384
+ Channel.prototype.serverNow = function () { return Date.now(); };
385
+
332
386
  informer.channel = function (name, opts) {
333
387
  if (typeof name !== 'string' || name.length > CHANNEL_NAME_MAX_LENGTH || !CHANNEL_NAME.test(name)) {
334
388
  throw channelError('join_refused', 'Invalid channel name: ' + name);
@@ -18,9 +18,10 @@ import { EventEmitter } from 'node:events';
18
18
  // plus the subscribe-side wildcard `rooms/*` (an ordinary name whose LAST
19
19
  // segment is `*`; `*` alone is not a name). Everything after `@user/` is one
20
20
  // username, verbatim — the server compares the whole remainder to the
21
- // socket's own — so `@user/*` is refused rather than read as a wildcard.
21
+ // socket's own — so `@user/*` is a valid name for a user called `*`, refused
22
+ // on subscribe as someone else's channel rather than read as a wildcard.
22
23
  export const USER_CHANNEL_PREFIX = '@user/';
23
- export const CHANNEL_NAME = /^(@user\/(?!\*$)\S+|[\w.-]+(\/[\w.-]+)*(\/\*)?)$/;
24
+ export const CHANNEL_NAME = /^(@user\/\S+|[\w.-]+(\/[\w.-]+)*(\/\*)?)$/;
24
25
  export const CHANNEL_NAME_MAX_LENGTH = 128;
25
26
  // `created`, `order_created`
26
27
  export const EVENT_NAME = /^[\w.-]+$/;
@@ -31,8 +32,10 @@ export const EVENT_NAME_MAX_LENGTH = 64;
31
32
  export const RESERVED_EVENTS = Object.freeze(['error', 'connected']);
32
33
  // config-factory.js app.channels.maxFrameBytes default
33
34
  export const MAX_FRAME_BYTES = 65536;
34
- // Frames kept per channel for `replay(channel, since)`.
35
+ // Frames kept per channel for `replay(channel, since)`, and for how long after
36
+ // the channel's last write (config-factory.js app.channels.replay defaults).
35
37
  export const REPLAY_FRAMES = 50;
38
+ export const REPLAY_TTL_MS = 60000;
36
39
  // deploy.js CHANNELS_SCHEMA description cap
37
40
  const DESCRIPTION_MAX_LENGTH = 500;
38
41
 
@@ -59,9 +62,9 @@ export function isChannelName(name) {
59
62
  return typeof name === 'string' && name.length <= CHANNEL_NAME_MAX_LENGTH && CHANNEL_NAME.test(name);
60
63
  }
61
64
 
62
- /** `rooms/*`: a subscribe-only name covering every channel one level under `rooms/`. */
65
+ /** `rooms/*`: a subscribe-only name covering every channel under `rooms/`. Any trailing `/*` counts, as on the server (`@user/*` too, so nothing can broadcast to it). */
63
66
  export function isWildcardName(name) {
64
- return isChannelName(name) && !name.startsWith(USER_CHANNEL_PREFIX) && name.endsWith('/*');
67
+ return typeof name === 'string' && name.endsWith('/*');
65
68
  }
66
69
 
67
70
  export function isEventName(name) {
@@ -156,23 +159,32 @@ export function validateChannels(block) {
156
159
  * @param {string} [opts.tenant] - frame tenant (the plugin knows none; defaults to 'dev')
157
160
  * @param {string} [opts.appId] - the dev app id (the mocked `report.id`)
158
161
  * @param {string} [opts.logPrefix] - console prefix
162
+ * @param {Function} [opts.now] - clock in ms (tests)
159
163
  * @returns {{ emitter: EventEmitter, broadcast: Function, relay: Function, replay: Function, tenant: string, appId: string }}
160
164
  */
161
- export function createDevChannels({ tenant = DEV_TENANT, appId = 'dev-local', logPrefix = '[app-channel]' } = {}) {
165
+ export function createDevChannels({ tenant = DEV_TENANT, appId = 'dev-local', logPrefix = '[app-channel]', now = Date.now } = {}) {
162
166
  const emitter = new EventEmitter();
163
- // channel name → { seq, frames }: the monotonic counter and the last
164
- // REPLAY_FRAMES frames, oldest first.
167
+ // channel name → { seq, frames, writtenAt }: the monotonic counter and the
168
+ // last REPLAY_FRAMES frames, oldest first, with the time of the last write.
165
169
  const buffers = new Map();
166
170
 
167
171
  function bufferFor(channel) {
168
172
  let buffer = buffers.get(channel);
169
173
  if (!buffer) {
170
- buffer = { seq: 0, frames: [] };
174
+ buffer = { seq: 0, frames: [], writtenAt: 0 };
171
175
  buffers.set(channel, buffer);
172
176
  }
173
177
  return buffer;
174
178
  }
175
179
 
180
+ // The server's PEXPIRE on the buffer key: the whole buffer goes
181
+ // REPLAY_TTL_MS after its last write, while the seq counter stays (it has
182
+ // its own, far longer life).
183
+ function liveFrames(buffer) {
184
+ if (buffer.frames.length && now() - buffer.writtenAt >= REPLAY_TTL_MS) buffer.frames = [];
185
+ return buffer.frames;
186
+ }
187
+
176
188
  // Validate and publish one §1.1 frame. Synchronous so the manifest relay
177
189
  // can run inside a synchronous emit(); throws the server's error codes.
178
190
  // `replay: false` keeps the frame out of the channel's replay buffer (it
@@ -196,10 +208,13 @@ export function createDevChannels({ tenant = DEV_TENANT, appId = 'dev-local', lo
196
208
 
197
209
  const buffer = bufferFor(channel);
198
210
  buffer.seq += 1;
199
- const frame = { tenant, appId, channel, event, payload: message, seq: buffer.seq, at: Date.now() };
211
+ const at = now();
212
+ const frame = { tenant, appId, channel, event, payload: message, seq: buffer.seq, at };
200
213
  if (replay !== false) {
201
- buffer.frames.push(frame);
202
- if (buffer.frames.length > REPLAY_FRAMES) buffer.frames.shift();
214
+ const frames = liveFrames(buffer);
215
+ frames.push(frame);
216
+ if (frames.length > REPLAY_FRAMES) frames.shift();
217
+ buffer.writtenAt = at;
203
218
  }
204
219
  emitter.emit(BROADCAST_EVENT, frame);
205
220
  return frame;
@@ -241,7 +256,9 @@ export function createDevChannels({ tenant = DEV_TENANT, appId = 'dev-local', lo
241
256
  * first, with the smallest seq still buffered (null when nothing is) and
242
257
  * the channel's current seq (0 before its first frame), so the page can
243
258
  * tell a gap from a quiet channel. Dev counters never restart, so
244
- * `current >= since` for any seq the page has seen.
259
+ * `current >= since` for any seq the page has seen. An expired buffer
260
+ * answers no frames and no oldest with the counter intact, which a page
261
+ * that saw an earlier seq reads as a gap.
245
262
  *
246
263
  * @param {string} channel
247
264
  * @param {number} [since] - the last seq the page has seen
@@ -250,10 +267,11 @@ export function createDevChannels({ tenant = DEV_TENANT, appId = 'dev-local', lo
250
267
  function replay(channel, since = 0) {
251
268
  const buffer = buffers.get(channel);
252
269
  if (!buffer) return { frames: [], oldest: null, current: 0 };
270
+ const frames = liveFrames(buffer);
253
271
  const from = Number(since) || 0;
254
272
  return {
255
- frames: buffer.frames.filter(f => f.seq > from),
256
- oldest: buffer.frames.length ? buffer.frames[0].seq : null,
273
+ frames: frames.filter(f => f.seq > from),
274
+ oldest: frames.length ? frames[0].seq : null,
257
275
  current: buffer.seq
258
276
  };
259
277
  }