@entrinsik/vite-plugin-informer 2.12.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 +3 -1
- package/bin/deploy.js +3 -0
- package/index.d.ts +7 -5
- package/package.json +1 -1
- package/src/agent-dev.js +1 -1
- package/src/client.js +29 -3
- package/src/deploy.js +198 -45
- package/src/dev-bag.js +1 -1
- package/src/dev-channel-actors.js +352 -0
- package/src/dev-channel-handlers.js +76 -18
- package/src/dev-channel-shim.js +28 -0
- package/src/dev-dependencies.js +58 -16
- package/src/dev-platform.js +2 -0
- package/src/openapi-to-dts.js +4 -0
- package/src/server-routes.js +22 -12
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ The assembler uploads your built frontend plus these source trees, matching how
|
|
|
25
25
|
| `webhooks/` | Publicly callable webhook routes |
|
|
26
26
|
| `tools/` | AI tools, private to the App's agents |
|
|
27
27
|
| `mcp/` | Tools exposed over the App's MCP endpoint |
|
|
28
|
-
| `channels/` | Live channel handlers: `join` / `joined` / `leave` and event-named handlers for `channel.send()
|
|
28
|
+
| `channels/` | Live channel handlers: `join` / `joined` / `leave` and event-named handlers for `channel.send()`, or a channel actor (`config.actor`: one live instance per channel, with `start` / `tick` / `stop` / `snapshot`); `npm run dev` runs both locally, and the `channels:` block in `informer.yaml` declares relays only (each entry needs `on`) |
|
|
29
29
|
| `migrations/` | Workspace database migrations, run in order on deploy |
|
|
30
30
|
| `embeddings/` | Declarative embedding use cases (see below) |
|
|
31
31
|
| `lib/`, `shared/` | Modules importable by the trees above |
|
|
@@ -48,6 +48,8 @@ A server whose version isn't comparable (a `dev` build, or an `/about` behind a
|
|
|
48
48
|
|
|
49
49
|
App Channels phase 2 (plugin ≥ 2.11.0: `channel.send()`, the `joined` export and event-named exports in `channels/` files, wildcard subscriptions, frame `seq` and replay, `connected`) needs a 2026.1.3 server that carries I5-13027. The version probe cannot tell such a server from an earlier 2026.1.3 build, so the deploy is not gated: an earlier build refuses a `channels/` file that exports `joined` or an event name, and accepts a `channels:` entry without `on` that a current server rejects. The dev server runs the phase 2 contract regardless; plugin 2.12.0 aligns its channel semantics with the server's (wildcard gating, replay expiry, and the per-user send budget), so on 2.11.0 the dev mirror has the phase 2 surface but not all of its behavior.
|
|
50
50
|
|
|
51
|
+
Channel actors (plugin ≥ 2.13.0: `config.actor` files, `request.member`, `__INFORMER__.serverNow()`) need a server that reports `platform.capabilities.channelActors`; on one that does not, the same file runs as an ordinary channel handler, one fresh module per message. Under `npm run dev` each concrete channel gets its own module instance, calls run one at a time, `tick()` runs on a timer, and the actor stops `idleMs` after its last page leaves. `snapshot()` is kept in memory, so editing a file under `channels/`, `shared/`, `lib/` or `server/` restarts the running actors on the new code from their snapshots with the same pages subscribed: a game in progress survives the edit. The server's cluster, leases, billing and broadcast limits have no dev counterpart.
|
|
52
|
+
|
|
51
53
|
Feature-detect at runtime with optional chaining, since `platform` is absent entirely before 2026.1.3:
|
|
52
54
|
|
|
53
55
|
```javascript
|
package/bin/deploy.js
CHANGED
package/index.d.ts
CHANGED
|
@@ -2,12 +2,14 @@ import type { Plugin } from 'vite';
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Dev-only binding for a `target: app` or `target: pack` dependency slot.
|
|
5
|
-
* Points `request()` at the target app in dev. For app slots it
|
|
6
|
-
* manifest `defaultBinding` when both are present; for pack
|
|
7
|
-
* only way to bind — the marketplace pin has no install to
|
|
8
|
-
* dev, so point it at your locally-installed copy of the
|
|
5
|
+
* Points `request()` and `url()` at the target app in dev. For app slots it
|
|
6
|
+
* overrides the manifest `defaultBinding` when both are present; for pack
|
|
7
|
+
* slots it is the only way to bind — the marketplace pin has no install to
|
|
8
|
+
* resolve against in dev, so point it at your locally-installed copy of the
|
|
9
|
+
* pack's app.
|
|
9
10
|
*
|
|
10
|
-
* - `app` — the target app (`owner:slug` or UUID). Powers `request()
|
|
11
|
+
* - `app` — the target app (`owner:slug` or UUID). Powers `request()`, and
|
|
12
|
+
* names the app `url()` links into (on `INFORMER_URL`).
|
|
11
13
|
*
|
|
12
14
|
* A bare string is shorthand for `{ app }`.
|
|
13
15
|
*/
|
package/package.json
CHANGED
package/src/agent-dev.js
CHANGED
|
@@ -325,7 +325,7 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
|
|
|
325
325
|
// `context.<slot>.<method>(...)` and `env` work locally and match
|
|
326
326
|
// the prod sandbox bag. The manifest is already in hand.
|
|
327
327
|
const deps = manifestBlock(yaml, 'dependencies');
|
|
328
|
-
const context = buildDevContext({ deps, apiFetch, devBindings, appFetch });
|
|
328
|
+
const context = buildDevContext({ deps, apiFetch, devBindings, appFetch, serverOrigin });
|
|
329
329
|
const env = manifestBlock(yaml, 'env');
|
|
330
330
|
|
|
331
331
|
// emit() writes no app_event row in dev, but still relays a
|
package/src/client.js
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
* Informer API client using native fetch with Basic auth.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* The server's own explanation, dug out of an error body.
|
|
7
|
+
*
|
|
8
|
+
* Boom replies carry the useful sentence in `message`, and for a refused deploy
|
|
9
|
+
* that sentence is the whole value of the response — it names the call that
|
|
10
|
+
* unblocks the app. A bare status line leaves the author with nothing to act on.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} body - raw response body
|
|
13
|
+
* @returns {string} the server's message, or '' when the body says nothing
|
|
14
|
+
*/
|
|
15
|
+
function errorDetail(body) {
|
|
16
|
+
if (!body) return '';
|
|
17
|
+
try {
|
|
18
|
+
const parsed = JSON.parse(body);
|
|
19
|
+
return parsed.message || parsed.error || '';
|
|
20
|
+
} catch {
|
|
21
|
+
return String(body).trim().slice(0, 500);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
5
25
|
class InformerApiError extends Error {
|
|
6
26
|
constructor(status, statusText, body, url) {
|
|
7
27
|
super(`Informer API ${status} ${statusText}${url ? ` (${url})` : ''}`);
|
|
@@ -9,6 +29,7 @@ class InformerApiError extends Error {
|
|
|
9
29
|
this.status = status;
|
|
10
30
|
this.statusText = statusText;
|
|
11
31
|
this.body = body;
|
|
32
|
+
this.detail = errorDetail(body);
|
|
12
33
|
this.url = url;
|
|
13
34
|
}
|
|
14
35
|
}
|
|
@@ -63,7 +84,7 @@ export function createClient({ baseUrl, apiKey, user, pass }) {
|
|
|
63
84
|
* Upload a file using chunked Flow.js protocol, then assemble into an entity's library.
|
|
64
85
|
* @param {{ entityPath?: string, reportId?: string, path: string, buffer: Buffer, filename: string }} opts
|
|
65
86
|
*/
|
|
66
|
-
async function uploadChunked({ entityPath, reportId, path, buffer, filename }) {
|
|
87
|
+
async function uploadChunked({ entityPath, reportId, path, buffer, filename, stagingLibraryId }) {
|
|
67
88
|
const basePath = entityPath || `reports/${reportId}`;
|
|
68
89
|
const totalChunks = Math.ceil(buffer.length / CHUNK_SIZE);
|
|
69
90
|
const uploadId = `publish-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
@@ -98,14 +119,19 @@ export function createClient({ baseUrl, apiKey, user, pass }) {
|
|
|
98
119
|
}
|
|
99
120
|
}
|
|
100
121
|
|
|
101
|
-
// Assemble chunks into a file in the entity's library at the specified
|
|
122
|
+
// Assemble chunks into a file in the entity's library at the specified
|
|
123
|
+
// path. `stagingLibraryId` claims the in-flight deploy — without it the
|
|
124
|
+
// server refuses the write rather than folding it into someone else's
|
|
125
|
+
// build (see deploy-target.js).
|
|
102
126
|
await request('POST', `${basePath}/_upload`, {
|
|
103
127
|
uploadId,
|
|
104
|
-
path
|
|
128
|
+
path,
|
|
129
|
+
...(stagingLibraryId ? { stagingLibraryId } : {})
|
|
105
130
|
});
|
|
106
131
|
}
|
|
107
132
|
|
|
108
133
|
return {
|
|
134
|
+
baseUrl: origin,
|
|
109
135
|
get: (path) => request('GET', path),
|
|
110
136
|
post: (path, body) => request('POST', path, body),
|
|
111
137
|
put: (path, body) => request('PUT', path, body),
|
package/src/deploy.js
CHANGED
|
@@ -71,19 +71,18 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
|
|
|
71
71
|
};
|
|
72
72
|
if (id) payload.id = id;
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
74
|
+
console.log(`Creating new app "${name}"...`);
|
|
75
|
+
entity = await api.post('apps', { ...payload, type: 'app' });
|
|
76
|
+
apiPrefix = 'apps';
|
|
77
|
+
|
|
78
|
+
// Null, not a throw: client.js turns a 404 into a null reply, so the
|
|
79
|
+
// legacy-reports fallback has to branch on the value. As a catch on
|
|
80
|
+
// `e.status === 404` it could never fire, and a genuine missing-apps-API
|
|
81
|
+
// surfaced as "Failed to create" instead of falling back.
|
|
82
|
+
if (entity === null) {
|
|
83
|
+
console.log('Apps API not available, using legacy reports API...');
|
|
84
|
+
entity = await api.post('reports', { ...payload, type: 'magicReport' });
|
|
85
|
+
apiPrefix = 'reports';
|
|
87
86
|
}
|
|
88
87
|
|
|
89
88
|
if (!entity || !entity.id) {
|
|
@@ -116,9 +115,10 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
|
|
|
116
115
|
limit: 10
|
|
117
116
|
});
|
|
118
117
|
|
|
119
|
-
// 5.
|
|
120
|
-
|
|
121
|
-
|
|
118
|
+
// 5. Open a staging library so the upload pass below doesn't touch what the
|
|
119
|
+
// app is currently serving.
|
|
120
|
+
console.log('Staging deploy...');
|
|
121
|
+
const stagingLibraryId = await openStagingDeploy(api, entityPath);
|
|
122
122
|
|
|
123
123
|
// 6. Upload the app-library file set assembled in step 0: dist output at the
|
|
124
124
|
// library root, plus informer.yaml / data-access.yaml and the
|
|
@@ -126,37 +126,59 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
|
|
|
126
126
|
// tree this server is too old to keep out of browsers (see compat.js).
|
|
127
127
|
// Sourced from the shared collectAppFiles() so a deploy and a marketplace
|
|
128
128
|
// publish package byte-identical contents.
|
|
129
|
-
|
|
129
|
+
//
|
|
130
|
+
// Everything from here until the deploy completes runs inside the try, so
|
|
131
|
+
// nothing can leave the app staged: a live staging pointer redirects later
|
|
132
|
+
// content writes into a library nothing serves while reads keep returning
|
|
133
|
+
// the old build, and the routes that cannot redirect refuse with 409
|
|
134
|
+
// (I5-12961). `files` is declared outside the try so step 13 can still
|
|
135
|
+
// report on it once the staging lock has released.
|
|
130
136
|
const files = plan.files;
|
|
137
|
+
let deployed = false;
|
|
138
|
+
const releaseStaging = installStagingSignalHandlers(api, entityPath, stagingLibraryId);
|
|
139
|
+
try {
|
|
140
|
+
console.log('Uploading files...');
|
|
131
141
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
142
|
+
// Presented on every write so the server folds it into THIS deploy. A
|
|
143
|
+
// write that doesn't claim the staging library is refused rather than
|
|
144
|
+
// quietly joining it (see deploy-target.js). Empty on the _clear
|
|
145
|
+
// fallback, where there is no staging library to claim.
|
|
146
|
+
const claim = stagingLibraryId ? { stagingLibraryId } : {};
|
|
147
|
+
|
|
148
|
+
for (const { abs, rel } of files) {
|
|
149
|
+
const content = await readFile(abs);
|
|
150
|
+
|
|
151
|
+
if (content.length > CHUNK_THRESHOLD) {
|
|
152
|
+
// Large file: chunked upload via Flow.js protocol
|
|
153
|
+
await api.uploadChunked({
|
|
154
|
+
entityPath,
|
|
155
|
+
path: rel,
|
|
156
|
+
buffer: content,
|
|
157
|
+
filename: basename(abs),
|
|
158
|
+
stagingLibraryId
|
|
159
|
+
});
|
|
160
|
+
console.log(` ${rel} (${formatSize(content.length)}, chunked)`);
|
|
161
|
+
} else {
|
|
162
|
+
// Small file: direct JSON upload
|
|
163
|
+
const ext = '.' + rel.split('.').pop();
|
|
164
|
+
const isText = TEXT_EXTENSIONS.has(ext.toLowerCase());
|
|
165
|
+
const payload = isText
|
|
166
|
+
? { content: content.toString('utf8'), encoding: 'utf8', ...claim }
|
|
167
|
+
: { content: content.toString('base64'), encoding: 'base64', ...claim };
|
|
168
|
+
|
|
169
|
+
await api.put(`${entityPath}/contents/${rel}`, payload);
|
|
170
|
+
console.log(` ${rel}`);
|
|
171
|
+
}
|
|
154
172
|
}
|
|
155
|
-
}
|
|
156
173
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
174
|
+
// 12. Deploy: run migrations + scan/bundle server routes + webhooks + embeddings + tools + agents
|
|
175
|
+
if (apiPrefix === 'apps') {
|
|
176
|
+
await runServerDeploy(api, entityPath, stagingLibraryId);
|
|
177
|
+
}
|
|
178
|
+
deployed = true;
|
|
179
|
+
} finally {
|
|
180
|
+
releaseStaging();
|
|
181
|
+
if (stagingLibraryId && !deployed) await discardStaging(api, entityPath, stagingLibraryId);
|
|
160
182
|
}
|
|
161
183
|
|
|
162
184
|
// 13. Print URL and return UUID for saving
|
|
@@ -170,6 +192,129 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
|
|
|
170
192
|
return { id: entity.id, url: entityUrl };
|
|
171
193
|
}
|
|
172
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Discard this deploy's staged upload so a failure doesn't leave the app locked.
|
|
197
|
+
*
|
|
198
|
+
* Presents the id so a cleanup can never destroy a DIFFERENT deploy's in-flight
|
|
199
|
+
* upload, and is best-effort: the original failure is what the user needs to
|
|
200
|
+
* see, so a cleanup problem is reported but never replaces it.
|
|
201
|
+
*
|
|
202
|
+
* @param {Object} api - Informer API client
|
|
203
|
+
* @param {string} entityPath
|
|
204
|
+
* @param {string} stagingLibraryId
|
|
205
|
+
*/
|
|
206
|
+
export async function discardStaging(api, entityPath, stagingLibraryId) {
|
|
207
|
+
try {
|
|
208
|
+
await api.del(`${entityPath}/files/_stage?stagingLibraryId=${encodeURIComponent(stagingLibraryId)}`);
|
|
209
|
+
console.log('Discarded the staged upload; the app is unchanged and editable.');
|
|
210
|
+
} catch (cleanupErr) {
|
|
211
|
+
console.log(`Could not discard the staged upload (${cleanupErr.message}).`);
|
|
212
|
+
console.log(`The app will keep redirecting content edits until it is cleared.`);
|
|
213
|
+
console.log(`Run: DELETE ${entityPath}/files/_stage`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Discard the staged upload if the process is interrupted.
|
|
219
|
+
*
|
|
220
|
+
* Ctrl-C during a long upload is an ordinary way to end a deploy, and without a
|
|
221
|
+
* handler it leaves the app staged with no cleanup at all. Returns a function
|
|
222
|
+
* that removes the handlers so a completed deploy doesn't keep them installed.
|
|
223
|
+
*
|
|
224
|
+
* @returns {Function} release
|
|
225
|
+
*/
|
|
226
|
+
function installStagingSignalHandlers(api, entityPath, stagingLibraryId) {
|
|
227
|
+
if (!stagingLibraryId) return () => {};
|
|
228
|
+
|
|
229
|
+
// A second Ctrl-C during the cleanup await would re-enter and start a
|
|
230
|
+
// competing discard, so the first one wins and later signals exit at once.
|
|
231
|
+
let discarding = false;
|
|
232
|
+
|
|
233
|
+
const handlers = ['SIGINT', 'SIGTERM'].map((signal) => {
|
|
234
|
+
const handler = async () => {
|
|
235
|
+
if (discarding) {
|
|
236
|
+
console.log(`\nInterrupted again; exiting without waiting for the discard.`);
|
|
237
|
+
process.exit(130);
|
|
238
|
+
}
|
|
239
|
+
discarding = true;
|
|
240
|
+
console.log(`\nInterrupted (${signal}); discarding the staged upload...`);
|
|
241
|
+
await discardStaging(api, entityPath, stagingLibraryId);
|
|
242
|
+
process.exit(130);
|
|
243
|
+
};
|
|
244
|
+
process.on(signal, handler);
|
|
245
|
+
return { signal, handler };
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
return () => handlers.forEach(({ signal, handler }) => process.off(signal, handler));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* POST `files/_stage`: open a staging library for this deploy.
|
|
253
|
+
*
|
|
254
|
+
* Everything uploaded afterwards lands in the staging library while the app
|
|
255
|
+
* goes on serving its current one; POST `_deploy`, given this library's id,
|
|
256
|
+
* then replaces the live library's files with the staged ones inside a single
|
|
257
|
+
* transaction. That is what keeps an app usable, and complete, for the
|
|
258
|
+
* whole length of its own deploy (I5-12961).
|
|
259
|
+
*
|
|
260
|
+
* Falls back to the old wipe-then-upload flow against a server with no _stage
|
|
261
|
+
* route. That flow is precisely what staging exists to avoid, so it announces
|
|
262
|
+
* itself rather than degrading silently.
|
|
263
|
+
*
|
|
264
|
+
* Note the null check rather than a try/catch: client.js turns a 404 into a
|
|
265
|
+
* `null` reply instead of throwing, so an unsupported route arrives here as a
|
|
266
|
+
* value, not an exception.
|
|
267
|
+
*
|
|
268
|
+
* @param {Object} api - Informer API client
|
|
269
|
+
* @param {string} entityPath - e.g. `apps/team:my-app`
|
|
270
|
+
* @returns {Promise<string|null>} staging library id to present at deploy time, or
|
|
271
|
+
* null when it fell back to _clear and there is nothing to promote
|
|
272
|
+
*/
|
|
273
|
+
export async function openStagingDeploy(api, entityPath) {
|
|
274
|
+
let staged;
|
|
275
|
+
try {
|
|
276
|
+
staged = await api.post(`${entityPath}/files/_stage`);
|
|
277
|
+
} catch (err) {
|
|
278
|
+
// 409 means a previous deploy died and left its upload staged. The
|
|
279
|
+
// server refuses to stage over it (destroying it silently is how two
|
|
280
|
+
// deploys interleave), so the app stays locked until someone discards
|
|
281
|
+
// it — and this message is the only place that instruction appears.
|
|
282
|
+
if (err.status === 409) {
|
|
283
|
+
const base = api.baseUrl ? `${api.baseUrl}/api` : '<informer-url>/api';
|
|
284
|
+
throw new Error(
|
|
285
|
+
`${err.detail || 'This app already has a staged upload from an earlier deploy.'}\n\n`
|
|
286
|
+
+ `A previous deploy did not finish and left its upload staged. Discard it, then re-run this deploy:\n`
|
|
287
|
+
+ ` curl -u <user>:<pass> -X DELETE '${base}/${entityPath}/files/_stage'`
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
throw err;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// `null` means 404 and ONLY 404 — client.js returns it for a missing route
|
|
294
|
+
// and throws for every other non-ok status. Any other falsy-id reply is a
|
|
295
|
+
// server that DID stage, so it must not take the destructive branch.
|
|
296
|
+
if (staged === null) {
|
|
297
|
+
console.log(' Server does not support staged deploys; clearing files instead.');
|
|
298
|
+
console.log(' The app will be unavailable to its users until this deploy finishes.');
|
|
299
|
+
await api.post(`${entityPath}/files/_clear`);
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (!staged.id) {
|
|
304
|
+
// _stage SUCCEEDED, so the server has already pointed the app at a
|
|
305
|
+
// staging library this deploy cannot name. Clearing here would empty the
|
|
306
|
+
// live library while every upload silently redirected into that staging
|
|
307
|
+
// library, and the unclaimed deploy would then publish the empty one.
|
|
308
|
+
throw new Error(
|
|
309
|
+
`POST ${entityPath}/files/_stage succeeded but returned no library id `
|
|
310
|
+
+ `(got: ${JSON.stringify(staged).slice(0, 200)}). The app now has a staging library `
|
|
311
|
+
+ `this deploy cannot claim. Nothing was uploaded or cleared.`
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return staged.id;
|
|
316
|
+
}
|
|
317
|
+
|
|
173
318
|
/**
|
|
174
319
|
* POST `_deploy`: run migrations, scan/bundle server routes, webhooks, tools and
|
|
175
320
|
* agents, and claim the app as platform-managed.
|
|
@@ -179,8 +324,10 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
|
|
|
179
324
|
*
|
|
180
325
|
* @param {{post: (path: string, body?: unknown) => Promise<unknown>}} api
|
|
181
326
|
* @param {string} entityPath e.g. `apps/my-app`
|
|
327
|
+
* @param {string} [stagingLibraryId] id returned by openStagingDeploy; presenting
|
|
328
|
+
* it is what authorises the server to promote that staged upload
|
|
182
329
|
*/
|
|
183
|
-
export async function runServerDeploy(api, entityPath) {
|
|
330
|
+
export async function runServerDeploy(api, entityPath, stagingLibraryId) {
|
|
184
331
|
try {
|
|
185
332
|
console.log('Deploying...');
|
|
186
333
|
// `managed: true` claims the app as platform-managed (origin
|
|
@@ -189,7 +336,13 @@ export async function runServerDeploy(api, entityPath) {
|
|
|
189
336
|
// flag is absent, so the GO admin panel's Redeploy and the builder's
|
|
190
337
|
// Save can share this route without converting user-built apps. See
|
|
191
338
|
// app/routes/deploy.js.
|
|
192
|
-
|
|
339
|
+
// Presenting the staged id is what authorises the server to promote that
|
|
340
|
+
// upload. Omitting it (the _clear fallback path) leaves the server
|
|
341
|
+
// deploying the live library, which is the pre-staging behaviour.
|
|
342
|
+
const result = await api.post(`${entityPath}/_deploy`, {
|
|
343
|
+
managed: true,
|
|
344
|
+
...(stagingLibraryId ? { stagingLibraryId } : {})
|
|
345
|
+
});
|
|
193
346
|
if (result === null) {
|
|
194
347
|
// api.post turns a 404 into null instead of throwing, so this is the
|
|
195
348
|
// only place it can be caught. Files uploaded, but migrations, server
|
package/src/dev-bag.js
CHANGED
|
@@ -222,7 +222,7 @@ export function createDevBagBuilder({ serverOrigin, authHeader, devWorkspaceId,
|
|
|
222
222
|
async function build({ forwarding = null } = {}) {
|
|
223
223
|
const manifest = await loadManifest(projectRoot);
|
|
224
224
|
const deps = manifestBlock(manifest, 'dependencies');
|
|
225
|
-
const context = buildDevContext({ deps, apiFetch, devBindings, appFetch, forwarding });
|
|
225
|
+
const context = buildDevContext({ deps, apiFetch, devBindings, appFetch, forwarding, serverOrigin });
|
|
226
226
|
const env = manifestBlock(manifest, 'env');
|
|
227
227
|
|
|
228
228
|
// emit() writes no app_event row in dev, but still relays a listed
|
|
@@ -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
|
+
}
|
|
@@ -1,10 +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
5
|
import { devPlatform } from './dev-platform.js';
|
|
6
6
|
import { filePathToRoute, matchRoute, walkJsFiles } from './server-routes.js';
|
|
7
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';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* The dev counterpart of app-channel-handlers.js: a page's `channel()` mock
|
|
@@ -12,8 +13,9 @@ import { createDevChannels, isChannelName, isWildcardName, isEventName, isReserv
|
|
|
12
13
|
* the matching `channels/` file runs in-process the way a deployed handler
|
|
13
14
|
* runs in the sandbox — `join` decides admission, `joined` runs after it,
|
|
14
15
|
* `leave` runs on unsubscribe, and an event-named export answers `send()`.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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.
|
|
17
19
|
*/
|
|
18
20
|
|
|
19
21
|
export const CHANNELS_DIR = 'channels';
|
|
@@ -23,6 +25,10 @@ export const CHANNEL_METHOD = 'CHANNEL';
|
|
|
23
25
|
export const HANDLER_TIMEOUT_MS = 5000;
|
|
24
26
|
// Per-user inbound budget (token bucket): send() and the replay reads a reconnect makes.
|
|
25
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'];
|
|
26
32
|
// Exports with a fixed meaning; every other export must be an event name.
|
|
27
33
|
export const LIFECYCLE_EXPORTS = Object.freeze(['config', 'join', 'joined', 'leave']);
|
|
28
34
|
|
|
@@ -156,10 +162,39 @@ export function createDevChannelHandlers(viteServer, { serverOrigin, authHeader,
|
|
|
156
162
|
// handler reading platform.capabilities.embeddings must not disagree with
|
|
157
163
|
// the route beside it, and its embed() must be bound when they agree.
|
|
158
164
|
const bagBuilder = createDevBagBuilder({ serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings, appToken, channels, logPrefix: '[app]', platform, appId });
|
|
159
|
-
// clientId → channel name → { clientId, channel, params, file }
|
|
165
|
+
// clientId → channel name → { clientId, channel, params, file, actor }
|
|
160
166
|
const subscriptions = new Map();
|
|
161
|
-
// username → { tokens, ts }
|
|
167
|
+
// username (or `actor:` + username) → { tokens, ts }
|
|
162
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 });
|
|
163
198
|
|
|
164
199
|
function recordsFor(clientId) {
|
|
165
200
|
let records = subscriptions.get(clientId);
|
|
@@ -170,14 +205,15 @@ export function createDevChannelHandlers(viteServer, { serverOrigin, authHeader,
|
|
|
170
205
|
return records;
|
|
171
206
|
}
|
|
172
207
|
|
|
173
|
-
function takeSendToken(username) {
|
|
208
|
+
function takeSendToken(username, budget = rate) {
|
|
174
209
|
const at = now();
|
|
175
|
-
|
|
210
|
+
const key = budget === rate ? username : `actor:${username}`;
|
|
211
|
+
let bucket = buckets.get(key);
|
|
176
212
|
if (!bucket) {
|
|
177
|
-
bucket = { tokens:
|
|
178
|
-
buckets.set(
|
|
213
|
+
bucket = { tokens: budget.burst, ts: at };
|
|
214
|
+
buckets.set(key, bucket);
|
|
179
215
|
}
|
|
180
|
-
bucket.tokens = Math.min(
|
|
216
|
+
bucket.tokens = Math.min(budget.burst, bucket.tokens + (Math.max(0, at - bucket.ts) / 1000) * budget.perSecond);
|
|
181
217
|
bucket.ts = at;
|
|
182
218
|
if (bucket.tokens < 1) return false;
|
|
183
219
|
bucket.tokens -= 1;
|
|
@@ -209,13 +245,13 @@ export function createDevChannelHandlers(viteServer, { serverOrigin, authHeader,
|
|
|
209
245
|
|
|
210
246
|
// Run one export with the channel bag under the handler's timeout. A thrown
|
|
211
247
|
// channel error keeps its status; anything else is 500 app_channel_<export>_failed.
|
|
212
|
-
async function runExport(mod, exportName, { channel, params, payload }) {
|
|
248
|
+
async function runExport(mod, exportName, { channel, params, payload, clientId }) {
|
|
213
249
|
const { bag } = await bagBuilder.build();
|
|
214
250
|
const invocation = {
|
|
215
251
|
...bag,
|
|
216
252
|
channel: { name: channel, params, broadcast: async (event, body, options) => await bag.broadcast(channel, event, body, options) },
|
|
217
253
|
payload: payload === undefined ? null : payload,
|
|
218
|
-
request:
|
|
254
|
+
request: requestFor(clientId)
|
|
219
255
|
};
|
|
220
256
|
const cap = Math.min(Number(mod.config && mod.config.timeout) || timeoutMs, timeoutMs);
|
|
221
257
|
let timer;
|
|
@@ -252,12 +288,17 @@ export function createDevChannelHandlers(viteServer, { serverOrigin, authHeader,
|
|
|
252
288
|
if (Array.isArray(required) && required.length > 0 && !required.some(r => roles.includes(r))) {
|
|
253
289
|
throw channelError('app_channel_role_required', 403);
|
|
254
290
|
}
|
|
255
|
-
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') {
|
|
256
297
|
// admitted only when the handler returned exactly true
|
|
257
298
|
if (await runExport(mod, 'join', record) !== true) throw channelError('app_channel_join_refused', 403);
|
|
258
299
|
}
|
|
259
300
|
recordsFor(clientId).set(channel, record);
|
|
260
|
-
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)'}`);
|
|
261
302
|
return mod;
|
|
262
303
|
}
|
|
263
304
|
|
|
@@ -267,6 +308,10 @@ export function createDevChannelHandlers(viteServer, { serverOrigin, authHeader,
|
|
|
267
308
|
if (!record) return;
|
|
268
309
|
records.delete(channel);
|
|
269
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
|
+
}
|
|
270
315
|
try {
|
|
271
316
|
const mod = await viteServer.ssrLoadModule(record.file.filePath);
|
|
272
317
|
if (typeof mod.leave === 'function') await runExport(mod, 'leave', record);
|
|
@@ -282,6 +327,12 @@ export function createDevChannelHandlers(viteServer, { serverOrigin, authHeader,
|
|
|
282
327
|
if (!isEventName(event)) throw channelError('app_channel_invalid_event', 400);
|
|
283
328
|
if (isReservedEvent(event)) throw channelError('app_channel_reserved_event', 400);
|
|
284
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
|
+
}
|
|
285
336
|
const mod = await viteServer.ssrLoadModule(record.file.filePath);
|
|
286
337
|
if (LIFECYCLE_EXPORTS.includes(event) || typeof mod[event] !== 'function') throw channelError('app_channel_no_handler', 404);
|
|
287
338
|
if (!takeSendToken(devUser.username)) throw channelError('app_channel_rate_limited', 429);
|
|
@@ -290,7 +341,7 @@ export function createDevChannelHandlers(viteServer, { serverOrigin, authHeader,
|
|
|
290
341
|
return result === undefined ? null : result;
|
|
291
342
|
}
|
|
292
343
|
|
|
293
|
-
|
|
344
|
+
async function devChannelHandlersMiddleware(req, res, next) {
|
|
294
345
|
const parsed = parseUrl(req.url, true);
|
|
295
346
|
const route = `${req.method} ${parsed.pathname}`;
|
|
296
347
|
let body = {};
|
|
@@ -315,7 +366,10 @@ export function createDevChannelHandlers(viteServer, { serverOrigin, authHeader,
|
|
|
315
366
|
if (mod && typeof mod.joined === 'function') {
|
|
316
367
|
const record = recordsFor(body.clientId).get(body.channel);
|
|
317
368
|
setImmediate(() => {
|
|
318
|
-
|
|
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}`));
|
|
319
373
|
});
|
|
320
374
|
}
|
|
321
375
|
return;
|
|
@@ -324,12 +378,16 @@ export function createDevChannelHandlers(viteServer, { serverOrigin, authHeader,
|
|
|
324
378
|
await unsubscribe(body);
|
|
325
379
|
return sendJson(res, 200, { ok: true });
|
|
326
380
|
}
|
|
327
|
-
|
|
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() });
|
|
328
383
|
} catch (err) {
|
|
329
384
|
const status = typeof err.statusCode === 'number' ? err.statusCode : 500;
|
|
330
385
|
if (status === 500) console.error(logPrefix, err);
|
|
331
386
|
else if (route === 'POST /subscribe') console.log(`${logPrefix} join("${body.channel}") refused (${err.code || err.message})`);
|
|
332
387
|
sendJson(res, status, { error: err.code || err.message });
|
|
333
388
|
}
|
|
334
|
-
}
|
|
389
|
+
}
|
|
390
|
+
// the running actors, for tests and the terminal
|
|
391
|
+
devChannelHandlersMiddleware.actors = actors;
|
|
392
|
+
return devChannelHandlersMiddleware;
|
|
335
393
|
}
|
package/src/dev-channel-shim.js
CHANGED
|
@@ -134,6 +134,30 @@ export function generateDevChannelShim() {
|
|
|
134
134
|
hot.on('vite:ws:connect', subscribeAll);
|
|
135
135
|
}
|
|
136
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
|
+
|
|
137
161
|
function Channel(name, opts) {
|
|
138
162
|
this.name = name;
|
|
139
163
|
// null-prototype: an event name admits 'constructor' and 'toString',
|
|
@@ -355,6 +379,10 @@ export function generateDevChannelShim() {
|
|
|
355
379
|
}).catch(function () {});
|
|
356
380
|
};
|
|
357
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
|
+
|
|
358
386
|
informer.channel = function (name, opts) {
|
|
359
387
|
if (typeof name !== 'string' || name.length > CHANNEL_NAME_MAX_LENGTH || !CHANNEL_NAME.test(name)) {
|
|
360
388
|
throw channelError('join_refused', 'Invalid channel name: ' + name);
|
package/src/dev-dependencies.js
CHANGED
|
@@ -165,8 +165,8 @@ const METHOD_SURFACE = {
|
|
|
165
165
|
query: ['execute'],
|
|
166
166
|
datasource: ['query'],
|
|
167
167
|
integration: ['request'],
|
|
168
|
-
app: ['request'],
|
|
169
|
-
pack: ['request']
|
|
168
|
+
app: ['request', 'url'],
|
|
169
|
+
pack: ['request', 'url']
|
|
170
170
|
};
|
|
171
171
|
|
|
172
172
|
// Cross-app request() method allow-list — mirrors REQUEST_METHODS in
|
|
@@ -395,7 +395,7 @@ export function resolveAppBinding(binding) {
|
|
|
395
395
|
// to the server constant by test/streams-parity.test.js.
|
|
396
396
|
export const DEV_FORWARD_MAX_BYTES = Math.floor(52428800 * 3 / 4);
|
|
397
397
|
|
|
398
|
-
export function buildDevContext({ deps, apiFetch, devBindings = {}, appFetch = null, forwarding = null }) {
|
|
398
|
+
export function buildDevContext({ deps, apiFetch, devBindings = {}, appFetch = null, forwarding = null, serverOrigin = null }) {
|
|
399
399
|
const context = {};
|
|
400
400
|
for (const [name, decl] of Object.entries(deps || {})) {
|
|
401
401
|
if (!decl || typeof decl !== 'object') continue;
|
|
@@ -403,11 +403,10 @@ export function buildDevContext({ deps, apiFetch, devBindings = {}, appFetch = n
|
|
|
403
403
|
if (!VALID_TARGETS.has(target)) continue;
|
|
404
404
|
|
|
405
405
|
// App and pack slots bind from the plugin's `devBindings` (a human
|
|
406
|
-
// `owner:slug` pointing request() at the target app). App
|
|
407
|
-
// back to the manifest `defaultBinding` UUID; pack slots
|
|
408
|
-
// fallback — the pin resolves via marketplace installs, which
|
|
409
|
-
// doesn't have, so the devBinding names the locally-installed app.
|
|
410
|
-
// request() is the only surface either way.
|
|
406
|
+
// `owner:slug` pointing request() and url() at the target app). App
|
|
407
|
+
// slots fall back to the manifest `defaultBinding` UUID; pack slots
|
|
408
|
+
// have no fallback — the pin resolves via marketplace installs, which
|
|
409
|
+
// dev doesn't have, so the devBinding names the locally-installed app.
|
|
411
410
|
if (target === 'app' || target === 'pack') {
|
|
412
411
|
const fallback = (target === 'app'
|
|
413
412
|
&& typeof decl.defaultBinding === 'string' && UUID_PATTERN.test(decl.defaultBinding))
|
|
@@ -415,7 +414,7 @@ export function buildDevContext({ deps, apiFetch, devBindings = {}, appFetch = n
|
|
|
415
414
|
: null;
|
|
416
415
|
const binding = devBindings[name] != null ? devBindings[name] : fallback;
|
|
417
416
|
context[name] = binding
|
|
418
|
-
? makeAppDevProxy({ name, binding, appFetch, kind: target })
|
|
417
|
+
? makeAppDevProxy({ name, binding, appFetch, serverOrigin, kind: target })
|
|
419
418
|
: makeUnboundDevProxy({ name, target });
|
|
420
419
|
continue;
|
|
421
420
|
}
|
|
@@ -432,12 +431,31 @@ export function buildDevContext({ deps, apiFetch, devBindings = {}, appFetch = n
|
|
|
432
431
|
}
|
|
433
432
|
|
|
434
433
|
/**
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
434
|
+
* Append an app-relative path, query string, or fragment to a view URL, or
|
|
435
|
+
* null when the result would leave the view. Mirrors joinViewPath in
|
|
436
|
+
* informer-server's modules/app/lib/app-launch.js so dev refuses the same
|
|
437
|
+
* paths prod 400s; exported so test/streams-parity.test.js can hold the two
|
|
438
|
+
* copies to the same input table.
|
|
439
|
+
*
|
|
440
|
+
* Unguarded, as the server copy is: no `path` makes the parse throw, so the
|
|
441
|
+
* one thing that can is a malformed INFORMER_URL, and that deserves to read
|
|
442
|
+
* as the config error it is rather than as a claim about '..' segments.
|
|
443
|
+
*/
|
|
444
|
+
export function joinViewPath(viewUrl, path) {
|
|
445
|
+
if (!path) return viewUrl;
|
|
446
|
+
const joined = /^[?#]/.test(path) ? `${viewUrl}${path}` : `${viewUrl}/${path.replace(/^\/+/, '')}`;
|
|
447
|
+
const base = new URL(viewUrl, 'http://localhost').pathname;
|
|
448
|
+
const resolved = new URL(joined, 'http://localhost').pathname;
|
|
449
|
+
return resolved === base || resolved.startsWith(`${base}/`) ? joined : null;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Dev proxy for a `target: app` or `target: pack` slot, with prod's
|
|
454
|
+
* `request()` and `url()` surface. Pack slots reuse this wholesale — in
|
|
455
|
+
* production the pack driver resolves its marketplace pin and then delegates
|
|
456
|
+
* the runtime to the app driver, and the devBinding IS that resolution done
|
|
457
|
+
* by hand. The prod version gate (pack_dependency_out_of_range) is not
|
|
458
|
+
* emulated: dev has no pack_install to read a version from.
|
|
441
459
|
*
|
|
442
460
|
* Production runs `request()` through the target's own /view/_/ dispatch, and
|
|
443
461
|
* dev injects into the same route:
|
|
@@ -452,16 +470,21 @@ export function buildDevContext({ deps, apiFetch, devBindings = {}, appFetch = n
|
|
|
452
470
|
* Non-JSON success responses follow the prod envelope contract: text/HTML
|
|
453
471
|
* returns a text envelope; binary is rejected (dev can't emulate it yet).
|
|
454
472
|
*
|
|
473
|
+
* url(path) -> <INFORMER_URL>/api/apps/<app>/view[/path | ?query | #fragment]
|
|
474
|
+
* The target's view URL on the configured server, for a page to open.
|
|
475
|
+
*
|
|
455
476
|
* @param {Object} args
|
|
456
477
|
* @param {string} args.name - dependency slot name
|
|
457
478
|
* @param {string|{app?: string}} args.binding - devBindings entry (or the
|
|
458
479
|
* manifest defaultBinding UUID). A bare string is shorthand for `{ app }`.
|
|
459
480
|
* @param {Function|null} args.appFetch - token-authed fetch, or null when
|
|
460
481
|
* INFORMER_APP_TOKEN is unset.
|
|
482
|
+
* @param {string|null} [args.serverOrigin] - INFORMER_URL, the server url()
|
|
483
|
+
* links point at.
|
|
461
484
|
* @param {'app'|'pack'} [args.kind] - slot flavor, for error labels and the
|
|
462
485
|
* structured resourceType guest code branches on.
|
|
463
486
|
*/
|
|
464
|
-
function makeAppDevProxy({ name, binding, appFetch, kind = 'app' }) {
|
|
487
|
+
function makeAppDevProxy({ name, binding, appFetch, serverOrigin = null, kind = 'app' }) {
|
|
465
488
|
const { app } = resolveAppBinding(binding);
|
|
466
489
|
|
|
467
490
|
return {
|
|
@@ -522,6 +545,25 @@ function makeAppDevProxy({ name, binding, appFetch, kind = 'app' }) {
|
|
|
522
545
|
return { status, body, contentType: contentType || '', headers: { 'content-type': contentType || '' } };
|
|
523
546
|
}
|
|
524
547
|
return body;
|
|
548
|
+
},
|
|
549
|
+
|
|
550
|
+
async url(path) {
|
|
551
|
+
if (!app) {
|
|
552
|
+
throw new Error(
|
|
553
|
+
`Dependency "${name}" (${kind}): dev url() needs the target app — set devBindings.${name}.app (e.g. 'admin:kanban')`
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
if (!serverOrigin) {
|
|
557
|
+
throw new Error(`Dependency "${name}" (${kind}): dev url() needs INFORMER_URL, the server its links point at`);
|
|
558
|
+
}
|
|
559
|
+
if (path !== undefined && path !== null && typeof path !== 'string') {
|
|
560
|
+
throw new Error(`Dependency "${name}" (${kind}): url(path) takes a string`);
|
|
561
|
+
}
|
|
562
|
+
const href = joinViewPath(`${serverOrigin}/api/apps/${encodeURIComponent(app)}/view`, path);
|
|
563
|
+
if (!href) {
|
|
564
|
+
throw new Error(`Dependency "${name}" (${kind}): url(path) must not escape the target app with ".." path segments`);
|
|
565
|
+
}
|
|
566
|
+
return href;
|
|
525
567
|
}
|
|
526
568
|
};
|
|
527
569
|
}
|
package/src/dev-platform.js
CHANGED
|
@@ -31,6 +31,8 @@ export const DEV_CAPABILITIES = Object.freeze({
|
|
|
31
31
|
// live channels: broadcast() in the sandbox, the `channels:` relay block,
|
|
32
32
|
// and channels/ join/leave handlers — the dev server mirrors all three.
|
|
33
33
|
channels: true,
|
|
34
|
+
// channels/ files with config.actor: one live instance per channel (dev-channel-actors.js)
|
|
35
|
+
channelActors: true,
|
|
34
36
|
embeddings: false
|
|
35
37
|
});
|
|
36
38
|
|
package/src/openapi-to-dts.js
CHANGED
|
@@ -109,6 +109,10 @@ function slotInterface (iface, spec) {
|
|
|
109
109
|
return [
|
|
110
110
|
`export interface ${iface} {`,
|
|
111
111
|
overloads.join('\n'),
|
|
112
|
+
` /** Link into the target app's UI: its view root, or a path, query or`,
|
|
113
|
+
` * fragment under it. Absolute wherever the deployment names an`,
|
|
114
|
+
` * external address — always, when apps serve from their own origins. */`,
|
|
115
|
+
' url(path?: string): Promise<string>;',
|
|
112
116
|
'}'
|
|
113
117
|
].join('\n');
|
|
114
118
|
}
|
package/src/server-routes.js
CHANGED
|
@@ -164,8 +164,8 @@ async function walkJsFiles(dir, basePath) {
|
|
|
164
164
|
* descriptor (mirrors app-sandbox.js buildInvokeScript / respondCallback).
|
|
165
165
|
* The encoding allow-list and contract checks are enforced inside the ivm in
|
|
166
166
|
* production; replicated here so handlers see the same errors in dev (where
|
|
167
|
-
* there's no isolate to attribute the throw to).
|
|
168
|
-
*
|
|
167
|
+
* there's no isolate to attribute the throw to). The header rule is shared
|
|
168
|
+
* with prod's modules/app/lib/handler-headers.js — change both.
|
|
169
169
|
*/
|
|
170
170
|
function sendDescriptor(res, result) {
|
|
171
171
|
const encoding = typeof result.encoding === 'string' ? result.encoding : null;
|
|
@@ -177,28 +177,38 @@ function sendDescriptor(res, result) {
|
|
|
177
177
|
}
|
|
178
178
|
const responseBody = result.body !== undefined ? result.body : null;
|
|
179
179
|
|
|
180
|
+
// Validate before writing anything: prod rejects a malformed body while
|
|
181
|
+
// building the response, so nothing of the handler's reaches the client. Left
|
|
182
|
+
// below the header loop, a 500 here still carried the handler's
|
|
183
|
+
// Content-Disposition and the browser saved a .pdf full of JSON.
|
|
184
|
+
if (encoding === 'base64' && !isBase64(responseBody)) {
|
|
185
|
+
throw new Error('Handler returned malformed base64 body');
|
|
186
|
+
}
|
|
187
|
+
|
|
180
188
|
res.statusCode = result.status || 200;
|
|
189
|
+
// Lowercased for wire parity with Hapi. Node's header lookup is
|
|
190
|
+
// case-insensitive either way, which is why dev never reproduced I5-12972.
|
|
181
191
|
for (const [key, value] of Object.entries(result.headers || {})) {
|
|
182
|
-
res.setHeader(key, value);
|
|
192
|
+
res.setHeader(key.toLowerCase(), value);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Same rule as prod's applyHandlerHeaders, and applied before the body is
|
|
196
|
+
// branched on so it covers base64 too. Dev used to leave a binary response
|
|
197
|
+
// unlabelled, which the browser sniffed happily while prod shipped it as
|
|
198
|
+
// application/json — the I5-12972 failure, arrived at from the other side.
|
|
199
|
+
const declaredType = res.getHeader('content-type');
|
|
200
|
+
if (typeof declaredType !== 'string' || !declaredType.trim()) {
|
|
201
|
+
res.setHeader('content-type', 'application/json');
|
|
183
202
|
}
|
|
184
203
|
|
|
185
204
|
if (responseBody === null) {
|
|
186
205
|
res.end();
|
|
187
206
|
} else if (encoding === 'base64') {
|
|
188
|
-
if (!isBase64(responseBody)) {
|
|
189
|
-
throw new Error('Handler returned malformed base64 body');
|
|
190
|
-
}
|
|
191
207
|
res.end(Buffer.from(responseBody, 'base64'));
|
|
192
208
|
} else if (typeof responseBody === 'string') {
|
|
193
209
|
// Strings go out verbatim, as prod passes them.
|
|
194
|
-
if (!res.getHeader('content-type')) {
|
|
195
|
-
res.setHeader('Content-Type', 'application/json');
|
|
196
|
-
}
|
|
197
210
|
res.end(responseBody);
|
|
198
211
|
} else {
|
|
199
|
-
if (!res.getHeader('content-type')) {
|
|
200
|
-
res.setHeader('Content-Type', 'application/json');
|
|
201
|
-
}
|
|
202
212
|
res.end(JSON.stringify(responseBody));
|
|
203
213
|
}
|
|
204
214
|
}
|