@entrinsik/vite-plugin-informer 2.10.0 → 2.11.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,25 +1,32 @@
1
- import { CHANNEL_NAME, CHANNEL_NAME_MAX_LENGTH, DEV_CHANNEL_EVENT } from './dev-channels.js';
1
+ import { CHANNEL_NAME, CHANNEL_NAME_MAX_LENGTH, EVENT_NAME, EVENT_NAME_MAX_LENGTH, RESERVED_EVENTS, USER_CHANNEL_PREFIX, DEV_CHANNEL_EVENT, DEV_CHANNEL_API } from './dev-channels.js';
2
2
 
3
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.
4
+ * The dev `__INFORMER__.channel(name, opts)` mock: the App Channels client
5
+ * surface (App API v2 §1.9) as the origin-mode shim in html-utils.js
6
+ * generateChannelShim implements it
10
7
  *
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.
8
+ * var ch = __INFORMER__.channel('rooms/east', { since: 12 }); // sync; throws join_refused on a bad name
9
+ * ch.on('created', function (payload, frame) {}); // first handler subscribes
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
13
+ * ch.close();
14
+ *
15
+ * Transport: production subscribes each channel over a nes socket; dev POSTs
16
+ * subscribe / unsubscribe / send to DEV_CHANNEL_API, where the matching
17
+ * channels/ handler runs (dev-channel-handlers.js). Frames ride Vite's own dev
18
+ * websocket as the custom event DEV_CHANNEL_EVENT and reach every page; a
19
+ * channel dispatches only once its subscribe was admitted, matching frames by
20
+ * exact name or, for a `rooms/*` wildcard, by the frame's wildcard parents.
21
+ * Every frame carries a per-channel `seq`; after a dropped socket the shim
22
+ * re-subscribes on `vite:ws:connect` (or the next `on()`), replays from the
23
+ * last seq it saw via GET /replay, drops what it already delivered, and
24
+ * reports `replay_gap` when the buffer no longer reaches back that far.
19
25
  *
