@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 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 this release 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.
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, mkdir, access } from 'node:fs/promises';
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
- pkg.devDependencies['@entrinsik/vite-plugin-informer'] = '^1.0.0';
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entrinsik/vite-plugin-informer",
3
- "version": "2.11.0",
3
+ "version": "2.12.0",
4
4
  "description": "Vite plugin and deploy tool for Informer App development",
5
5
  "scripts": {
6
6
  "test": "node --test"
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: devPlatform() });
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 { devPlatform } from './dev-platform.js';
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
- * @returns {{ query: Function, apiFetch: Function, build: () => Promise<{ manifest: Object, bag: Object }> }}
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
- return { status: 400, body: { error: `Invalid fetch path: ${String(path).slice(0, 80)}` }, contentType: 'application/json' };
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
- let status, contentType, text;
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
- text = await resp.text();
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: devPlatform() }
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 send budget (token bucket).
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 strictly under `prefix` segments?
105
- // (`/rooms/:room` and `/rooms/east/members` both cover names under `rooms/`.)
106
- function coversUnder(routePath, prefix) {
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 > prefix.length && prefix.every((seg, i) => segments[i].startsWith(':') || segments[i] === seg);
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
- const bagBuilder = createDevBagBuilder({ serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings, appToken, channels, logPrefix: '[app]' });
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; when no file takes the
183
- // wildcard itself but some file gates a name under its prefix, the
184
- // wildcard is refused rather than letting it listen around the gate.
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
- if (table.some(route => coversUnder(route.path, prefix))) throw channelError('app_channel_wildcard_gated', 403);
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();
@@ -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
- this._handlers = {};
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 = {}; // concrete channel → last seq delivered
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
- // A frame at or below the last seq delivered for its channel is a
157
- // duplicate. Returns whether it was dispatched.
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._lastSeq[frame.channel] || 0)) return false;
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 channel's counter restarted (idle channel): nothing seen applies
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 (!self._closed && frame && frame.channel === concrete && self._accept(frame)) delivered++;
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;