@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.
@@ -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 | 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
+ * 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,153 @@ 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
+ // A handler that threw is its own code: 'disconnected' would make a
79
+ // typo in a join handler read exactly like a dropped network.
80
+ function mapStatus(status) {
81
+ if (status === 403) return 'join_refused';
82
+ if (status === 429) return 'rate_limited';
83
+ if (status === 402) return 'budget_exhausted';
84
+ if (status >= 400 && status < 500) return 'join_refused';
85
+ if (status >= 500) return 'handler_failed';
86
+ return 'disconnected';
87
+ }
88
+
89
+ function call(method, path, body) {
90
+ var opts = { method: method, credentials: 'same-origin', headers: {} };
91
+ if (body !== undefined) {
92
+ opts.headers['Content-Type'] = 'application/json';
93
+ opts.body = JSON.stringify(body);
94
+ }
95
+ return fetch(API + path, opts).then(function (res) {
96
+ return res.text().then(function (text) {
97
+ var data = null;
98
+ try { data = text ? JSON.parse(text) : null; } catch (e) { data = null; }
99
+ return { ok: res.ok, status: res.status, body: data };
100
+ });
101
+ }, function (e) {
102
+ throw channelError('disconnected', 'The dev server could not be reached: ' + ((e && e.message) || e));
103
+ });
104
+ }
105
+
106
+ function refusal(res, channel, fallback) {
107
+ return channelError(mapStatus(res.status), (res.body && res.body.error) || (fallback + ' (' + res.status + ')'), channel);
108
+ }
109
+
39
110
  function deliver(frame) {
40
111
  if (!frame || typeof frame.channel !== 'string') return;
41
112
  open.slice().forEach(function (ch) {
42
- if (ch.name === frame.channel) ch._deliver(frame);
113
+ if (covers(ch, frame.channel)) ch._deliver(frame);
43
114
  });
44
115
  }
45
116
 
117
+ // A dropped socket: every open channel reports it and re-subscribes on
118
+ // reconnect, replaying from the last seq it saw.
46
119
  function dropAll() {
47
120
  open.slice().forEach(function (ch) {
48
- ch._emitError(channelError('disconnected', 'The dev server connection was lost'));
121
+ ch._admitted = false;
122
+ ch._resume = true;
123
+ ch._emitError(channelError('disconnected', 'The dev server connection was lost', ch.name));
49
124
  });
50
125
  }
51
126
 
127
+ function subscribeAll() {
128
+ open.slice().forEach(function (ch) { ch._subscribe(); });
129
+ }
130
+
52
131
  if (hot) {
53
132
  hot.on(FRAME_EVENT, deliver);
54
133
  hot.on('vite:ws:disconnect', dropAll);
134
+ hot.on('vite:ws:connect', subscribeAll);
55
135
  }
56
136
 
