@entrinsik/vite-plugin-informer 2.7.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.
- package/README.md +131 -0
- package/bin/ci.js +0 -0
- package/bin/workspace.js +15 -7
- package/package.json +1 -1
- package/src/agent-dev.js +26 -12
- package/src/assemble.js +5 -1
- package/src/compat.js +252 -0
- package/src/deploy.js +116 -43
- package/src/dev-bag.js +185 -0
- package/src/dev-channel-handlers.js +325 -0
- package/src/dev-channel-shim.js +361 -0
- package/src/dev-channels.js +283 -0
- package/src/dev-dependencies.js +44 -17
- package/src/dev-platform.js +44 -0
- package/src/dev-streams.js +660 -0
- package/src/env.js +17 -1
- package/src/index.js +156 -16
- package/src/server-routes.js +121 -196
- package/src/streams-client.js +200 -0
|
@@ -0,0 +1,361 @@
|
|
|
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
|
+
|
|
3
|
+
/**
|
|
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 —
|
|
7
|
+
*
|
|
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.
|
|
25
|
+
*
|
|
26
|
+
* Returned as source (an `installDevChannels(hot, informer)` function
|
|
27
|
+
* declaration) so it can be evaluated in a test `vm` against a fake hot
|
|
28
|
+
* context and a fake fetch, the same way the server spec drives the
|
|
29
|
+
* production shim.
|
|
30
|
+
*/
|
|
31
|
+
export function generateDevChannelShim() {
|
|
32
|
+
return `
|
|
33
|
+
// --- App Channels (dev): frames arrive on Vite's dev websocket ---
|
|
34
|
+
function installDevChannels(hot, informer, unavailableReason) {
|
|
35
|
+
var CHANNEL_NAME = /${CHANNEL_NAME.source}/;
|
|
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}';
|
|
41
|
+
var FRAME_EVENT = '${DEV_CHANNEL_EVENT}';
|
|
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
|
+
}
|
|
51
|
+
|
|
52
|
+
function channelError(code, message, channel) {
|
|
53
|
+
var e = new Error(message);
|
|
54
|
+
e.code = code;
|
|
55
|
+
if (channel) e.channel = channel;
|
|
56
|
+
return e;
|
|
57
|
+
}
|
|
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
|
+
|
|
106
|
+
function deliver(frame) {
|
|
107
|
+
if (!frame || typeof frame.channel !== 'string') return;
|
|
108
|
+
open.slice().forEach(function (ch) {
|
|
109
|
+
if (covers(ch, frame.channel)) ch._deliver(frame);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// A dropped socket: every open channel reports it and re-subscribes on
|
|
114
|
+
// reconnect, replaying from the last seq it saw.
|
|
115
|
+
function dropAll() {
|
|
116
|
+
open.slice().forEach(function (ch) {
|
|
117
|
+
ch._admitted = false;
|
|
118
|
+
ch._resume = true;
|
|
119
|
+
ch._emitError(channelError('disconnected', 'The dev server connection was lost', ch.name));
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function subscribeAll() {
|
|
124
|
+
open.slice().forEach(function (ch) { ch._subscribe(); });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (hot) {
|
|
128
|
+
hot.on(FRAME_EVENT, deliver);
|
|
129
|
+
hot.on('vite:ws:disconnect', dropAll);
|
|
130
|
+
hot.on('vite:ws:connect', subscribeAll);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function Channel(name, opts) {
|
|
134
|
+
this.name = name;
|
|
135
|
+
this._handlers = {};
|
|
136
|
+
this._closed = false;
|
|
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
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
Channel.prototype._dispatch = function (event, payload, frame) {
|
|
148
|
+
var list = this._handlers[event];
|
|
149
|
+
if (!list) return;
|
|
150
|
+
list.slice().forEach(function (fn) {
|
|
151
|
+
try { fn(payload, frame); }
|
|
152
|
+
catch (e) { console.error('[Informer] channel handler failed:', e); }
|
|
153
|
+
});
|
|
154
|
+
};
|
|
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
|
+
|
|
174
|
+
Channel.prototype._emitError = function (err) {
|
|
175
|
+
if (this._closed) return;
|
|
176
|
+
var list = this._handlers.error;
|
|
177
|
+
if (!list || !list.length) {
|
|
178
|
+
console.warn('[Informer] channel "' + this.name + '" ' + err.code + ': ' + err.message);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
list.slice().forEach(function (fn) {
|
|
182
|
+
try { fn(err); }
|
|
183
|
+
catch (e) { console.error('[Informer] channel error handler failed:', e); }
|
|
184
|
+
});
|
|
185
|
+
};
|
|
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
|
+
|
|
256
|
+
Channel.prototype.on = function (event, fn) {
|
|
257
|
+
if (this._closed) throw channelError('disconnected', 'The channel is closed');
|
|
258
|
+
if (typeof event !== 'string' || !event) throw new TypeError('channel.on: event name required');
|
|
259
|
+
if (typeof fn !== 'function') throw new TypeError('channel.on: handler must be a function');
|
|
260
|
+
var list = this._handlers[event] || (this._handlers[event] = []);
|
|
261
|
+
list.push(fn);
|
|
262
|
+
if (open.indexOf(this) === -1) open.push(this);
|
|
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();
|
|
286
|
+
}
|
|
287
|
+
return function () {
|
|
288
|
+
var i = list.indexOf(fn);
|
|
289
|
+
if (i !== -1) list.splice(i, 1);
|
|
290
|
+
};
|
|
291
|
+
};
|
|
292
|
+
|
|
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
|
+
});
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
Channel.prototype.close = function () {
|
|
316
|
+
if (this._closed) return;
|
|
317
|
+
this._closed = true;
|
|
318
|
+
var i = open.indexOf(this);
|
|
319
|
+
if (i !== -1) open.splice(i, 1);
|
|
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 () {});
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
informer.channel = function (name, opts) {
|
|
333
|
+
if (typeof name !== 'string' || name.length > CHANNEL_NAME_MAX_LENGTH || !CHANNEL_NAME.test(name)) {
|
|
334
|
+
throw channelError('join_refused', 'Invalid channel name: ' + name);
|
|
335
|
+
}
|
|
336
|
+
return new Channel(name, opts);
|
|
337
|
+
};
|
|
338
|
+
}`;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The `<script type="module">` tag that installs the mock on the dev page.
|
|
343
|
+
* Vite turns an inline module script into an `?html-proxy` module, which is
|
|
344
|
+
* what gives it a live `import.meta.hot`. Module scripts run in document
|
|
345
|
+
* order after parsing, so placed at the top of <head> this installs
|
|
346
|
+
* `channel()` before the app's own module executes.
|
|
347
|
+
*/
|
|
348
|
+
export function renderDevChannelScript({ hub = true } = {}) {
|
|
349
|
+
// Without INFORMER_URL, configureServer returns before building the channel
|
|
350
|
+
// hub, so nothing can ever push a frame — but import.meta.hot is still live,
|
|
351
|
+
// which would leave the shim silent and the author staring at a channel that
|
|
352
|
+
// simply never delivers. Passing no `hot` routes it through the same
|
|
353
|
+
// report-once diagnostic, with the reason that actually applies.
|
|
354
|
+
const install = hub
|
|
355
|
+
? 'installDevChannels(import.meta.hot, window.__INFORMER__);'
|
|
356
|
+
: `installDevChannels(null, window.__INFORMER__, 'Dev channels need INFORMER_URL set for the dev server to relay frames');`;
|
|
357
|
+
return `<script type="module">
|
|
358
|
+
${generateDevChannelShim()}
|
|
359
|
+
${install}
|
|
360
|
+
</script>`;
|
|
361
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* App Channels in dev: the `broadcast(channel, event, payload)` verb for
|
|
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.
|
|
8
|
+
*
|
|
9
|
+
* A broadcast validates exactly like the server's `broadcastAppMessage`
|
|
10
|
+
* (app-channel-broadcast.js) — same regexes, limits and error messages — and
|
|
11
|
+
* publishes one frame on a plugin-local emitter. The plugin forwards every
|
|
12
|
+
* frame to the page over Vite's own dev websocket (see index.js); nothing
|
|
13
|
+
* here touches the network. Rate limiting and the `enabled` switch are
|
|
14
|
+
* server-cluster concerns and have no dev counterpart.
|
|
15
|
+
*/
|
|
16
|
+
|
|
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.-]+)*(\/\*)?)$/;
|
|
24
|
+
export const CHANNEL_NAME_MAX_LENGTH = 128;
|
|
25
|
+
// `created`, `order_created`
|
|
26
|
+
export const EVENT_NAME = /^[\w.-]+$/;
|
|
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']);
|
|
32
|
+
// config-factory.js app.channels.maxFrameBytes default
|
|
33
|
+
export const MAX_FRAME_BYTES = 65536;
|
|
34
|
+
// Frames kept per channel for `replay(channel, since)`.
|
|
35
|
+
export const REPLAY_FRAMES = 50;
|
|
36
|
+
// deploy.js CHANNELS_SCHEMA description cap
|
|
37
|
+
const DESCRIPTION_MAX_LENGTH = 500;
|
|
38
|
+
|
|
39
|
+
// The Vite custom event a frame rides to the page (`hot.on(DEV_CHANNEL_EVENT, ...)`).
|
|
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';
|
|
44
|
+
// The plugin has no tenant identity; frames carry this until it does.
|
|
45
|
+
export const DEV_TENANT = 'dev';
|
|
46
|
+
|
|
47
|
+
/** A short, single-line preview of a payload for the dev console (frames may be 64 KiB). */
|
|
48
|
+
export function previewPayload(value, max = 200) {
|
|
49
|
+
let text;
|
|
50
|
+
try { text = JSON.stringify(value); } catch { text = String(value); }
|
|
51
|
+
if (text === undefined) text = 'undefined';
|
|
52
|
+
return text.length > max ? `${text.slice(0, max)}… (${text.length} chars)` : text;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The emitter event a published frame is raised on. */
|
|
56
|
+
export const BROADCAST_EVENT = 'broadcast';
|
|
57
|
+
|
|
58
|
+
export function isChannelName(name) {
|
|
59
|
+
return typeof name === 'string' && name.length <= CHANNEL_NAME_MAX_LENGTH && CHANNEL_NAME.test(name);
|
|
60
|
+
}
|
|
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
|
+
|
|
67
|
+
export function isEventName(name) {
|
|
68
|
+
return typeof name === 'string' && name.length <= EVENT_NAME_MAX_LENGTH && EVENT_NAME.test(name);
|
|
69
|
+
}
|
|
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
|
+
|
|
93
|
+
// Shaped like the boom error the server throws: message === code, plus the
|
|
94
|
+
// HTTP status it would carry, so a handler can branch on either in dev.
|
|
95
|
+
export function channelError(code, statusCode) {
|
|
96
|
+
const err = new Error(code);
|
|
97
|
+
err.code = code;
|
|
98
|
+
err.statusCode = statusCode;
|
|
99
|
+
return err;
|
|
100
|
+
}
|
|
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
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Validate the shape of a parsed `channels:` block. Returns human-readable
|
|
106
|
+
* error strings (empty when valid). Mirrors deploy.js CHANNELS_SCHEMA so an
|
|
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`.
|
|
109
|
+
*
|
|
110
|
+
* @param {*} block - The raw `channels:` value
|
|
111
|
+
* @returns {string[]} Error messages, one per problem
|
|
112
|
+
*/
|
|
113
|
+
export function validateChannels(block) {
|
|
114
|
+
const errors = [];
|
|
115
|
+
if (block === undefined || block === null) return errors;
|
|
116
|
+
if (typeof block !== 'object' || Array.isArray(block)) {
|
|
117
|
+
return ['channels: must be a map of channel name → { on, description? }'];
|
|
118
|
+
}
|
|
119
|
+
for (const [name, def] of Object.entries(block)) {
|
|
120
|
+
if (!isChannelName(name) || isWildcardName(name)) {
|
|
121
|
+
errors.push(`channels: invalid channel name "${name}" (use segments of letters, digits, _ . -, joined by /, max ${CHANNEL_NAME_MAX_LENGTH} chars)`);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (def === null || def === undefined) {
|
|
125
|
+
errors.push(onRequired(name));
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (typeof def !== 'object' || Array.isArray(def)) {
|
|
129
|
+
errors.push(`channels.${name}: must be a map with "on" and an optional "description"`);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
for (const key of Object.keys(def)) {
|
|
133
|
+
if (key !== 'description' && key !== 'on') errors.push(`channels.${name}: unknown key "${key}"`);
|
|
134
|
+
}
|
|
135
|
+
if (def.description !== undefined && (typeof def.description !== 'string' || def.description.length > DESCRIPTION_MAX_LENGTH)) {
|
|
136
|
+
errors.push(`channels.${name}.description: must be a string of at most ${DESCRIPTION_MAX_LENGTH} chars`);
|
|
137
|
+
}
|
|
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)`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return errors;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Create the dev channels hub shared by every dev handler bag.
|
|
154
|
+
*
|
|
155
|
+
* @param {Object} [opts]
|
|
156
|
+
* @param {string} [opts.tenant] - frame tenant (the plugin knows none; defaults to 'dev')
|
|
157
|
+
* @param {string} [opts.appId] - the dev app id (the mocked `report.id`)
|
|
158
|
+
* @param {string} [opts.logPrefix] - console prefix
|
|
159
|
+
* @returns {{ emitter: EventEmitter, broadcast: Function, relay: Function, replay: Function, tenant: string, appId: string }}
|
|
160
|
+
*/
|
|
161
|
+
export function createDevChannels({ tenant = DEV_TENANT, appId = 'dev-local', logPrefix = '[app-channel]' } = {}) {
|
|
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
|
+
}
|
|
175
|
+
|
|
176
|
+
// Validate and publish one §1.1 frame. Synchronous so the manifest relay
|
|
177
|
+
// can run inside a synchronous emit(); throws the server's error codes.
|
|
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);
|
|
182
|
+
if (!isEventName(event)) throw channelError('app_channel_invalid_event', 400);
|
|
183
|
+
if (isReservedEvent(event)) throw channelError('app_channel_reserved_event', 400);
|
|
184
|
+
|
|
185
|
+
const message = payload === undefined ? null : payload;
|
|
186
|
+
let serialized;
|
|
187
|
+
try {
|
|
188
|
+
serialized = JSON.stringify(message);
|
|
189
|
+
} catch {
|
|
190
|
+
throw channelError('app_channel_invalid_payload', 400);
|
|
191
|
+
}
|
|
192
|
+
// A function/symbol serializes to nothing at all; the sandbox membrane
|
|
193
|
+
// would have refused it before the server ever saw it.
|
|
194
|
+
if (serialized === undefined) throw channelError('app_channel_invalid_payload', 400);
|
|
195
|
+
if (Buffer.byteLength(serialized) > MAX_FRAME_BYTES) throw channelError('app_channel_frame_too_large', 413);
|
|
196
|
+
|
|
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
|
+
}
|
|
204
|
+
emitter.emit(BROADCAST_EVENT, frame);
|
|
205
|
+
return frame;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// The bag member. Async like the sandbox's, so a bad name/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 : {});
|
|
213
|
+
console.log(`${logPrefix} broadcast("${channel}", "${event}", ${previewPayload(frame.payload)})`);
|
|
214
|
+
return { ok: true, seq: frame.seq };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The `channels:` relay (mirror of emitAppEvent): an emit() of an event
|
|
219
|
+
* listed in a channel's `on` is also broadcast to that channel, same event
|
|
220
|
+
* name and payload. A dropped relay warns and never fails the emit.
|
|
221
|
+
*
|
|
222
|
+
* @param {Object} manifestChannels - the parsed `channels:` block
|
|
223
|
+
* @param {string} event
|
|
224
|
+
* @param {*} payload
|
|
225
|
+
*/
|
|
226
|
+
function relay(manifestChannels, event, payload) {
|
|
227
|
+
if (!manifestChannels || typeof manifestChannels !== 'object') return;
|
|
228
|
+
for (const [channel, def] of Object.entries(manifestChannels)) {
|
|
229
|
+
if (![].concat((def && def.on) || []).includes(event)) continue;
|
|
230
|
+
try {
|
|
231
|
+
publish(channel, event, payload);
|
|
232
|
+
console.log(`${logPrefix} relayed emit("${event}") → channel "${channel}"`);
|
|
233
|
+
} catch (err) {
|
|
234
|
+
console.warn(`${logPrefix} relay dropped: channel "${channel}" event "${event}": ${err.message}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
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 };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Build the dev `emit(event, payload)` bag member: a console-logged no-op
|
|
266
|
+
* (no app_event row in dev) that still runs the manifest relay, so a page
|
|
267
|
+
* subscribed to a relayed channel sees the frame locally.
|
|
268
|
+
*
|
|
269
|
+
* @param {Object} opts
|
|
270
|
+
* @param {ReturnType<typeof createDevChannels>} opts.channels
|
|
271
|
+
* @param {Object} opts.manifestChannels - the parsed `channels:` block
|
|
272
|
+
* @param {string} [opts.logPrefix]
|
|
273
|
+
* @returns {(event: string, payload?: *) => { ok: true }}
|
|
274
|
+
*/
|
|
275
|
+
export function createDevEmit({ channels, manifestChannels, logPrefix = '[app-event]' }) {
|
|
276
|
+
return (event, payload) => {
|
|
277
|
+
// The sandbox bootstrap sends `payload || {}` across the membrane.
|
|
278
|
+
const body = payload || {};
|
|
279
|
+
console.log(`${logPrefix} emit("${event}", ${previewPayload(body)})`);
|
|
280
|
+
channels.relay(manifestChannels, event, body);
|
|
281
|
+
return { ok: true };
|
|
282
|
+
};
|
|
283
|
+
}
|