20
26
  * Returned as source (an `installDevChannels(hot, informer)` function
21
27
  * 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.
28
+ * context and a fake fetch, the same way the server spec drives the
29
+ * production shim.
23
30
  */
24
31
  export function generateDevChannelShim() {
25
32
  return `
@@ -27,50 +34,143 @@ export function generateDevChannelShim() {
27
34
  function installDevChannels(hot, informer, unavailableReason) {
28
35
  var CHANNEL_NAME = /${CHANNEL_NAME.source}/;
29
36
  var CHANNEL_NAME_MAX_LENGTH = ${CHANNEL_NAME_MAX_LENGTH};
37
+ var EVENT_NAME = /${EVENT_NAME.source}/;
38
+ var EVENT_NAME_MAX_LENGTH = ${EVENT_NAME_MAX_LENGTH};
39
+ var RESERVED_EVENTS = ${JSON.stringify(RESERVED_EVENTS)};
40
+ var USER_PREFIX = '${USER_CHANNEL_PREFIX}';
30
41
  var FRAME_EVENT = '${DEV_CHANNEL_EVENT}';
31
- var open = []; // channels holding at least one handler
42
+ var API = '${DEV_CHANNEL_API}';
43
+ var clientId = newClientId(); // identifies this page to the dev server
44
+ var open = []; // channels holding at least one handler
45
+
46
+ function newClientId() {
47
+ var c = typeof crypto !== 'undefined' ? crypto : null;
48
+ if (c && typeof c.randomUUID === 'function') return c.randomUUID();
49
+ return Math.random().toString(36).slice(2) + Date.now().toString(36);
50
+ }
32
51
 
33
- function channelError(code, message) {
52
+ function channelError(code, message, channel) {
34
53
  var e = new Error(message);
35
54
  e.code = code;
55
+ if (channel) e.channel = channel;
36
56
  return e;
37
57
  }
38
58
 
59
+ function isWildcard(name) {
60
+ return name.indexOf(USER_PREFIX) !== 0 && name.slice(-2) === '/*';
61
+ }
62
+
63
+ // The wildcard names a frame on \`channel\` also belongs to: a/b/c → a/b/*, a/*.
64
+ function wildcardParents(channel) {
65
+ if (channel.indexOf(USER_PREFIX) === 0) return [];
66
+ var segments = channel.split('/');
67
+ var parents = [];
68
+ for (var i = segments.length - 1; i >= 1; i--) parents.push(segments.slice(0, i).join('/') + '/*');
69
+ return parents;
70
+ }
71
+
72
+ function covers(ch, frameChannel) {
73
+ if (ch.name === frameChannel) return true;
74
+ return isWildcard(ch.name) && wildcardParents(frameChannel).indexOf(ch.name) !== -1;
75
+ }
76
+
77
+ // A refused subscribe maps the server's status the way nes errors do.
78
+ function mapStatus(status) {
79
+ if (status === 403) return 'join_refused';
80
+ if (status === 429) return 'rate_limited';
81
+ if (status >= 400 && status < 500) return 'join_refused';
82
+ return 'disconnected';
83
+ }
84
+
85
+ function call(method, path, body) {
86
+ var opts = { method: method, credentials: 'same-origin', headers: {} };
87
+ if (body !== undefined) {
88
+ opts.headers['Content-Type'] = 'application/json';
89
+ opts.body = JSON.stringify(body);
90
+ }
91
+ return fetch(API + path, opts).then(function (res) {
92
+ return res.text().then(function (text) {
93
+ var data = null;
94
+ try { data = text ? JSON.parse(text) : null; } catch (e) { data = null; }
95
+ return { ok: res.ok, status: res.status, body: data };
96
+ });
97
+ }, function (e) {
98
+ throw channelError('disconnected', 'The dev server could not be reached: ' + ((e && e.message) || e));
99
+ });
100
+ }
101
+
102
+ function refusal(res, channel, fallback) {
103
+ return channelError(mapStatus(res.status), (res.body && res.body.error) || (fallback + ' (' + res.status + ')'), channel);
104
+ }
105
+
39
106
  function deliver(frame) {
40
107
  if (!frame || typeof frame.channel !== 'string') return;
41
108
  open.slice().forEach(function (ch) {
42
- if (ch.name === frame.channel) ch._deliver(frame);
109
+ if (covers(ch, frame.channel)) ch._deliver(frame);
43
110
  });
44
111
  }
45
112
 
113
+ // A dropped socket: every open channel reports it and re-subscribes on
114
+ // reconnect, replaying from the last seq it saw.
46
115
  function dropAll() {
47
116
  open.slice().forEach(function (ch) {
48
- ch._emitError(channelError('disconnected', 'The dev server connection was lost'));
117
+ ch._admitted = false;
118
+ ch._resume = true;
119
+ ch._emitError(channelError('disconnected', 'The dev server connection was lost', ch.name));
49
120
  });
50
121
  }
51
122
 
123
+ function subscribeAll() {
124
+ open.slice().forEach(function (ch) { ch._subscribe(); });
125
+ }
126
+
52
127
  if (hot) {
53
128
  hot.on(FRAME_EVENT, deliver);
54
129
  hot.on('vite:ws:disconnect', dropAll);
130
+ hot.on('vite:ws:connect', subscribeAll);
55
131
  }
56
132
 
57
- function Channel(name) {
133
+ function Channel(name, opts) {
58
134
  this.name = name;
59
135
  this._handlers = {};
60
136
  this._closed = false;
61
137
  this._unavailable = false;
138
+ this._admitted = false;
139
+ this._subscribing = null; // in-flight subscribe (never rejects)
140
+ this._replaying = false; // live frames queue behind an in-flight replay
141
+ this._pending = [];
142
+ this._lastSeq = {}; // concrete channel → last seq delivered
143
+ this._since = (opts && typeof opts.since === 'number') ? opts.since : null;
144
+ this._resume = this._since !== null; // replay on the next subscribe
62
145
  }
63
146
 
64
- Channel.prototype._deliver = function (frame) {
65
- if (this._closed) return;
66
- var list = this._handlers[frame.event];
147
+ Channel.prototype._dispatch = function (event, payload, frame) {
148
+ var list = this._handlers[event];
67
149
  if (!list) return;
68
150
  list.slice().forEach(function (fn) {
69
- try { fn(frame.payload, frame); }
151
+ try { fn(payload, frame); }
70
152
  catch (e) { console.error('[Informer] channel handler failed:', e); }
71
153
  });
72
154
  };
73
155
 
156
+ // A frame at or below the last seq delivered for its channel is a
157
+ // duplicate. Returns whether it was dispatched.
158
+ Channel.prototype._accept = function (frame) {
159
+ if (typeof frame.seq === 'number') {
160
+ if (frame.seq <= (this._lastSeq[frame.channel] || 0)) return false;
161
+ this._lastSeq[frame.channel] = frame.seq;
162
+ }
163
+ this._dispatch(frame.event, frame.payload, frame);
164
+ return true;
165
+ };
166
+
167
+ // A live frame in: only once admitted, and behind any in-flight replay.
168
+ Channel.prototype._deliver = function (frame) {
169
+ if (this._closed || !this._admitted) return false;
170
+ if (this._replaying) { this._pending.push(frame); return false; }
171
+ return this._accept(frame);
172
+ };
173
+
74
174
  Channel.prototype._emitError = function (err) {
75
175
  if (this._closed) return;
76
176
  var list = this._handlers.error;
@@ -84,6 +184,75 @@ export function generateDevChannelShim() {
84
184
  });
85
185
  };
86
186
 
187
+ Channel.prototype._subscribe = function () {
188
+ var self = this;
189
+ if (!hot || this._closed || this._admitted || this._subscribing) return;
190
+ this._subscribing = call('POST', '/subscribe', { clientId: clientId, channel: this.name }).then(function (res) {
191
+ if (!res.ok) throw refusal(res, self.name, 'Subscribe refused');
192
+ if (self._closed) return 0;
193
+ self._admitted = true;
194
+ return self._replay();
195
+ }).then(function (replayed) {
196
+ self._subscribing = null;
197
+ if (self._closed || !self._admitted) return;
198
+ self._dispatch('connected', { replayed: replayed || 0 });
199
+ }, function (err) {
200
+ self._subscribing = null;
201
+ self._emitError(err);
202
+ });
203
+ };
204
+
205
+ // Catch up from the dev server's buffer: the concrete channels this
206
+ // handle has seen frames from (or itself, from \`since\` on a first
207
+ // subscribe). Live frames arriving meanwhile queue behind the replay so
208
+ // the seq dedupe sees the buffered frames first. Resolves the count delivered.
209
+ Channel.prototype._replay = function () {
210
+ var self = this;
211
+ var since = this._since;
212
+ this._since = null;
213
+ var targets = [];
214
+ if (this._resume) {
215
+ if (isWildcard(this.name)) targets = Object.keys(this._lastSeq);
216
+ else if (since !== null || this._lastSeq[this.name] !== undefined) targets = [this.name];
217
+ }
218
+ this._resume = false;
219
+ if (!targets.length) return Promise.resolve(0);
220
+ var delivered = 0;
221
+ this._replaying = true;
222
+ return targets.reduce(function (chain, concrete) {
223
+ return chain.then(function () {
224
+ var last = self._lastSeq[concrete] || 0;
225
+ if (since !== null && since > last) last = since;
226
+ return call('GET', '/replay?channel=' + encodeURIComponent(concrete) + '&since=' + last).then(function (res) {
227
+ if (!res.ok) throw refusal(res, concrete, 'Replay refused');
228
+ var data = res.body || {};
229
+ var current = typeof data.current === 'number' ? data.current : 0;
230
+ var oldest = typeof data.oldest === 'number' ? data.oldest : null;
231
+ if (current < last) {
232
+ // the channel's counter restarted (idle channel): nothing seen applies
233
+ delete self._lastSeq[concrete];
234
+ last = 0;
235
+ } else if (current > last && (oldest === null || oldest > last + 1)) {
236
+ self._emitError(channelError('replay_gap', 'Frames on "' + concrete + '" after seq ' + last + ' are no longer buffered', concrete));
237
+ }
238
+ (data.frames || []).forEach(function (frame) {
239
+ if (!self._closed && frame && frame.channel === concrete && self._accept(frame)) delivered++;
240
+ });
241
+ });
242
+ });
243
+ }, Promise.resolve()).then(function () {
244
+ self._replaying = false;
245
+ var queued = self._pending;
246
+ self._pending = [];
247
+ queued.forEach(function (frame) { self._deliver(frame); });
248
+ return delivered;
249
+ }, function (err) {
250
+ self._replaying = false;
251
+ self._pending = [];
252
+ throw err;
253
+ });
254
+ };
255
+
87
256
  Channel.prototype.on = function (event, fn) {
88
257
  if (this._closed) throw channelError('disconnected', 'The channel is closed');
89
258
  if (typeof event !== 'string' || !event) throw new TypeError('channel.on: event name required');
@@ -91,14 +260,29 @@ export function generateDevChannelShim() {
91
260
  var list = this._handlers[event] || (this._handlers[event] = []);
92
261
  list.push(fn);
93
262
  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
- });
263
+ var self = this;
264
+ if (!hot) {
265
+ if (!this._unavailable) {
266
+ // Not served by the Vite dev server: no frames can ever arrive.
267
+ // Reported once, asynchronously, like a failed connection.
268
+ this._unavailable = true;
269
+ Promise.resolve().then(function () {
270
+ self._emitError(channelError('disconnected', unavailableReason || 'The Vite dev websocket is not available', self.name));
271
+ });
272
+ }
273
+ } else if (this._admitted) {
274
+ // A late listener still learns the channel is up.
275
+ if (event === 'connected') {
276
+ Promise.resolve().then(function () {
277
+ if (self._closed || list.indexOf(fn) === -1) return;
278
+ try { fn({ replayed: 0 }); }
279
+ catch (e) { console.error('[Informer] channel handler failed:', e); }
280
+ });
281
+ }
282
+ } else {
283
+ // A use: after a dropped socket, bring every open channel back
284
+ // before this one registers its own handlers.
285
+ subscribeAll();
102
286
  }
103
287
  return function () {
104
288
  var i = list.indexOf(fn);
@@ -106,8 +290,26 @@ export function generateDevChannelShim() {
106
290
  };
107
291
  };
108
292
 
109
- Channel.prototype.send = function () {
110
- return Promise.reject(channelError('not_supported', 'Sending on a channel is not supported yet'));
293
+ Channel.prototype.send = function (event, payload) {
294
+ var self = this;
295
+ if (this._closed) return Promise.reject(channelError('disconnected', 'The channel is closed', this.name));
296
+ if (typeof event !== 'string' || !event || event.length > EVENT_NAME_MAX_LENGTH || !EVENT_NAME.test(event) || RESERVED_EVENTS.indexOf(event) !== -1) {
297
+ return Promise.reject(channelError('send_refused', 'Invalid event name: ' + event, this.name));
298
+ }
299
+ if (isWildcard(this.name)) {
300
+ return Promise.reject(channelError('send_refused', 'Cannot send on a wildcard channel; send to a concrete channel instead', this.name));
301
+ }
302
+ if (!hot) return Promise.reject(channelError('disconnected', unavailableReason || 'The Vite dev websocket is not available', this.name));
303
+ // a send right after the first on(): let the subscribe land first
304
+ return (this._subscribing || Promise.resolve()).then(function () {
305
+ return call('POST', '/send', { clientId: clientId, channel: self.name, event: event, payload: payload === undefined ? null : payload });
306
+ }).then(function (res) {
307
+ if (res.ok) return res.body ? res.body.result : null;
308
+ var message = (res.body && res.body.error) || ('Send refused (' + res.status + ')');
309
+ if (res.status === 429) throw channelError('rate_limited', message, self.name);
310
+ if (res.status >= 400 && res.status < 500) throw channelError('send_refused', message, self.name);
311
+ throw channelError('disconnected', message, self.name);
312
+ });
111
313
  };
112
314
 
113
315
  Channel.prototype.close = function () {
@@ -116,13 +318,22 @@ export function generateDevChannelShim() {
116
318
  var i = open.indexOf(this);
117
319
  if (i !== -1) open.splice(i, 1);
118
320
  this._handlers = {};
321
+ var pending = this._subscribing;
322
+ var subscribed = this._admitted || pending;
323
+ this._admitted = false;
324
+ if (!hot || !subscribed) return;
325
+ var self = this;
326
+ // after an in-flight subscribe lands, so the server forgets what it just recorded
327
+ (pending || Promise.resolve()).then(function () {
328
+ return call('POST', '/unsubscribe', { clientId: clientId, channel: self.name });
329
+ }).catch(function () {});
119
330
  };
120
331
 
121
- informer.channel = function (name) {
332
+ informer.channel = function (name, opts) {
122
333
  if (typeof name !== 'string' || name.length > CHANNEL_NAME_MAX_LENGTH || !CHANNEL_NAME.test(name)) {
123
334
  throw channelError('join_refused', 'Invalid channel name: ' + name);
124
335
  }
125
- return new Channel(name);
336
+ return new Channel(name, opts);
126
337
  };
127
338
  }`;
128
339
  }
@@ -2,8 +2,9 @@ import { EventEmitter } from 'node:events';
2
2
 
3
3
  /**
4
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.
5
+ * in-process handlers, the `channels:` manifest relay that mirrors
6
+ * `emitAppEvent` on the server, and the per-channel replay buffer the page
7
+ * catches up from after a dropped dev websocket.
7
8
  *
8
9
  * A broadcast validates exactly like the server's `broadcastAppMessage`
9
10
  * (app-channel-broadcast.js) — same regexes, limits and error messages — and
@@ -13,21 +14,33 @@ import { EventEmitter } from 'node:events';
13
14
  * server-cluster concerns and have no dev counterpart.
14
15
  */
15
16
 
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.-]+)*)$/;
17
+ // Mirrors of app-channel-broadcast.js: `orders`, `orders/east`, `@user/brad`,
18
+ // plus the subscribe-side wildcard `rooms/*` (an ordinary name whose LAST
19
+ // segment is `*`; `*` alone is not a name). Everything after `@user/` is one
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.
22
+ export const USER_CHANNEL_PREFIX = '@user/';
23
+ export const CHANNEL_NAME = /^(@user\/(?!\*$)\S+|[\w.-]+(\/[\w.-]+)*(\/\*)?)$/;
20
24
  export const CHANNEL_NAME_MAX_LENGTH = 128;