57
- function Channel(name) {
137
+ function Channel(name, opts) {
58
138
  this.name = name;
59
- this._handlers = {};
139
+ // null-prototype: an event name admits 'constructor' and 'toString',
140
+ // which resolve through Object.prototype on a plain object
141
+ this._handlers = Object.create(null);
60
142
  this._closed = false;
61
143
  this._unavailable = false;
144
+ this._admitted = false;
145
+ this._subscribing = null; // in-flight subscribe (never rejects)
146
+ this._replaying = false; // live frames queue behind an in-flight replay
147
+ this._pending = [];
148
+ this._lastSeq = Object.create(null); // concrete channel → highest seq seen
149
+ this._replayedThrough = Object.create(null); // → how far a replay caught it up
150
+ this._since = (opts && typeof opts.since === 'number') ? opts.since : null;
151
+ this._resume = this._since !== null; // replay on the next subscribe
62
152
  }
63
153
 
64
- Channel.prototype._deliver = function (frame) {
65
- if (this._closed) return;
66
- var list = this._handlers[frame.event];
154
+ Channel.prototype._dispatch = function (event, payload, frame) {
155
+ var list = this._handlers[event];
67
156
  if (!list) return;
68
157
  list.slice().forEach(function (fn) {
69
- try { fn(frame.payload, frame); }
158
+ try { fn(payload, frame); }
70
159
  catch (e) { console.error('[Informer] channel handler failed:', e); }
71
160
  });
72
161
  };
73
162
 
163
+ // Only a frame the replay already handed over is a duplicate. Seq is
164
+ // allocated atomically but published separately, so on a real server two
165
+ // concurrent broadcasts can arrive out of seq order; deduping against
166
+ // the running max would drop the lower one for good.
167
+ // Returns whether it was dispatched.
168
+ Channel.prototype._accept = function (frame) {
169
+ if (typeof frame.seq === 'number') {
170
+ if (frame.seq <= (this._replayedThrough[frame.channel] || 0)) return false;
171
+ if (frame.seq > (this._lastSeq[frame.channel] || 0)) this._lastSeq[frame.channel] = frame.seq;
172
+ }
173
+ this._dispatch(frame.event, frame.payload, frame);
174
+ return true;
175
+ };
176
+
177
+ // A live frame in: only once admitted, and behind any in-flight replay.
178
+ Channel.prototype._deliver = function (frame) {
179
+ if (this._closed || !this._admitted) return false;
180
+ if (this._replaying) { this._pending.push(frame); return false; }
181
+ return this._accept(frame);
182
+ };
183
+
74
184
  Channel.prototype._emitError = function (err) {
75
185
  if (this._closed) return;
76
186
  var list = this._handlers.error;
@@ -84,6 +194,89 @@ export function generateDevChannelShim() {
84
194
  });
85
195
  };
86
196
 
197
+ Channel.prototype._subscribe = function () {
198
+ var self = this;
199
+ if (!hot || this._closed || this._admitted || this._subscribing) return;
200
+ this._subscribing = call('POST', '/subscribe', { clientId: clientId, channel: this.name }).then(function (res) {
201
+ if (!res.ok) throw refusal(res, self.name, 'Subscribe refused');
202
+ if (self._closed) return 0;
203
+ self._admitted = true;
204
+ return self._replay();
205
+ }).then(function (replayed) {
206
+ self._subscribing = null;
207
+ if (self._closed || !self._admitted) return;
208
+ self._dispatch('connected', { replayed: replayed || 0 });
209
+ }, function (err) {
210
+ self._subscribing = null;
211
+ self._emitError(err);
212
+ });
213
+ };
214
+
215
+ // Catch up from the dev server's buffer: the concrete channels this
216
+ // handle has seen frames from (or itself, from \`since\` on a first
217
+ // subscribe). Live frames arriving meanwhile queue behind the replay so
218
+ // the seq dedupe sees the buffered frames first. Resolves the count delivered.
219
+ Channel.prototype._replay = function () {
220
+ var self = this;
221
+ var since = this._since;
222
+ this._since = null;
223
+ var targets = [];
224
+ if (this._resume) {
225
+ if (isWildcard(this.name)) targets = Object.keys(this._lastSeq);
226
+ else if (since !== null || this._lastSeq[this.name] !== undefined) targets = [this.name];
227
+ }
228
+ this._resume = false;
229
+ if (!targets.length) return Promise.resolve(0);
230
+ var delivered = 0;
231
+ this._replaying = true;
232
+ return targets.reduce(function (chain, concrete) {
233
+ return chain.then(function () {
234
+ var last = self._lastSeq[concrete] || 0;
235
+ if (since !== null && since > last) last = since;
236
+ return call('GET', '/replay?channel=' + encodeURIComponent(concrete) + '&since=' + last).then(function (res) {
237
+ if (!res.ok) throw refusal(res, concrete, 'Replay refused');
238
+ var data = res.body || {};
239
+ var current = typeof data.current === 'number' ? data.current : 0;
240
+ var oldest = typeof data.oldest === 'number' ? data.oldest : null;
241
+ if (current < last) {
242
+ // the counter restarted (an idle channel's key expired,
243
+ // or redis was flushed): nothing seen applies
244
+ delete self._lastSeq[concrete];
245
+ delete self._replayedThrough[concrete];
246
+ last = 0;
247
+ } else if (current > last && (oldest === null || oldest > last + 1)) {
248
+ self._emitError(channelError('replay_gap', 'Frames on "' + concrete + '" after seq ' + last + ' are no longer buffered', concrete));
249
+ }
250
+ // the mark queued live frames dedupe against: what the
251
+ // page already held, raised as the replay hands frames
252
+ // over. Set before the loop, so a frame at or below
253
+ // that seq is dropped rather than dispatched twice.
254
+ self._replayedThrough[concrete] = last;
255
+ (data.frames || []).forEach(function (frame) {
256
+ if (self._closed || !frame || frame.channel !== concrete) return;
257
+ if (self._accept(frame)) delivered++;
258
+ if (typeof frame.seq === 'number' && frame.seq > self._replayedThrough[concrete]) self._replayedThrough[concrete] = frame.seq;
259
+ });
260
+ }).catch(function (err) {
261
+ // a read refused for one channel (over the inbound rate,
262
+ // say) is reported on that channel and must not throw
263
+ // away the others, as on the server
264
+ self._emitError(err && err.code ? err : channelError('disconnected', String((err && err.message) || err), concrete));
265
+ });
266
+ });
267
+ }, Promise.resolve()).then(function () {
268
+ self._replaying = false;
269
+ var queued = self._pending;
270
+ self._pending = [];
271
+ queued.forEach(function (frame) { self._deliver(frame); });
272
+ return delivered;
273
+ }, function (err) {
274
+ self._replaying = false;
275
+ self._pending = [];
276
+ throw err;
277
+ });
278
+ };
279
+
87
280
  Channel.prototype.on = function (event, fn) {
88
281
  if (this._closed) throw channelError('disconnected', 'The channel is closed');
89
282
  if (typeof event !== 'string' || !event) throw new TypeError('channel.on: event name required');
@@ -91,14 +284,29 @@ export function generateDevChannelShim() {
91
284
  var list = this._handlers[event] || (this._handlers[event] = []);
92
285
  list.push(fn);
93
286
  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
- });
287
+ var self = this;
288
+ if (!hot) {
289
+ if (!this._unavailable) {
290
+ // Not served by the Vite dev server: no frames can ever arrive.
291
+ // Reported once, asynchronously, like a failed connection.
292
+ this._unavailable = true;
293
+ Promise.resolve().then(function () {
294
+ self._emitError(channelError('disconnected', unavailableReason || 'The Vite dev websocket is not available', self.name));
295
+ });
296
+ }
297
+ } else if (this._admitted) {
298
+ // A late listener still learns the channel is up.
299
+ if (event === 'connected') {
300
+ Promise.resolve().then(function () {
301
+ if (self._closed || list.indexOf(fn) === -1) return;
302
+ try { fn({ replayed: 0 }); }
303
+ catch (e) { console.error('[Informer] channel handler failed:', e); }
304
+ });
305
+ }
306
+ } else {
307
+ // A use: after a dropped socket, bring every open channel back
308
+ // before this one registers its own handlers.
309
+ subscribeAll();
102
310
  }
103
311
  return function () {
104
312
  var i = list.indexOf(fn);
@@ -106,8 +314,28 @@ export function generateDevChannelShim() {
106
314
  };
107
315
  };
108
316
 
109
- Channel.prototype.send = function () {
110
- return Promise.reject(channelError('not_supported', 'Sending on a channel is not supported yet'));
317
+ Channel.prototype.send = function (event, payload) {
318
+ var self = this;
319
+ if (this._closed) return Promise.reject(channelError('disconnected', 'The channel is closed', this.name));
320
+ if (typeof event !== 'string' || !event || event.length > EVENT_NAME_MAX_LENGTH || !EVENT_NAME.test(event) || RESERVED_EVENTS.indexOf(event) !== -1) {
321
+ return Promise.reject(channelError('send_refused', 'Invalid event name: ' + event, this.name));
322
+ }
323
+ if (isWildcard(this.name)) {
324
+ return Promise.reject(channelError('send_refused', 'Cannot send on a wildcard channel; send to a concrete channel instead', this.name));
325
+ }
326
+ if (!hot) return Promise.reject(channelError('disconnected', unavailableReason || 'The Vite dev websocket is not available', this.name));
327
+ // a send right after the first on(): let the subscribe land first
328
+ return (this._subscribing || Promise.resolve()).then(function () {
329
+ return call('POST', '/send', { clientId: clientId, channel: self.name, event: event, payload: payload === undefined ? null : payload });
330
+ }).then(function (res) {
331
+ if (res.ok) return res.body ? res.body.result : null;
332
+ var message = (res.body && res.body.error) || ('Send refused (' + res.status + ')');
333
+ if (res.status === 429) throw channelError('rate_limited', message, self.name);
334
+ if (res.status === 402) throw channelError('budget_exhausted', message, self.name);
335
+ if (res.status >= 400 && res.status < 500) throw channelError('send_refused', message, self.name);
336
+ if (res.status >= 500) throw channelError('handler_failed', message, self.name);
337
+ throw channelError('disconnected', message, self.name);
338
+ });
111
339
  };
112
340
 
113
341
  Channel.prototype.close = function () {
@@ -115,14 +343,23 @@ export function generateDevChannelShim() {
115
343
  this._closed = true;
116
344
  var i = open.indexOf(this);
117
345
  if (i !== -1) open.splice(i, 1);
118
- this._handlers = {};
346
+ this._handlers = Object.create(null);
347
+ var pending = this._subscribing;
348
+ var subscribed = this._admitted || pending;
349
+ this._admitted = false;
350
+ if (!hot || !subscribed) return;
351
+ var self = this;
352
+ // after an in-flight subscribe lands, so the server forgets what it just recorded
353
+ (pending || Promise.resolve()).then(function () {
354
+ return call('POST', '/unsubscribe', { clientId: clientId, channel: self.name });
355
+ }).catch(function () {});
119
356
  };
120
357
 
121
- informer.channel = function (name) {
358
+ informer.channel = function (name, opts) {
122
359
  if (typeof name !== 'string' || name.length > CHANNEL_NAME_MAX_LENGTH || !CHANNEL_NAME.test(name)) {
123
360
  throw channelError('join_refused', 'Invalid channel name: ' + name);
124
361
  }
125
- return new Channel(name);
362
+ return new Channel(name, opts);
126
363
  };
127
364
  }`;
128
365
  }