@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.
- package/README.md +3 -1
- package/bin/workspace.js +15 -7
- package/package.json +1 -1
- package/src/dev-bag.js +185 -0
- package/src/dev-channel-handlers.js +325 -0
- package/src/dev-channel-shim.js +248 -37
- package/src/dev-channels.js +119 -27
- package/src/dev-platform.js +3 -0
- package/src/env.js +17 -1
- package/src/index.js +28 -3
- package/src/server-routes.js +78 -211
package/src/dev-channel-shim.js
CHANGED
|
@@ -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
|
|
5
|
-
* (App API v2 §1.9)
|
|
6
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
65
|
-
|
|
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(
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/dev-channels.js
CHANGED
|
@@ -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,
|
|
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
|
-
//
|
|
18
|
-
//
|
|
19
|
-
|
|
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
|
|
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)
|
|
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
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/src/dev-platform.js
CHANGED
|
@@ -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
|
*
|