21
25
  // `created`, `order_created`
22
26
  export const EVENT_NAME = /^[\w.-]+$/;
23
27
  export const EVENT_NAME_MAX_LENGTH = 64;
28
+ // Event names the client owns: `connected` is dispatched after a subscribe is
29
+ // admitted, `error` carries refusals and drops. A handler cannot broadcast,
30
+ // send or export either.
31
+ export const RESERVED_EVENTS = Object.freeze(['error', 'connected']);
24
32
  // config-factory.js app.channels.maxFrameBytes default
25
33
  export const MAX_FRAME_BYTES = 65536;
34
+ // Frames kept per channel for `replay(channel, since)`.
35
+ export const REPLAY_FRAMES = 50;
26
36
  // deploy.js CHANNELS_SCHEMA description cap
27
37
  const DESCRIPTION_MAX_LENGTH = 500;
28
38
 
29
39
  // The Vite custom event a frame rides to the page (`hot.on(DEV_CHANNEL_EVENT, ...)`).
30
40
  export const DEV_CHANNEL_EVENT = 'informer:channel';
41
+ // Where the page's subscribe / unsubscribe / send / replay calls land on the
42
+ // dev server (see dev-channel-handlers.js).
43
+ export const DEV_CHANNEL_API = '/_dev/channels';
31
44
  // The plugin has no tenant identity; frames carry this until it does.
