@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
package/src/dev-bag.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { loadManifest, manifestBlock, buildDevContext, buildDevCrypto, buildDevMessaging, normalizeFetchPath } from './dev-dependencies.js';
|
|
2
2
|
import { createDevChannels, createDevEmit } from './dev-channels.js';
|
|
3
|
-
import {
|
|
3
|
+
import { LIMITS } from './dev-streams.js';
|
|
4
|
+
import { devPlatform, createDevEmbed } from './dev-platform.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* The part of the dev handler bag every surface shares — `server/` routes and
|
|
@@ -11,18 +12,33 @@ import { devPlatform } from './dev-platform.js';
|
|
|
11
12
|
* for channels), the same split the prod sandbox's buildInvokeScript makes.
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
|
-
// embed() is present on a real install even without the embeddings
|
|
15
|
-
// capability, where it throws a written explanation. Mirroring that here
|
|
16
|
-
// keeps the dev failure the same lesson as the deployed one, instead of a
|
|
17
|
-
// bare "embed is not a function" that reads like a missing binding.
|
|
18
|
-
export const embed = async () => {
|
|
19
|
-
throw new Error('embed() is not available in the dev mirror: the embeddings capability needs a real Informer (platform.capabilities.embeddings is false)');
|
|
20
|
-
};
|
|
21
|
-
|
|
22
15
|
// Cap dev proxy calls at 30s so a hung upstream fails loudly instead of
|
|
23
16
|
// hanging the handler.
|
|
24
17
|
const FETCH_TIMEOUT_MS = 30000;
|
|
25
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Read a response body into memory, refusing once it passes `max` and cancelling
|
|
21
|
+
* the reader so the rest never arrives. `arrayBuffer()` would buffer whatever the
|
|
22
|
+
* upstream sends before anything could measure it.
|
|
23
|
+
*/
|
|
24
|
+
async function readCapped(resp, max) {
|
|
25
|
+
if (!resp.body) return Buffer.from(await resp.arrayBuffer());
|
|
26
|
+
const reader = resp.body.getReader();
|
|
27
|
+
const chunks = [];
|
|
28
|
+
let total = 0;
|
|
29
|
+
for (;;) {
|
|
30
|
+
const { done, value } = await reader.read();
|
|
31
|
+
if (done) break;
|
|
32
|
+
total += value.byteLength;
|
|
33
|
+
if (total > max) {
|
|
34
|
+
await reader.cancel();
|
|
35
|
+
throw new RangeError(`The upstream body exceeds the ${max} bytes the dev server holds in memory`);
|
|
36
|
+
}
|
|
37
|
+
chunks.push(Buffer.from(value));
|
|
38
|
+
}
|
|
39
|
+
return Buffer.concat(chunks);
|
|
40
|
+
}
|
|
41
|
+
|
|
26
42
|
/** log(message, data) + log.debug/info/warn/error — the sandbox log surface, to the terminal. */
|
|
27
43
|
export function buildDevLog(prefix = '[app-log]') {
|
|
28
44
|
const logCall = (level, message, data) => {
|
|
@@ -65,9 +81,15 @@ export function buildDevUser(user) {
|
|
|
65
81
|
* @param {string|null} [opts.appToken] - INFORMER_APP_TOKEN for cross-app request()
|
|
66
82
|
* @param {ReturnType<typeof createDevChannels>} [opts.channels] - the dev channels hub
|
|
67
83
|
* @param {string} [opts.logPrefix] - prefix for notify/email/emit console lines
|
|
68
|
-
* @
|
|
84
|
+
* @param {Object} [opts.platform] - the MERGED platform descriptor (dev defaults
|
|
85
|
+
* + mock.platform). The bag must see the same one the browser does, or an app
|
|
86
|
+
* that opts into `embeddings` reads the flag as true and still finds embed()
|
|
87
|
+
* throwing. Defaults to the bare dev descriptor.
|
|
88
|
+
* @param {string|null} [opts.appId] - the deployed app's id, for the opt-in
|
|
89
|
+
* dev embed()'s call to its `_embed` route
|
|
90
|
+
* @returns {{ query: Function, apiFetch: Function, build: (opts?: { forwarding?: Object|null }) => Promise<{ manifest: Object, bag: Object }> }}
|
|
69
91
|
*/
|
|
70
|
-
export function createDevBagBuilder({ serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings = {}, appToken = null, channels = createDevChannels(), logPrefix = '[app]' }) {
|
|
92
|
+
export function createDevBagBuilder({ serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings = {}, appToken = null, channels = createDevChannels(), logPrefix = '[app]', platform = devPlatform(), appId = null }) {
|
|
71
93
|
// query() implementation — proxies to the workspace _sql endpoint
|
|
72
94
|
async function query(sql, params) {
|
|
73
95
|
if (!devWorkspaceId) {
|
|
@@ -97,7 +119,12 @@ export function createDevBagBuilder({ serverOrigin, authHeader, devWorkspaceId,
|
|
|
97
119
|
// reject non-canonical shapes here instead of silently accepting them.
|
|
98
120
|
const apiPath = normalizeFetchPath(path);
|
|
99
121
|
if (!apiPath) {
|
|
100
|
-
|
|
122
|
+
// `message` is where every other failure from here puts its
|
|
123
|
+
// sentence (the synthetic 502 below, and boom's own answers), so
|
|
124
|
+
// one field read by a caller finds all of them. `error` stays
|
|
125
|
+
// beside it for anything already reading that.
|
|
126
|
+
const reason = `Invalid fetch path: ${String(path).slice(0, 80)}`;
|
|
127
|
+
return { status: 400, body: { message: reason, error: reason }, contentType: 'application/json' };
|
|
101
128
|
}
|
|
102
129
|
const url = `${serverOrigin}${apiPath}`;
|
|
103
130
|
const fetchOpts = {
|
|
@@ -112,13 +139,31 @@ export function createDevBagBuilder({ serverOrigin, authHeader, devWorkspaceId,
|
|
|
112
139
|
// Read the stream exactly once — `.json()` consumes/locks the body, so a
|
|
113
140
|
// `.text()` fallback would throw "Body is unusable" on any non-JSON
|
|
114
141
|
// response (auth-bounce HTML, proxy error page). Parse in memory
|
|
115
|
-
// instead — same as prod's unwrapInject.
|
|
116
|
-
|
|
142
|
+
// instead — same as prod's unwrapInject. `raw: true` (I5-13030, a
|
|
143
|
+
// stream being filled from an integration) keeps the bytes as bytes and
|
|
144
|
+
// hands the headers back too; the body is still parsed for an error.
|
|
145
|
+
let status, contentType, text, bytes, headers;
|
|
117
146
|
try {
|
|
118
147
|
const resp = await globalThis.fetch(url, { ...fetchOpts, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
119
148
|
status = resp.status;
|
|
120
149
|
contentType = resp.headers.get('content-type') || '';
|
|
121
|
-
|
|
150
|
+
if (opts.raw) {
|
|
151
|
+
// Bounded before it is buffered. Prod meters the body mid-stream
|
|
152
|
+
// and tears the socket down at the first byte past the cap; here
|
|
153
|
+
// the whole thing lands in memory, so an unbounded read turns a
|
|
154
|
+
// clean 413 into an OOM'd dev server with nothing naming the
|
|
155
|
+
// cause. The declared length is the cheap check; the running one
|
|
156
|
+
// covers a chunked response that declares nothing.
|
|
157
|
+
const declared = Number(resp.headers.get('content-length'));
|
|
158
|
+
if (Number.isFinite(declared) && declared > LIMITS.maxUploadBytes) {
|
|
159
|
+
throw new RangeError(`The upstream body is ${declared} bytes; the dev server holds at most ${LIMITS.maxUploadBytes} in memory`);
|
|
160
|
+
}
|
|
161
|
+
bytes = await readCapped(resp, LIMITS.maxUploadBytes);
|
|
162
|
+
headers = Object.fromEntries(resp.headers.entries());
|
|
163
|
+
text = status >= 400 ? bytes.toString('utf8') : '';
|
|
164
|
+
} else {
|
|
165
|
+
text = await resp.text();
|
|
166
|
+
}
|
|
122
167
|
} catch (err) {
|
|
123
168
|
// Transport failure or timeout — fetch throws (TypeError 'fetch failed'
|
|
124
169
|
// with the real reason on err.cause, or a TimeoutError). Return a
|
|
@@ -129,7 +174,7 @@ export function createDevBagBuilder({ serverOrigin, authHeader, devWorkspaceId,
|
|
|
129
174
|
}
|
|
130
175
|
let body;
|
|
131
176
|
try { body = JSON.parse(text); } catch { body = text; }
|
|
132
|
-
return { status, body, contentType };
|
|
177
|
+
return opts.raw ? { status, body, contentType, bytes, headers } : { status, body, contentType };
|
|
133
178
|
}
|
|
134
179
|
|
|
135
180
|
async function apiFetch(path, opts = {}) {
|
|
@@ -155,6 +200,11 @@ export function createDevBagBuilder({ serverOrigin, authHeader, devWorkspaceId,
|
|
|
155
200
|
// won't 500 in production.
|
|
156
201
|
const { notify, email } = buildDevMessaging(logPrefix);
|
|
157
202
|
const log = buildDevLog();
|
|
203
|
+
// Throws the written explanation a real install gives an app type without
|
|
204
|
+
// the capability, unless the mock platform opts into embeddings; then it
|
|
205
|
+
// posts to the deployed app's _embed route through apiFetch. See
|
|
206
|
+
// createDevEmbed.
|
|
207
|
+
const embed = createDevEmbed({ platform, apiFetch, appId });
|
|
158
208
|
// markdown helper — passthrough in dev (production uses `marked`)
|
|
159
209
|
const markdown = (text) => text;
|
|
160
210
|
|
|
@@ -162,11 +212,17 @@ export function createDevBagBuilder({ serverOrigin, authHeader, devWorkspaceId,
|
|
|
162
212
|
* The shared bag for one invocation. The manifest is parsed per call
|
|
163
213
|
* (deps, env and channels all come from that one read) so edits to
|
|
164
214
|
* informer.yaml take effect without a dev-server restart.
|
|
215
|
+
*
|
|
216
|
+
* @param {Object} [opts]
|
|
217
|
+
* @param {Object|null} [opts.forwarding] - the request's stream forwarding
|
|
218
|
+
* services (dev-streams.js createStreamServices), so an integration
|
|
219
|
+
* request() can send a staged upload or fill a download; only `server/`
|
|
220
|
+
* routes have streams, so channel handlers leave it null
|
|
165
221
|
*/
|
|
166
|
-
async function build() {
|
|
222
|
+
async function build({ forwarding = null } = {}) {
|
|
167
223
|
const manifest = await loadManifest(projectRoot);
|
|
168
224
|
const deps = manifestBlock(manifest, 'dependencies');
|
|
169
|
-
const context = buildDevContext({ deps, apiFetch, devBindings, appFetch });
|
|
225
|
+
const context = buildDevContext({ deps, apiFetch, devBindings, appFetch, forwarding, serverOrigin });
|
|
170
226
|
const env = manifestBlock(manifest, 'env');
|
|
171
227
|
|
|
172
228
|
// emit() writes no app_event row in dev, but still relays a listed
|
|
@@ -177,7 +233,7 @@ export function createDevBagBuilder({ serverOrigin, authHeader, devWorkspaceId,
|
|
|
177
233
|
|
|
178
234
|
return {
|
|
179
235
|
manifest,
|
|
180
|
-
bag: { context, query, fetch: apiFetch, emit, broadcast, notify, email, embed, crypto: buildDevCrypto(), markdown, log, env, platform
|
|
236
|
+
bag: { context, query, fetch: apiFetch, emit, broadcast, notify, email, embed, crypto: buildDevCrypto(), markdown, log, env, platform }
|
|
181
237
|
};
|
|
182
238
|
}
|
|
183
239
|
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Channel actors in the dev mirror, the counterpart of the server's
|
|
5
|
+
* app-channel-actors.js. A `channels/` file whose `config.actor` is an object
|
|
6
|
+
* gets one live module instance per concrete channel (`match/42`), kept while
|
|
7
|
+
* pages are subscribed:
|
|
8
|
+
*
|
|
9
|
+
* - start({ channel, restored }) runs first, then join / joined / leave and
|
|
10
|
+
* every event export run inside that instance, one call at a time
|
|
11
|
+
* - tick({ dt, frame }) runs `config.actor.tick` times a second; a tick that
|
|
12
|
+
* comes due while a call is still running is skipped, not queued
|
|
13
|
+
* - the actor stops `idleMs` after its last page leaves, with its
|
|
14
|
+
* stop({ reason }), and the next join or send starts a fresh one
|
|
15
|
+
* - snapshot() is called every `snapshotMs` and kept in memory; when a file
|
|
16
|
+
* the actors load changes, each actor keeps a snapshot, stops with reason
|
|
17
|
+
* 'redeploy', and restarts at once on the new code from it, with the same
|
|
18
|
+
* pages: a game survives an edit, as it survives a deploy on the server
|
|
19
|
+
*
|
|
20
|
+
* A page is one member here (a dev page is one client, with no reconnects),
|
|
21
|
+
* so leave runs when it unsubscribes. The server's cluster, leases, billing
|
|
22
|
+
* and rate limits have no dev counterpart.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export const ACTOR_LIFECYCLE = Object.freeze(['start', 'stop', 'tick', 'snapshot']);
|
|
26
|
+
// The server's actors.callTimeoutMs: a call that runs longer stops the actor.
|
|
27
|
+
export const ACTOR_CALL_TIMEOUT_MS = 1000;
|
|
28
|
+
export const ACTOR_STOP_TIMEOUT_MS = 5000;
|
|
29
|
+
const DEFAULTS = Object.freeze({ tick: 0, idleMs: 30000, snapshotMs: 5000 });
|
|
30
|
+
const MAX_TICK_HZ = 60;
|
|
31
|
+
|
|
32
|
+
export function isActorModule(mod) {
|
|
33
|
+
const actor = mod && mod.config && mod.config.actor;
|
|
34
|
+
return Boolean(actor && typeof actor === 'object' && !Array.isArray(actor));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `request.member` for a dev page: a hash of its client id, shaped like the server's. */
|
|
38
|
+
export function memberIdFor(clientId) {
|
|
39
|
+
return `m_${createHash('sha256').update(`dev:${clientId}`).digest('base64url').slice(0, 16)}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function optionsOf(mod) {
|
|
43
|
+
const asked = mod.config.actor;
|
|
44
|
+
return {
|
|
45
|
+
tickHz: Math.min(Math.max(Number(asked.tick) || 0, 0), MAX_TICK_HZ),
|
|
46
|
+
idleMs: asked.idleMs === undefined ? DEFAULTS.idleMs : Math.max(0, Number(asked.idleMs) || 0),
|
|
47
|
+
snapshotMs: Math.min(Math.max(Number(asked.snapshotMs) || DEFAULTS.snapshotMs, 1000), 60000)
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// What `config.actor` may say, and the wording the deploy uses to refuse it
|
|
52
|
+
// (channel-scanner.js assertActorConfig on the server). Without this, a file
|
|
53
|
+
// that sets `actor: { tickRate: 30 }` or exports tick() with no rate runs
|
|
54
|
+
// quietly tick-less in dev and 400s at deploy time.
|
|
55
|
+
const ACTOR_KEYS = ['tick', 'idleMs', 'memoryMb', 'snapshotMs'];
|
|
56
|
+
|
|
57
|
+
function assertActorConfig(mod, filePath) {
|
|
58
|
+
const actor = mod.config.actor;
|
|
59
|
+
const exports = Object.keys(mod);
|
|
60
|
+
const fail = message => {
|
|
61
|
+
throw new Error(`channel handler "${filePath}" ${message} Fix config.actor.`);
|
|
62
|
+
};
|
|
63
|
+
const unknown = Object.keys(actor).filter(key => !ACTOR_KEYS.includes(key));
|
|
64
|
+
if (unknown.length) fail(`sets config.actor.${unknown.join(', config.actor.')}, which is not an actor setting (${ACTOR_KEYS.join(', ')} are).`);
|
|
65
|
+
const number = (key, min) => {
|
|
66
|
+
if (actor[key] === undefined) return;
|
|
67
|
+
if (typeof actor[key] !== 'number' || !Number.isFinite(actor[key]) || actor[key] < min) {
|
|
68
|
+
fail(`sets config.actor.${key} to ${JSON.stringify(actor[key])}; it must be a number of at least ${min}.`);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
number('tick', 0);
|
|
72
|
+
number('idleMs', 0);
|
|
73
|
+
number('memoryMb', 8);
|
|
74
|
+
number('snapshotMs', 1000);
|
|
75
|
+
if (actor.tick > 0 && !exports.includes('tick')) fail(`sets config.actor.tick to ${actor.tick} but exports no tick() to call.`);
|
|
76
|
+
if (exports.includes('tick') && !(actor.tick > 0)) fail('exports tick() but config.actor.tick does not say how often to call it.');
|
|
77
|
+
if (actor.snapshotMs !== undefined && !exports.includes('snapshot')) fail(`sets config.actor.snapshotMs to ${actor.snapshotMs} but exports no snapshot() to call.`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const unavailable = name => () => {
|
|
81
|
+
throw new Error(`${name}() is not available in a channel actor: it would run with one subscriber's credentials on behalf of all of them. Call a server/ route from the page instead.`);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {Object} opts
|
|
86
|
+
* @param {Object} opts.viteServer - loads a fresh module instance per actor run (ssrLoadModule with a query)
|
|
87
|
+
* @param {Function} opts.bagFor - async (channel, params) => the handler bag (broadcast, query, log, …)
|
|
88
|
+
* @param {string} [opts.logPrefix]
|
|
89
|
+
* @param {number} [opts.callTimeoutMs]
|
|
90
|
+
*/
|
|
91
|
+
export function createDevActors({ viteServer, bagFor, logPrefix = '[app-channel]', callTimeoutMs = ACTOR_CALL_TIMEOUT_MS }) {
|
|
92
|
+
const actors = new Map(); // channel name → actor
|
|
93
|
+
const starting = new Map(); // channel name → its start in flight
|
|
94
|
+
const snapshots = new Map(); // channel name → the last snapshot() kept
|
|
95
|
+
let runs = 0;
|
|
96
|
+
|
|
97
|
+
async function load(filePath, channel) {
|
|
98
|
+
// a query per run: a separate instance per channel, and a fresh one per start
|
|
99
|
+
return await viteServer.ssrLoadModule(`${filePath}?actor=${encodeURIComponent(channel)}&run=${++runs}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function queue(actor, exportName, extras, timeoutMs = callTimeoutMs) {
|
|
103
|
+
const run = async () => {
|
|
104
|
+
if (actor.state === 'stopped') throw Object.assign(new Error('app_channel_actor_stopped'), { statusCode: 503, code: 'app_channel_actor_stopped' });
|
|
105
|
+
actor.busy = true;
|
|
106
|
+
let timer;
|
|
107
|
+
try {
|
|
108
|
+
const call = Promise.resolve().then(() => actor.mod[exportName]({ ...actor.bag, ...extras }));
|
|
109
|
+
const overrun = new Promise((_, reject) => {
|
|
110
|
+
timer = setTimeout(() => reject(Object.assign(new Error(`${exportName} ran past ${timeoutMs} ms`), { overrun: true })), timeoutMs);
|
|
111
|
+
});
|
|
112
|
+
return await Promise.race([call, overrun]);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
if (err && err.overrun) {
|
|
115
|
+
console.error(`${logPrefix} actor "${actor.channel}" stopped: ${err.message}; the next join or send starts a new one`);
|
|
116
|
+
stop(actor, 'timeout');
|
|
117
|
+
throw Object.assign(new Error('app_channel_actor_timeout'), { statusCode: 504, code: 'app_channel_actor_timeout' });
|
|
118
|
+
}
|
|
119
|
+
if (err && typeof err.statusCode === 'number') throw err;
|
|
120
|
+
console.error(`${logPrefix} ${exportName}("${actor.channel}") failed:`, err);
|
|
121
|
+
throw Object.assign(new Error(`app_channel_${exportName}_failed`), { statusCode: 500, code: 'app_channel_send_failed' });
|
|
122
|
+
} finally {
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
actor.busy = false;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
const result = actor.queue.then(run, run);
|
|
128
|
+
actor.queue = result.catch(() => {});
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const has = (actor, name) => typeof actor.mod[name] === 'function';
|
|
133
|
+
|
|
134
|
+
// Another actor took this channel while this one was stopping: the
|
|
135
|
+
// channel's shared state (its kept snapshot, its registry slot) is that
|
|
136
|
+
// one's now, and this one must not write over it.
|
|
137
|
+
const isReplaced = actor => actors.has(actor.channel) && actors.get(actor.channel) !== actor;
|
|
138
|
+
|
|
139
|
+
function scheduleTick(actor) {
|
|
140
|
+
const period = 1000 / actor.options.tickHz;
|
|
141
|
+
const next = actor.tickOrigin + (actor.frame + 1) * period;
|
|
142
|
+
actor.timers.tick = setTimeout(() => {
|
|
143
|
+
if (actor.state !== 'running') return;
|
|
144
|
+
actor.frame++;
|
|
145
|
+
if (!actor.busy) {
|
|
146
|
+
const now = Date.now();
|
|
147
|
+
const dt = (now - actor.lastTickAt) / 1000;
|
|
148
|
+
actor.lastTickAt = now;
|
|
149
|
+
queue(actor, 'tick', { dt, frame: actor.frame, request: null }).catch(() => {});
|
|
150
|
+
}
|
|
151
|
+
scheduleTick(actor);
|
|
152
|
+
}, Math.max(0, next - Date.now()));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function armIdle(actor) {
|
|
156
|
+
clearTimeout(actor.timers.idle);
|
|
157
|
+
if (actor.members.size > 0 || actor.state !== 'running') return;
|
|
158
|
+
actor.timers.idle = setTimeout(() => stop(actor, 'idle'), actor.options.idleMs);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function saveSnapshot(actor) {
|
|
162
|
+
if (!has(actor, 'snapshot')) return;
|
|
163
|
+
try {
|
|
164
|
+
const state = await queue(actor, 'snapshot', { request: null });
|
|
165
|
+
// taken between messages, so a replacement may have started meanwhile
|
|
166
|
+
if (state !== undefined && state !== null && !isReplaced(actor)) snapshots.set(actor.channel, JSON.parse(JSON.stringify(state)));
|
|
167
|
+
} catch {
|
|
168
|
+
// reported by queue()
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function start(channel, params, filePath) {
|
|
173
|
+
const mod = await load(filePath, channel);
|
|
174
|
+
assertActorConfig(mod, filePath);
|
|
175
|
+
const bag = await bagFor(channel, params);
|
|
176
|
+
// An actor's broadcast to its own channel answers the production shape:
|
|
177
|
+
// there it is queued on the actor's ordered chain and has no seq yet,
|
|
178
|
+
// and `const { seq } = await channel.broadcast(...)` must not read as a
|
|
179
|
+
// usable number locally and null once deployed. Any other channel goes
|
|
180
|
+
// the way every handler's broadcast does.
|
|
181
|
+
const broadcastOwn = async (event, body, options) => {
|
|
182
|
+
await bag.broadcast(channel, event, body, options);
|
|
183
|
+
return { ok: true, queued: true, seq: null };
|
|
184
|
+
};
|
|
185
|
+
const actor = {
|
|
186
|
+
channel, params, filePath, mod,
|
|
187
|
+
options: optionsOf(mod),
|
|
188
|
+
bag: {
|
|
189
|
+
...bag,
|
|
190
|
+
broadcast: (name, event, body, options) => (name === channel
|
|
191
|
+
? broadcastOwn(event, body, options)
|
|
192
|
+
: bag.broadcast(name, event, body, options)),
|
|
193
|
+
fetch: unavailable('fetch'),
|
|
194
|
+
context: undefined,
|
|
195
|
+
channel: { name: channel, params, broadcast: broadcastOwn }
|
|
196
|
+
},
|
|
197
|
+
members: new Map(), // clientId → the request it joined with
|
|
198
|
+
queue: Promise.resolve(),
|
|
199
|
+
busy: false,
|
|
200
|
+
timers: {},
|
|
201
|
+
frame: 0,
|
|
202
|
+
state: 'running'
|
|
203
|
+
};
|
|
204
|
+
actors.set(channel, actor);
|
|
205
|
+
const restored = has(actor, 'snapshot') ? snapshots.get(channel) : undefined;
|
|
206
|
+
if (has(actor, 'start')) {
|
|
207
|
+
try {
|
|
208
|
+
await queue(actor, 'start', { restored, request: null });
|
|
209
|
+
} catch (err) {
|
|
210
|
+
// a state the new start cannot take would fail every start after it too
|
|
211
|
+
snapshots.delete(channel);
|
|
212
|
+
actors.delete(channel);
|
|
213
|
+
actor.state = 'stopped';
|
|
214
|
+
throw err;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (actor.options.tickHz > 0 && has(actor, 'tick')) {
|
|
218
|
+
actor.tickOrigin = Date.now();
|
|
219
|
+
actor.lastTickAt = actor.tickOrigin;
|
|
220
|
+
scheduleTick(actor);
|
|
221
|
+
}
|
|
222
|
+
if (has(actor, 'snapshot')) actor.timers.snapshot = setInterval(() => saveSnapshot(actor), actor.options.snapshotMs);
|
|
223
|
+
armIdle(actor);
|
|
224
|
+
console.log(`${logPrefix} actor "${channel}" started${restored !== undefined ? ' from its snapshot' : ''}`);
|
|
225
|
+
return actor;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Stop an actor: for a redeploy (an edited file) keep a snapshot first;
|
|
230
|
+
* after idle or a timeout drop it; stop() runs unless the actor hung.
|
|
231
|
+
*/
|
|
232
|
+
function stop(actor, reason) {
|
|
233
|
+
if (actor.stopping) return actor.stopping;
|
|
234
|
+
// Synchronously, as the server does: a stop that only marks the actor
|
|
235
|
+
// dead after its awaits hands it out of ensure() for the whole
|
|
236
|
+
// graceful stop, and the page joins an actor that is on its way out.
|
|
237
|
+
actor.state = 'stopping';
|
|
238
|
+
actor.stopping = (async () => {
|
|
239
|
+
clearTimeout(actor.timers.tick);
|
|
240
|
+
clearTimeout(actor.timers.idle);
|
|
241
|
+
clearInterval(actor.timers.snapshot);
|
|
242
|
+
if (reason === 'redeploy' || reason === 'shutdown') await saveSnapshot(actor);
|
|
243
|
+
if (reason !== 'timeout' && has(actor, 'stop')) {
|
|
244
|
+
await queue(actor, 'stop', { reason, request: null }, ACTOR_STOP_TIMEOUT_MS).catch(() => {});
|
|
245
|
+
}
|
|
246
|
+
actor.state = 'stopped';
|
|
247
|
+
const replaced = isReplaced(actor);
|
|
248
|
+
// a replacement started while this one was stopping owns the
|
|
249
|
+
// channel's snapshot now
|
|
250
|
+
if ((reason === 'idle' || reason === 'timeout') && !replaced) snapshots.delete(actor.channel);
|
|
251
|
+
if (!replaced) actors.delete(actor.channel);
|
|
252
|
+
console.log(`${logPrefix} actor "${actor.channel}" stopped (${reason})`);
|
|
253
|
+
})();
|
|
254
|
+
return actor.stopping;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Admit a page: the actor's join decides (exactly true admits), and an
|
|
259
|
+
* actor never holds a member its join did not admit. Every admission goes
|
|
260
|
+
* through here, as on the server, so a join that seeds per-member state or
|
|
261
|
+
* caps seats is exercised in dev too -- not just on the subscribe path.
|
|
262
|
+
*/
|
|
263
|
+
async function admit(actor, clientId, request) {
|
|
264
|
+
if (has(actor, 'join') && await queue(actor, 'join', { payload: null, request }) !== true) {
|
|
265
|
+
armIdle(actor);
|
|
266
|
+
throw Object.assign(new Error('app_channel_join_refused'), { statusCode: 403, code: 'app_channel_join_refused' });
|
|
267
|
+
}
|
|
268
|
+
actor.members.set(clientId, request);
|
|
269
|
+
clearTimeout(actor.timers.idle);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// The running actor for a channel, starting one if there is none; two
|
|
273
|
+
// callers at once share one start.
|
|
274
|
+
async function ensure(channel, params, filePath) {
|
|
275
|
+
const running = actors.get(channel);
|
|
276
|
+
if (running && running.state === 'running' && !starting.has(channel)) return running;
|
|
277
|
+
if (!starting.has(channel)) {
|
|
278
|
+
starting.set(channel, start(channel, params, filePath).finally(() => starting.delete(channel)));
|
|
279
|
+
}
|
|
280
|
+
return await starting.get(channel);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
/** A page subscribes: the actor's join decides (exactly true admits). */
|
|
285
|
+
async join({ channel, params, filePath, clientId, request }) {
|
|
286
|
+
const actor = await ensure(channel, params, filePath);
|
|
287
|
+
await admit(actor, clientId, request);
|
|
288
|
+
},
|
|
289
|
+
async joined({ channel, clientId, request }) {
|
|
290
|
+
const actor = actors.get(channel);
|
|
291
|
+
if (!actor || !actor.members.has(clientId) || !has(actor, 'joined')) return;
|
|
292
|
+
await queue(actor, 'joined', { payload: null, request });
|
|
293
|
+
},
|
|
294
|
+
async leave({ channel, clientId }) {
|
|
295
|
+
const actor = actors.get(channel);
|
|
296
|
+
if (!actor || !actor.members.has(clientId)) return;
|
|
297
|
+
const request = actor.members.get(clientId);
|
|
298
|
+
actor.members.delete(clientId);
|
|
299
|
+
armIdle(actor);
|
|
300
|
+
if (has(actor, 'leave')) await queue(actor, 'leave', { payload: null, request });
|
|
301
|
+
},
|
|
302
|
+
/** A page's send(): a call into the running actor, started afresh if it stopped. */
|
|
303
|
+
async send({ channel, params, filePath, clientId, request, event, payload }) {
|
|
304
|
+
const actor = await ensure(channel, params, filePath);
|
|
305
|
+
if (ACTOR_LIFECYCLE.includes(event) || typeof actor.mod[event] !== 'function') {
|
|
306
|
+
throw Object.assign(new Error('app_channel_no_handler'), { statusCode: 404, code: 'app_channel_no_handler' });
|
|
307
|
+
}
|
|
308
|
+
// a sender this actor has not admitted (it restarted, or its code
|
|
309
|
+
// changed since the page joined) goes through join like anyone else
|
|
310
|
+
if (!actor.members.has(clientId)) await admit(actor, clientId, request);
|
|
311
|
+
return await queue(actor, event, { payload: payload === undefined ? null : payload, request });
|
|
312
|
+
},
|
|
313
|
+
/**
|
|
314
|
+
* A file an actor loads changed: an actor that keeps snapshots and has
|
|
315
|
+
* pages stops with one and restarts at once on the new code, carrying
|
|
316
|
+
* its pages in through the new code's own join. Any other actor just
|
|
317
|
+
* stops, and the next join or send starts it fresh -- the same two
|
|
318
|
+
* rules the server follows across a deploy, so a join that refuses the
|
|
319
|
+
* carried page (or a file that keeps no snapshot) behaves here as it
|
|
320
|
+
* will there.
|
|
321
|
+
*/
|
|
322
|
+
async restartAll() {
|
|
323
|
+
const running = [...actors.values()].filter(a => a.state === 'running');
|
|
324
|
+
await Promise.all(running.map(async actor => {
|
|
325
|
+
const carry = has(actor, 'snapshot') && actor.members.size > 0;
|
|
326
|
+
const members = new Map(actor.members);
|
|
327
|
+
await stop(actor, 'redeploy');
|
|
328
|
+
if (!carry) return;
|
|
329
|
+
try {
|
|
330
|
+
const next = await ensure(actor.channel, actor.params, actor.filePath);
|
|
331
|
+
for (const [clientId, request] of members) {
|
|
332
|
+
try {
|
|
333
|
+
await admit(next, clientId, request);
|
|
334
|
+
} catch (err) {
|
|
335
|
+
console.log(`${logPrefix} a page was not carried into actor "${actor.channel}" on the edited code: ${err.message}`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
} catch (err) {
|
|
339
|
+
console.error(`${logPrefix} actor "${actor.channel}" did not restart on the edited code:`, err);
|
|
340
|
+
}
|
|
341
|
+
}));
|
|
342
|
+
},
|
|
343
|
+
async stopAll(reason = 'shutdown') {
|
|
344
|
+
await Promise.all([...actors.values()].map(actor => stop(actor, reason)));
|
|
345
|
+
},
|
|
346
|
+
/** What is running, for the terminal and tests. */
|
|
347
|
+
list() {
|
|
348
|
+
return [...actors.values()].map(a => ({ channel: a.channel, members: a.members.size, frame: a.frame, state: a.state }));
|
|
349
|
+
},
|
|
350
|
+
snapshots
|
|
351
|
+
};
|
|
352
|
+
}
|