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