32
45
  export const DEV_TENANT = 'dev';
33
46
 
@@ -46,23 +59,53 @@ export function isChannelName(name) {
46
59
  return typeof name === 'string' && name.length <= CHANNEL_NAME_MAX_LENGTH && CHANNEL_NAME.test(name);
47
60
  }
48
61
 
62
+ /** `rooms/*`: a subscribe-only name covering every channel one level under `rooms/`. */
63
+ export function isWildcardName(name) {
64
+ return isChannelName(name) && !name.startsWith(USER_CHANNEL_PREFIX) && name.endsWith('/*');
65
+ }
66
+
49
67
  export function isEventName(name) {
50
68
  return typeof name === 'string' && name.length <= EVENT_NAME_MAX_LENGTH && EVENT_NAME.test(name);
51
69
  }
52
70
 
71
+ export function isReservedEvent(name) {
72
+ return RESERVED_EVENTS.includes(name);
73
+ }
74
+
75
+ /**
76
+ * The wildcard names a frame on `channel` is also delivered to, nearest first:
77
+ * `a/b/c` → `['a/b/*', 'a/*']`. A one-segment name has none, and a `@user/`
78
+ * channel never matches a wildcard.
79
+ *
80
+ * @param {string} channel
81
+ * @returns {string[]}
82
+ */
83
+ export function wildcardParents(channel) {
84
+ if (typeof channel !== 'string' || channel.startsWith(USER_CHANNEL_PREFIX)) return [];
85
+ const segments = channel.split('/');
86
+ const parents = [];
87
+ for (let i = segments.length - 1; i >= 1; i--) {
88
+ parents.push(`${segments.slice(0, i).join('/')}/*`);
89
+ }
90
+ return parents;
91
+ }
92
+
53
93
  // Shaped like the boom error the server throws: message === code, plus the
