@entrinsik/vite-plugin-informer 2.11.0 → 2.12.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 +5 -3
- package/bin/init.js +13 -2
- package/index.d.ts +14 -0
- package/package.json +1 -1
- package/src/agent-dev.js +8 -11
- package/src/dev-bag.js +75 -19
- package/src/dev-channel-handlers.js +23 -13
- package/src/dev-channel-shim.js +37 -11
- package/src/dev-channels.js +33 -15
- package/src/dev-dependencies.js +140 -4
- package/src/dev-platform.js +80 -7
- package/src/dev-streams.js +176 -27
- package/src/index.js +61 -8
- package/src/server-routes.js +4 -3
- package/src/streams-client.js +121 -8
package/README.md
CHANGED
|
@@ -13,6 +13,8 @@ npm run dev # local dev against the Informer in .
|
|
|
13
13
|
npm run deploy # assemble + upload + deploy
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
+
`informer-init` leaves an existing `@entrinsik/vite-plugin-informer` entry alone, and writes one only when the dependency is missing — in which case it uses its own version (2.12.0 and later; before that it wrote `^1.0.0`). A project scaffolded earlier still carries `^1.0.0`, which resolves to 1.0.5 and predates everything below: bump it by hand.
|
|
17
|
+
|
|
16
18
|
## What a deploy ships
|
|
17
19
|
|
|
18
20
|
The assembler uploads your built frontend plus these source trees, matching how an App's library is structured in Informer:
|
|
@@ -44,7 +46,7 @@ Against an older server the CLI:
|
|
|
44
46
|
|
|
45
47
|
A server whose version isn't comparable (a `dev` build, or an `/about` behind a proxy) is treated as current and warned about — gating it would break deploys to builds made from source.
|
|
46
48
|
|
|
47
|
-
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.
|
|
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.
|
|
48
50
|
|
|
49
51
|
Feature-detect at runtime with optional chaining, since `platform` is absent entirely before 2026.1.3:
|
|
50
52
|
|
|
@@ -118,11 +120,11 @@ const hits = await query(
|
|
|
118
120
|
|
|
119
121
|
### Dev loop
|
|
120
122
|
|
|
121
|
-
`npm run deploy` uploads `embeddings/` like any other source tree — there is no manifest block to add (plugin ≥ 2.10.0; earlier versions never upload the folder, so the use case silently does not exist on the server). `npm run dev` never runs the pump, and its `embed()` throws an explanation rather than embedding (the dev mirror reports `platform.capabilities.embeddings: false`), so a search route is exercised against a deployed App. Feature-detect with `platform?.capabilities?.embeddings` — `typeof embed === 'function'` is true on both, since the binding exists either way. Then, in the App admin panel, the **Embeddings** tab lists each use case with its pump status (queued, running, up to date, last run, last error, skipped docs) and a **Run now** action; the same surface exists as API routes (`GET /apps/{id}/embeddings`, `POST /apps/{id}/embeddings/{name}/_run`). Deploy, run, read the error, fix, repeat.
|
|
123
|
+
`npm run deploy` uploads `embeddings/` like any other source tree — there is no manifest block to add (plugin ≥ 2.10.0; earlier versions never upload the folder, so the use case silently does not exist on the server). `npm run dev` never runs the pump, and its `embed()` throws an explanation rather than embedding (the dev mirror reports `platform.capabilities.embeddings: false`), so a search route is exercised against a deployed App. Plugin ≥ 2.12.0 can opt in: `informer({ mock: { platform: { capabilities: { embeddings: true } } } })` binds the dev `embed()` to the deployed App's `_embed` route on the configured server (addressed by package.json `informer.id`; the dev credentials need write access to that App, and each call is billed to it), so the query vector under `npm run dev` is the deployed one — same model, same `revision`. The corpus is not: `query()` still reads the dev workspace datasource, which the pump never writes to, so a search route compares a deployed vector against local rows. Feature-detect with `platform?.capabilities?.embeddings` — `typeof embed === 'function'` is true on both, since the binding exists either way. Then, in the App admin panel, the **Embeddings** tab lists each use case with its pump status (queued, running, up to date, last run, last error, skipped docs) and a **Run now** action; the same surface exists as API routes (`GET /apps/{id}/embeddings`, `POST /apps/{id}/embeddings/{name}/_run`). Deploy, run, read the error, fix, repeat.
|
|
122
124
|
|
|
123
125
|
### Upgrading an App that already has an `embeddings/` folder
|
|
124
126
|
|
|
125
|
-
`embeddings/` is a server-side folder from
|
|
127
|
+
`embeddings/` is a server-side folder from Informer 2026.1.3 on, like `server/`: its files are uploaded with the library, never served to browsers, and scanned as pump handlers. An App that kept anything else there (assets, data files) loses those files in the browser after redeploying with plugin 2.10.0 or later. A `.js` file there that exports `GET` or `POST` but not both fails the deploy; one that exports neither is never scanned as a use case at all and lands as a warning on an otherwise successful deploy. Move such content elsewhere before upgrading; the deploy names every stray entry as a warning.
|
|
126
128
|
|
|
127
129
|
### Older Informer releases
|
|
128
130
|
|
package/bin/init.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { readFile, writeFile,
|
|
3
|
+
import { readFile, writeFile, access } from 'node:fs/promises';
|
|
4
4
|
import { resolve, basename } from 'node:path';
|
|
5
5
|
import { createInterface } from 'node:readline';
|
|
6
6
|
import { randomUUID } from 'node:crypto';
|
|
@@ -118,6 +118,16 @@ function prompt(question, defaultValue) {
|
|
|
118
118
|
});
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* The devDependency range a fresh scaffold gets. Read inside init() rather than
|
|
123
|
+
* at module scope so a failure reaches init()'s error handler.
|
|
124
|
+
*/
|
|
125
|
+
async function pluginVersionRange() {
|
|
126
|
+
const { version } = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
127
|
+
if (!version) throw new Error('could not read the version of @entrinsik/vite-plugin-informer; reinstall the plugin');
|
|
128
|
+
return `^${version}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
121
131
|
/**
|
|
122
132
|
* Convert package name to friendly display name.
|
|
123
133
|
* "magic-quickbooks-report" -> "Magic Quickbooks Report"
|
|
@@ -174,7 +184,8 @@ async function init() {
|
|
|
174
184
|
// 5. Add plugin to dependencies if not present
|
|
175
185
|
if (!pkg.devDependencies) pkg.devDependencies = {};
|
|
176
186
|
if (!pkg.devDependencies['@entrinsik/vite-plugin-informer']) {
|
|
177
|
-
|
|
187
|
+
// Float the scaffold on the version that scaffolded it, not a fixed range.
|
|
188
|
+
pkg.devDependencies['@entrinsik/vite-plugin-informer'] = await pluginVersionRange();
|
|
178
189
|
console.log('Added @entrinsik/vite-plugin-informer to devDependencies');
|
|
179
190
|
}
|
|
180
191
|
|
package/index.d.ts
CHANGED
|
@@ -15,11 +15,25 @@ export interface AppDevBinding {
|
|
|
15
15
|
app?: string;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* What the platform offers the app, as the server injects it
|
|
20
|
+
* (`window.__INFORMER__.platform`). A mock override merges into the dev
|
|
21
|
+
* defaults, so naming one capability leaves the rest alone.
|
|
22
|
+
*/
|
|
23
|
+
export interface MockPlatform {
|
|
24
|
+
version?: string;
|
|
25
|
+
originMode?: boolean;
|
|
26
|
+
capabilities?: Record<string, boolean>;
|
|
27
|
+
}
|
|
28
|
+
|
|
18
29
|
export interface InformerPluginOptions {
|
|
19
30
|
mock?: {
|
|
20
31
|
report?: { id?: string; name?: string };
|
|
21
32
|
theme?: 'light' | 'dark';
|
|
22
33
|
roles?: string[];
|
|
34
|
+
/** The viewer identity, for testing `@user/<username>` channels as someone else. */
|
|
35
|
+
user?: { username?: string; displayName?: string };
|
|
36
|
+
platform?: MockPlatform;
|
|
23
37
|
};
|
|
24
38
|
devBindings?: Record<string, string | AppDevBinding>;
|
|
25
39
|
proxy?: Record<string, unknown>;
|
package/package.json
CHANGED
package/src/agent-dev.js
CHANGED
|
@@ -4,15 +4,7 @@ import { parse as parseUrl } from 'node:url';
|
|
|
4
4
|
import yaml from 'yaml';
|
|
5
5
|
import { manifestBlock, buildDevContext, buildDevCrypto, buildDevMessaging, normalizeFetchPath } from './dev-dependencies.js';
|
|
6
6
|
import { createDevChannels, createDevEmit } from './dev-channels.js';
|
|
7
|
-
import { devPlatform } from './dev-platform.js';
|
|
8
|
-
|
|
9
|
-
// embed() is present on a real install even without the embeddings
|
|
10
|
-
// capability, where it throws a written explanation. Mirroring that here
|
|
11
|
-
// keeps the dev failure the same lesson as the deployed one, instead of a
|
|
12
|
-
// bare "embed is not a function" that reads like a missing binding.
|
|
13
|
-
const embed = async () => {
|
|
14
|
-
throw new Error('embed() is not available in the dev mirror: the embeddings capability needs a real Informer (platform.capabilities.embeddings is false)');
|
|
15
|
-
};
|
|
7
|
+
import { devPlatform, createDevEmbed } from './dev-platform.js';
|
|
16
8
|
|
|
17
9
|
const parseYaml = yaml.parse;
|
|
18
10
|
|
|
@@ -164,7 +156,7 @@ async function readSSE(response) {
|
|
|
164
156
|
* behind `broadcast()` and the `channels:` relay (a private one when omitted)
|
|
165
157
|
* @returns {Function} Connect middleware
|
|
166
158
|
*/
|
|
167
|
-
export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings, appToken, channels = createDevChannels() }) {
|
|
159
|
+
export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings, appToken, channels = createDevChannels(), platform = devPlatform(), appId = null }) {
|
|
168
160
|
|
|
169
161
|
// query() — proxies to the workspace _sql endpoint (same as server-routes.js)
|
|
170
162
|
async function query(sql, params) {
|
|
@@ -223,6 +215,11 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
|
|
|
223
215
|
return { status, body, contentType };
|
|
224
216
|
}
|
|
225
217
|
|
|
218
|
+
// Throws the written explanation unless the mock platform opts into
|
|
219
|
+
// embeddings; then posts to the deployed app's _embed route through
|
|
220
|
+
// apiFetch (hoisted below). See createDevEmbed.
|
|
221
|
+
const embed = createDevEmbed({ platform, apiFetch, appId });
|
|
222
|
+
|
|
226
223
|
async function apiFetch(path, opts = {}) {
|
|
227
224
|
return await fetchAs(authHeader, path, opts);
|
|
228
225
|
}
|
|
@@ -455,7 +452,7 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
|
|
|
455
452
|
|
|
456
453
|
if (tool) {
|
|
457
454
|
try {
|
|
458
|
-
result = await tool.handler({ args: tc.input, run: { agentName, trigger: triggerEvent }, context, query, fetch: apiFetch, emit, broadcast, notify, email, embed, crypto: cryptoHelper, markdown, log, env, platform
|
|
455
|
+
result = await tool.handler({ args: tc.input, run: { agentName, trigger: triggerEvent }, context, query, fetch: apiFetch, emit, broadcast, notify, email, embed, crypto: cryptoHelper, markdown, log, env, platform });
|
|
459
456
|
} catch (err) {
|
|
460
457
|
console.error(`[agent-dev] Tool "${tc.name}" failed:`, err.message);
|
|
461
458
|
result = { error: err.message };
|
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 });
|
|
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
|
|
|
@@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { parse as parseUrl } from 'node:url';
|
|
4
4
|
import { createDevBagBuilder, buildDevUser } from './dev-bag.js';
|
|
5
|
+
import { devPlatform } from './dev-platform.js';
|
|
5
6
|
import { filePathToRoute, matchRoute, walkJsFiles } from './server-routes.js';
|
|
6
7
|
import { createDevChannels, isChannelName, isWildcardName, isEventName, isReservedEvent, channelError, USER_CHANNEL_PREFIX } from './dev-channels.js';
|
|
7
8
|
|
|
@@ -20,7 +21,7 @@ export const CHANNELS_DIR = 'channels';
|
|
|
20
21
|
export const CHANNEL_METHOD = 'CHANNEL';
|
|
21
22
|
// The deployment's joinTimeoutMs cap; a file's `config.timeout` may only lower it.
|
|
22
23
|
export const HANDLER_TIMEOUT_MS = 5000;
|
|
23
|
-
// Per-user inbound
|
|
24
|
+
// Per-user inbound budget (token bucket): send() and the replay reads a reconnect makes.
|
|
24
25
|
export const SEND_RATE = Object.freeze({ perSecond: 10, burst: 30 });
|
|
25
26
|
// Exports with a fixed meaning; every other export must be an event name.
|
|
26
27
|
export const LIFECYCLE_EXPORTS = Object.freeze(['config', 'join', 'joined', 'leave']);
|
|
@@ -101,11 +102,13 @@ export async function validateChannelHandlers(projectRoot) {
|
|
|
101
102
|
return problems;
|
|
102
103
|
}
|
|
103
104
|
|
|
104
|
-
// Does a handler route cover some channel
|
|
105
|
-
// (`/rooms/:room` and `/rooms/east/members` both cover names
|
|
106
|
-
|
|
105
|
+
// Does a handler route cover some channel deeper than `minDepth` segments
|
|
106
|
+
// under `prefix`? (`/rooms/:room` and `/rooms/east/members` both cover names
|
|
107
|
+
// under `rooms/`.) `minDepth` is what the wildcard's own handler already gates:
|
|
108
|
+
// the prefix length for an unmatched wildcard, one more for a matched one.
|
|
109
|
+
function coversUnder(routePath, prefix, minDepth) {
|
|
107
110
|
const segments = routePath.split('/').filter(Boolean);
|
|
108
|
-
return segments.length >
|
|
111
|
+
return segments.length > minDepth && prefix.every((seg, i) => segments[i].startsWith(':') || segments[i] === seg);
|
|
109
112
|
}
|
|
110
113
|
|
|
111
114
|
function readJson(req) {
|
|
@@ -147,9 +150,12 @@ function sendJson(res, status, body) {
|
|
|
147
150
|
* @param {() => number} [opts.now] - clock, for the rate limiter
|
|
148
151
|
* @returns {Function} Connect middleware
|
|
149
152
|
*/
|
|
150
|
-
export function createDevChannelHandlers(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings = {}, appToken = null, channels = createDevChannels(), user, roles = [], logPrefix = '[app-channel]', timeoutMs = HANDLER_TIMEOUT_MS, rate = SEND_RATE, now = Date.now }) {
|
|
153
|
+
export function createDevChannelHandlers(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings = {}, appToken = null, channels = createDevChannels(), user, roles = [], logPrefix = '[app-channel]', timeoutMs = HANDLER_TIMEOUT_MS, rate = SEND_RATE, now = Date.now, platform = devPlatform(), appId = null }) {
|
|
151
154
|
const devUser = buildDevUser(user);
|
|
152
|
-
|
|
155
|
+
// The same merged descriptor the page and the server/ bag see: a channels/
|
|
156
|
+
// handler reading platform.capabilities.embeddings must not disagree with
|
|
157
|
+
// the route beside it, and its embed() must be bound when they agree.
|
|
158
|
+
const bagBuilder = createDevBagBuilder({ serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings, appToken, channels, logPrefix: '[app]', platform, appId });
|
|
153
159
|
// clientId → channel name → { clientId, channel, params, file }
|
|
154
160
|
const subscriptions = new Map();
|
|
155
161
|
// username → { tokens, ts }
|
|
@@ -179,9 +185,9 @@ export function createDevChannelHandlers(viteServer, { serverOrigin, authHeader,
|
|
|
179
185
|
}
|
|
180
186
|
|
|
181
187
|
// The handler file covering a channel name, or null for an open channel.
|
|
182
|
-
// A wildcard resolves with `*` as the param
|
|
183
|
-
//
|
|
184
|
-
//
|
|
188
|
+
// A wildcard resolves with `*` as the param and is admitted only when
|
|
189
|
+
// nothing sits deeper than the file that took it (or, unmatched, deeper
|
|
190
|
+
// than its prefix): a deeper file's gate would otherwise be listened around.
|
|
185
191
|
async function resolveChannel(channel) {
|
|
186
192
|
const table = (await scanChannelHandlers(projectRoot)).map(h => ({ ...h, method: CHANNEL_METHOD }));
|
|
187
193
|
let match;
|
|
@@ -191,12 +197,14 @@ export function createDevChannelHandlers(viteServer, { serverOrigin, authHeader,
|
|
|
191
197
|
if (err instanceof URIError) throw channelError('app_channel_invalid_name', 400);
|
|
192
198
|
throw err;
|
|
193
199
|
}
|
|
194
|
-
if (match) return { params: match.params, file: match.route };
|
|
195
200
|
if (isWildcardName(channel)) {
|
|
196
201
|
const prefix = channel.slice(0, -2).split('/');
|
|
197
|
-
|
|
202
|
+
// an unmatched wildcard gates nothing; a matched one gates its own
|
|
203
|
+
// depth, and a file deeper than that would go ungated either way
|
|
204
|
+
const gatedTo = match ? prefix.length + 1 : prefix.length;
|
|
205
|
+
if (table.some(route => coversUnder(route.path, prefix, gatedTo))) throw channelError('app_channel_wildcard_gated', 403);
|
|
198
206
|
}
|
|
199
|
-
return null;
|
|
207
|
+
return match ? { params: match.params, file: match.route } : null;
|
|
200
208
|
}
|
|
201
209
|
|
|
202
210
|
// Run one export with the channel bag under the handler's timeout. A thrown
|
|
@@ -290,6 +298,8 @@ export function createDevChannelHandlers(viteServer, { serverOrigin, authHeader,
|
|
|
290
298
|
if (route === 'GET /replay') {
|
|
291
299
|
const { channel, since } = parsed.query;
|
|
292
300
|
if (!isChannelName(channel)) throw channelError('app_channel_invalid_name', 400);
|
|
301
|
+
// the same per-user bucket as send(), spent before anything else, as on the server
|
|
302
|
+
if (!takeSendToken(devUser.username)) throw channelError('app_channel_rate_limited', 429);
|
|
293
303
|
return sendJson(res, 200, channels.replay(channel, Number(since) || 0));
|
|
294
304
|
}
|
|
295
305
|
if (!['POST /subscribe', 'POST /unsubscribe', 'POST /send'].includes(route)) return next();
|
package/src/dev-channel-shim.js
CHANGED
|
@@ -8,8 +8,8 @@ import { CHANNEL_NAME, CHANNEL_NAME_MAX_LENGTH, EVENT_NAME, EVENT_NAME_MAX_LENGT
|
|
|
8
8
|
* var ch = __INFORMER__.channel('rooms/east', { since: 12 }); // sync; throws join_refused on a bad name
|
|
9
9
|
* ch.on('created', function (payload, frame) {}); // first handler subscribes
|
|
10
10
|
* ch.on('connected', function (info) {}); // { replayed: n } after each (re)subscribe
|
|
11
|
-
* ch.on('error', function (err) {}); // err.code: join_refused | rate_limited | disconnected | replay_gap
|
|
12
|
-
* ch.send(event, payload).then(result); // rejects: send_refused | rate_limited | disconnected
|
|
11
|
+
* ch.on('error', function (err) {}); // err.code: join_refused | rate_limited | budget_exhausted | handler_failed | disconnected | replay_gap
|
|
12
|
+
* ch.send(event, payload).then(result); // rejects: send_refused | rate_limited | budget_exhausted | handler_failed | disconnected
|
|
13
13
|
* ch.close();
|
|
14
14
|
*
|
|
15
15
|
* Transport: production subscribes each channel over a nes socket; dev POSTs
|
|
@@ -75,10 +75,14 @@ export function generateDevChannelShim() {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
// A refused subscribe maps the server's status the way nes errors do.
|
|
78
|
+
// A handler that threw is its own code: 'disconnected' would make a
|
|
79
|
+
// typo in a join handler read exactly like a dropped network.
|
|
78
80
|
function mapStatus(status) {
|
|
79
81
|
if (status === 403) return 'join_refused';
|
|
80
82
|
if (status === 429) return 'rate_limited';
|
|
83
|
+
if (status === 402) return 'budget_exhausted';
|
|
81
84
|
if (status >= 400 && status < 500) return 'join_refused';
|
|
85
|
+
if (status >= 500) return 'handler_failed';
|
|
82
86
|
return 'disconnected';
|
|
83
87
|
}
|
|
84
88
|
|
|
@@ -132,14 +136,17 @@ export function generateDevChannelShim() {
|
|
|
132
136
|
|
|
133
137
|
function Channel(name, opts) {
|
|
134
138
|
this.name = name;
|
|
135
|
-
|
|
139
|
+
// null-prototype: an event name admits 'constructor' and 'toString',
|
|
140
|
+
// which resolve through Object.prototype on a plain object
|
|
141
|
+
this._handlers = Object.create(null);
|
|
136
142
|
this._closed = false;
|
|
137
143
|
this._unavailable = false;
|
|
138
144
|
this._admitted = false;
|
|
139
145
|
this._subscribing = null; // in-flight subscribe (never rejects)
|
|
140
146
|
this._replaying = false; // live frames queue behind an in-flight replay
|
|
141
147
|
this._pending = [];
|
|
142
|
-
this._lastSeq =
|
|
148
|
+
this._lastSeq = Object.create(null); // concrete channel → highest seq seen
|
|
149
|
+
this._replayedThrough = Object.create(null); // → how far a replay caught it up
|
|
143
150
|
this._since = (opts && typeof opts.since === 'number') ? opts.since : null;
|
|
144
151
|
this._resume = this._since !== null; // replay on the next subscribe
|
|
145
152
|
}
|
|
@@ -153,12 +160,15 @@ export function generateDevChannelShim() {
|
|
|
153
160
|
});
|
|
154
161
|
};
|
|
155
162
|
|
|
156
|
-
//
|
|
157
|
-
//
|
|
163
|
+
// Only a frame the replay already handed over is a duplicate. Seq is
|
|
164
|
+
// allocated atomically but published separately, so on a real server two
|
|
165
|
+
// concurrent broadcasts can arrive out of seq order; deduping against
|
|
166
|
+
// the running max would drop the lower one for good.
|
|
167
|
+
// Returns whether it was dispatched.
|
|
158
168
|
Channel.prototype._accept = function (frame) {
|
|
159
169
|
if (typeof frame.seq === 'number') {
|
|
160
|
-
if (frame.seq <= (this.
|
|
161
|
-
this._lastSeq[frame.channel] = frame.seq;
|
|
170
|
+
if (frame.seq <= (this._replayedThrough[frame.channel] || 0)) return false;
|
|
171
|
+
if (frame.seq > (this._lastSeq[frame.channel] || 0)) this._lastSeq[frame.channel] = frame.seq;
|
|
162
172
|
}
|
|
163
173
|
this._dispatch(frame.event, frame.payload, frame);
|
|
164
174
|
return true;
|
|
@@ -229,15 +239,29 @@ export function generateDevChannelShim() {
|
|
|
229
239
|
var current = typeof data.current === 'number' ? data.current : 0;
|
|
230
240
|
var oldest = typeof data.oldest === 'number' ? data.oldest : null;
|
|
231
241
|
if (current < last) {
|
|
232
|
-
// the
|
|
242
|
+
// the counter restarted (an idle channel's key expired,
|
|
243
|
+
// or redis was flushed): nothing seen applies
|
|
233
244
|
delete self._lastSeq[concrete];
|
|
245
|
+
delete self._replayedThrough[concrete];
|
|
234
246
|
last = 0;
|
|
235
247
|
} else if (current > last && (oldest === null || oldest > last + 1)) {
|
|
236
248
|
self._emitError(channelError('replay_gap', 'Frames on "' + concrete + '" after seq ' + last + ' are no longer buffered', concrete));
|
|
237
249
|
}
|
|
250
|
+
// the mark queued live frames dedupe against: what the
|
|
251
|
+
// page already held, raised as the replay hands frames
|
|
252
|
+
// over. Set before the loop, so a frame at or below
|
|
253
|
+
// that seq is dropped rather than dispatched twice.
|
|
254
|
+
self._replayedThrough[concrete] = last;
|
|
238
255
|
(data.frames || []).forEach(function (frame) {
|
|
239
|
-
if (
|
|
256
|
+
if (self._closed || !frame || frame.channel !== concrete) return;
|
|
257
|
+
if (self._accept(frame)) delivered++;
|
|
258
|
+
if (typeof frame.seq === 'number' && frame.seq > self._replayedThrough[concrete]) self._replayedThrough[concrete] = frame.seq;
|
|
240
259
|
});
|
|
260
|
+
}).catch(function (err) {
|
|
261
|
+
// a read refused for one channel (over the inbound rate,
|
|
262
|
+
// say) is reported on that channel and must not throw
|
|
263
|
+
// away the others, as on the server
|
|
264
|
+
self._emitError(err && err.code ? err : channelError('disconnected', String((err && err.message) || err), concrete));
|
|
241
265
|
});
|
|
242
266
|
});
|
|
243
267
|
}, Promise.resolve()).then(function () {
|
|
@@ -307,7 +331,9 @@ export function generateDevChannelShim() {
|
|
|
307
331
|
if (res.ok) return res.body ? res.body.result : null;
|
|
308
332
|
var message = (res.body && res.body.error) || ('Send refused (' + res.status + ')');
|
|
309
333
|
if (res.status === 429) throw channelError('rate_limited', message, self.name);
|
|
334
|
+
if (res.status === 402) throw channelError('budget_exhausted', message, self.name);
|
|
310
335
|
if (res.status >= 400 && res.status < 500) throw channelError('send_refused', message, self.name);
|
|
336
|
+
if (res.status >= 500) throw channelError('handler_failed', message, self.name);
|
|
311
337
|
throw channelError('disconnected', message, self.name);
|
|
312
338
|
});
|
|
313
339
|
};
|
|
@@ -317,7 +343,7 @@ export function generateDevChannelShim() {
|
|
|
317
343
|
this._closed = true;
|
|
318
344
|
var i = open.indexOf(this);
|
|
319
345
|
if (i !== -1) open.splice(i, 1);
|
|
320
|
-
this._handlers =
|
|
346
|
+
this._handlers = Object.create(null);
|
|
321
347
|
var pending = this._subscribing;
|
|
322
348
|
var subscribed = this._admitted || pending;
|
|
323
349
|
this._admitted = false;
|