54
94
  // HTTP status it would carry, so a handler can branch on either in dev.
55
- function channelError(code, statusCode) {
95
+ export function channelError(code, statusCode) {
56
96
  const err = new Error(code);
57
97
  err.code = code;
58
98
  err.statusCode = statusCode;
59
99
  return err;
60
100
  }
61
101
 
102
+ const onRequired = (name) => `channels.${name}: "on" is required — list the events to relay; a channel with nothing to relay needs no declaration`;
103
+
62
104
  /**
63
105
  * Validate the shape of a parsed `channels:` block. Returns human-readable
64
106
  * error strings (empty when valid). Mirrors deploy.js CHANNELS_SCHEMA so an
65
- * author sees at boot what the deploy would 400 on.
107
+ * author sees at boot what the deploy would 400 on: the block declares
108
+ * relays only, so every entry needs a non-empty `on`.
66
109
  *
67
110
  * @param {*} block - The raw `channels:` value
68
111
  * @returns {string[]} Error messages, one per problem
@@ -71,16 +114,19 @@ export function validateChannels(block) {
71
114
  const errors = [];
72
115
  if (block === undefined || block === null) return errors;
73
116
  if (typeof block !== 'object' || Array.isArray(block)) {
74
- return ['channels: must be a map of channel name → { description?, on? }'];
117
+ return ['channels: must be a map of channel name → { on, description? }'];
75
118
  }
76
119
  for (const [name, def] of Object.entries(block)) {
77
- if (!isChannelName(name)) {
120
+ if (!isChannelName(name) || isWildcardName(name)) {
78
121
  errors.push(`channels: invalid channel name "${name}" (use segments of letters, digits, _ . -, joined by /, max ${CHANNEL_NAME_MAX_LENGTH} chars)`);
79
122
  continue;
80
123
  }
81
- if (def === null || def === undefined) continue;
124
+ if (def === null || def === undefined) {
125
+ errors.push(onRequired(name));
126
+ continue;
127
+ }
82
128
  if (typeof def !== 'object' || Array.isArray(def)) {
83
- errors.push(`channels.${name}: must be a map with optional "description" and "on"`);
129
+ errors.push(`channels.${name}: must be a map with "on" and an optional "description"`);
84
130
  continue;
85
131
  }
86
132
  for (const key of Object.keys(def)) {
@@ -89,12 +135,14 @@ export function validateChannels(block) {
89
135
  if (def.description !== undefined && (typeof def.description !== 'string' || def.description.length > DESCRIPTION_MAX_LENGTH)) {
90
136
  errors.push(`channels.${name}.description: must be a string of at most ${DESCRIPTION_MAX_LENGTH} chars`);
91
137
  }
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
- }
138
+ const events = def.on === undefined || def.on === null ? [] : [].concat(def.on);
139
+ if (events.length === 0) {
140
+ errors.push(onRequired(name));
141
+ continue;
142
+ }
143
+ for (const event of events) {
144
+ if (!isEventName(event)) {
145
+ errors.push(`channels.${name}.on: invalid event name ${JSON.stringify(event)} (letters, digits, _ . -, max ${EVENT_NAME_MAX_LENGTH} chars)`);
98
146
  }
99
147
  }
100
148
  }
@@ -108,16 +156,31 @@ export function validateChannels(block) {
108
156
  * @param {string} [opts.tenant] - frame tenant (the plugin knows none; defaults to 'dev')
109
157
  * @param {string} [opts.appId] - the dev app id (the mocked `report.id`)
110
158
  * @param {string} [opts.logPrefix] - console prefix
111
- * @returns {{ emitter: EventEmitter, broadcast: Function, relay: Function, tenant: string, appId: string }}
159
+ * @returns {{ emitter: EventEmitter, broadcast: Function, relay: Function, replay: Function, tenant: string, appId: string }}
112
160
  */
113
161
  export function createDevChannels({ tenant = DEV_TENANT, appId = 'dev-local', logPrefix = '[app-channel]' } = {}) {
114
162
  const emitter = new EventEmitter();
163
+ // channel name → { seq, frames }: the monotonic counter and the last
164
+ // REPLAY_FRAMES frames, oldest first.
165
+ const buffers = new Map();
166
+
167
+ function bufferFor(channel) {
168
+ let buffer = buffers.get(channel);
169
+ if (!buffer) {
170
+ buffer = { seq: 0, frames: [] };
171
+ buffers.set(channel, buffer);
172
+ }
173
+ return buffer;
174
+ }
115
175
 
116
176
  // Validate and publish one §1.1 frame. Synchronous so the manifest relay
117
177
  // 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);
178
+ // `replay: false` keeps the frame out of the channel's replay buffer (it
179
+ // still takes a seq), as broadcastAppMessage does in production.
180
+ function publish(channel, event, payload, { replay } = {}) {
181
+ if (!isChannelName(channel) || isWildcardName(channel)) throw channelError('app_channel_invalid_name', 400);
120
182
  if (!isEventName(event)) throw channelError('app_channel_invalid_event', 400);
183
+ if (isReservedEvent(event)) throw channelError('app_channel_reserved_event', 400);
121
184
 
122
185
  const message = payload === undefined ? null : payload;
123
186
  let serialized;
@@ -131,17 +194,24 @@ export function createDevChannels({ tenant = DEV_TENANT, appId = 'dev-local', lo
131
194
  if (serialized === undefined) throw channelError('app_channel_invalid_payload', 400);
132
195
  if (Buffer.byteLength(serialized) > MAX_FRAME_BYTES) throw channelError('app_channel_frame_too_large', 413);
133
196
 
134
- const frame = { tenant, appId, channel, event, payload: message, at: Date.now() };
197
+ const buffer = bufferFor(channel);
198
+ buffer.seq += 1;
199
+ const frame = { tenant, appId, channel, event, payload: message, seq: buffer.seq, at: Date.now() };
200
+ if (replay !== false) {
201
+ buffer.frames.push(frame);
202
+ if (buffer.frames.length > REPLAY_FRAMES) buffer.frames.shift();
203
+ }
135
204
  emitter.emit(BROADCAST_EVENT, frame);
136
205
  return frame;
137
206
  }
138
207
 
139
208
  // 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);
209
+ // rejects rather than throws, exactly as it does in production; resolves
210
+ // `{ ok, seq }` like the server, and honors `options.replay`.
211
+ async function broadcast(channel, event, payload, options) {
212
+ const frame = publish(channel, event, payload, options && typeof options === 'object' ? options : {});
143
213
  console.log(`${logPrefix} broadcast("${channel}", "${event}", ${previewPayload(frame.payload)})`);
144
- return { ok: true };
214
+ return { ok: true, seq: frame.seq };
145
215
  }
146
216
 
147
217
  /**
@@ -166,7 +236,29 @@ export function createDevChannels({ tenant = DEV_TENANT, appId = 'dev-local', lo
166
236
  }
167
237
  }
168
238
 
169
- return { emitter, broadcast, relay, tenant, appId };
239
+ /**
240
+ * The buffered frames of one channel published after `since`, oldest
241
+ * first, with the smallest seq still buffered (null when nothing is) and
242
+ * the channel's current seq (0 before its first frame), so the page can
243
+ * tell a gap from a quiet channel. Dev counters never restart, so
244
+ * `current >= since` for any seq the page has seen.
245
+ *
246
+ * @param {string} channel
247
+ * @param {number} [since] - the last seq the page has seen
248
+ * @returns {{ frames: Object[], oldest: number|null, current: number }}
249
+ */
250
+ function replay(channel, since = 0) {
251
+ const buffer = buffers.get(channel);
252
+ if (!buffer) return { frames: [], oldest: null, current: 0 };
253
+ const from = Number(since) || 0;
254
+ return {
255
+ frames: buffer.frames.filter(f => f.seq > from),
256
+ oldest: buffer.frames.length ? buffer.frames[0].seq : null,
257
+ current: buffer.seq
258
+ };
259
+ }
260
+
261
+ return { emitter, broadcast, relay, replay, tenant, appId };
170
262
  }
171
263
 
172
264
  /**
@@ -8,6 +8,8 @@
8
8
  * feature-detects on it sees locally exactly what it sees on an install
9
9
  * without the feature. `version` is `'dev'` (not semver) so a floor check
10
10
  * treats the dev mirror as "unknown" rather than as any particular release.
11
+ * `originMode` is on: the dev server behaves like an app served from its own
12
+ * origin, where live channels work.
11
13
  *
12
14
  * Override any of it per project with `informer({ mock: { platform: {…} } })`.
13
15
  */
@@ -35,6 +37,7 @@ export function devPlatform(overrides = {}) {
35
37
  const { capabilities = {}, ...rest } = overrides || {};
36
38
  return {
37
39
  version: 'dev',
40
+ originMode: true,
38
41
  ...rest,
39
42
  capabilities: { ...DEV_CAPABILITIES, ...capabilities }
40
43
  };
package/src/env.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import dotenv from 'dotenv';
2
- import { existsSync } from 'node:fs';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
3
  import { resolve, dirname, parse as parsePath } from 'node:path';
4
4
 
5
5
  /**
@@ -71,6 +71,22 @@ export function envWritePath({ mode, cwd } = {}) {
71
71
  return resolve(dir, useMode ? `.env.${mode}` : '.env');
72
72
  }
73
73
 
74
+ /**
75
+ * A variable as defined in the app's OWN env file (the one envWritePath
76
+ * names), ignoring the shell and any parent .env loadEnv walked up to. Null
77
+ * when the file is missing or leaves the variable unset or empty.
78
+ *
79
+ * @param {string} name
80
+ * @param {{ mode?: string, cwd?: string }} options
81
+ * @returns {string|null}
82
+ */
83
+ export function localEnvValue(name, { mode, cwd } = {}) {
84
+ const path = envWritePath({ mode, cwd });
85
+ if (!existsSync(path)) return null;
86
+ const value = dotenv.parse(readFileSync(path, 'utf8'))[name];
87
+ return value ? value : null;
88
+ }
89
+
74
90
  /**
75
91
  * Parse --mode <name> from a process.argv array.
76
92
  *