@entrinsik/vite-plugin-informer 2.10.0 → 2.11.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
@@ -23,7 +23,7 @@ The assembler uploads your built frontend plus these source trees, matching how
23
23
  | `webhooks/` | Publicly callable webhook routes |
24
24
  | `tools/` | AI tools, private to the App's agents |
25
25
  | `mcp/` | Tools exposed over the App's MCP endpoint |
26
- | `channels/` | Live channel join/leave handlers |
26
+ | `channels/` | Live channel handlers: `join` / `joined` / `leave` and event-named handlers for `channel.send()`; `npm run dev` runs them locally, and the `channels:` block in `informer.yaml` declares relays only (each entry needs `on`) |
27
27
  | `migrations/` | Workspace database migrations, run in order on deploy |
28
28
  | `embeddings/` | Declarative embedding use cases (see below) |
29
29
  | `lib/`, `shared/` | Modules importable by the trees above |
@@ -44,6 +44,8 @@ Against an older server the CLI:
44
44
 
45
45
  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
46
 
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.
48
+
47
49
  Feature-detect at runtime with optional chaining, since `platform` is absent entirely before 2026.1.3:
48
50
 
49
51
  ```javascript
package/bin/workspace.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { readFile } from 'node:fs/promises';
4
- import { resolve } from 'node:path';
4
+ import { resolve, basename } from 'node:path';
5
5
  import { createClient } from '../src/client.js';
6
- import { loadEnv, envWritePath, parseModeArg } from '../src/env.js';
6
+ import { loadEnv, envWritePath, localEnvValue, parseModeArg } from '../src/env.js';
7
7
  import { init, migrate, reset } from '../src/workspace.js';
8
8
 
9
9
  const mode = parseModeArg(process.argv);
@@ -41,12 +41,22 @@ const api = createClient({ baseUrl, apiKey, user, pass });
41
41
  const migrationsDir = resolve('migrations');
42
42
  const envPath = envWritePath({ mode });
43
43
 
44
+ // loadEnv walks up to a parent .env (monorepo config), so process.env may
45
+ // carry ANOTHER app's workspace id. Only this app's own env file decides
46
+ // whether it is initialized; migrate/reset fall back to the inherited value.
47
+ const localWorkspaceId = localEnvValue('INFORMER_DEV_WORKSPACE', { mode });
48
+ const inheritedWorkspaceId = process.env.INFORMER_DEV_WORKSPACE || null;
49
+ if (!localWorkspaceId && inheritedWorkspaceId && command === 'init') {
50
+ console.log(`Ignoring INFORMER_DEV_WORKSPACE=${inheritedWorkspaceId} inherited from the environment: ${basename(envPath)} does not define it.`);
51
+ }
52
+ const workspaceId = localWorkspaceId || inheritedWorkspaceId;
53
+
44
54
  try {
45
55
  if (command === 'init') {
46
56
  // Check if already initialized
47
- if (process.env.INFORMER_DEV_WORKSPACE) {
48
- console.error(`Workspace already initialized: ${process.env.INFORMER_DEV_WORKSPACE}`);
49
- console.error('Run workspace:reset to start fresh, or remove INFORMER_DEV_WORKSPACE from .env to re-initialize.');
57
+ if (localWorkspaceId) {
58
+ console.error(`Workspace already initialized: ${localWorkspaceId}`);
59
+ console.error(`Run workspace:reset to start fresh, or remove INFORMER_DEV_WORKSPACE from ${basename(envPath)} to re-initialize.`);
50
60
  process.exit(1);
51
61
  }
52
62
 
@@ -72,7 +82,6 @@ try {
72
82
  await init({ api, slug, migrationsDir, envPath });
73
83
 
74
84
  } else if (command === 'migrate') {
75
- const workspaceId = process.env.INFORMER_DEV_WORKSPACE;
76
85
  if (!workspaceId) {
77
86
  console.error('No dev workspace found. Run workspace:init first.');
78
87
  process.exit(1);
@@ -80,7 +89,6 @@ try {
80
89
  await migrate({ api, workspaceId, migrationsDir });
81
90
 
82
91
  } else if (command === 'reset') {
83
- const workspaceId = process.env.INFORMER_DEV_WORKSPACE;
84
92
  if (!workspaceId) {
85
93
  console.error('No dev workspace found. Run workspace:init first.');
86
94
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entrinsik/vite-plugin-informer",
3
- "version": "2.10.0",
3
+ "version": "2.11.0",
4
4
  "description": "Vite plugin and deploy tool for Informer App development",
5
5
  "scripts": {
6
6
  "test": "node --test"
package/src/dev-bag.js ADDED
@@ -0,0 +1,185 @@
1
+ import { loadManifest, manifestBlock, buildDevContext, buildDevCrypto, buildDevMessaging, normalizeFetchPath } from './dev-dependencies.js';
2
+ import { createDevChannels, createDevEmit } from './dev-channels.js';
3
+ import { devPlatform } from './dev-platform.js';
4
+
5
+ /**
6
+ * The part of the dev handler bag every surface shares — `server/` routes and
7
+ * `channels/` handlers alike: the platform services (`query`, `fetch`, the
8
+ * typed dependency `context`, `emit`, `broadcast`, `notify`, `email`,
9
+ * `crypto`, `markdown`, `log`, `env`, `platform`). Each surface adds only its
10
+ * own inbound members (`request` + `respond` for HTTP, `channel` + `payload`
11
+ * for channels), the same split the prod sandbox's buildInvokeScript makes.
12
+ */
13
+
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
+ // Cap dev proxy calls at 30s so a hung upstream fails loudly instead of
23
+ // hanging the handler.
24
+ const FETCH_TIMEOUT_MS = 30000;
25
+
26
+ /** log(message, data) + log.debug/info/warn/error — the sandbox log surface, to the terminal. */
27
+ export function buildDevLog(prefix = '[app-log]') {
28
+ const logCall = (level, message, data) => {
29
+ const msg = typeof message === 'string' ? message : JSON.stringify(message);
30
+ const args = [`${prefix} [${level}] ${msg}`];
31
+ if (data) args.push(data);
32
+ console.log(...args);
33
+ };
34
+ return Object.assign(
35
+ (message, data) => logCall('info', message, data),
36
+ {
37
+ debug: (message, data) => logCall('debug', message, data),
38
+ info: (message, data) => logCall('info', message, data),
39
+ warn: (message, data) => logCall('warn', message, data),
40
+ error: (message, data) => logCall('error', message, data)
41
+ }
42
+ );
43
+ }
44
+
45
+ /** The viewer identity dev handlers see — the same object the page gets as window.__INFORMER__.user. */
46
+ export function buildDevUser(user) {
47
+ return {
48
+ username: (user && user.username) || 'dev',
49
+ displayName: (user && user.displayName) || 'Local Developer',
50
+ email: (user && user.email) || null,
51
+ timezone: (user && user.timezone) || null
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Build the shared services once per dev server and the per-invocation bag
57
+ * on demand.
58
+ *
59
+ * @param {Object} opts
60
+ * @param {string} opts.serverOrigin - Informer server origin
61
+ * @param {string} opts.authHeader - Basic or Bearer auth header for /api calls
62
+ * @param {string|null} opts.devWorkspaceId - workspace datasource id for query()
63
+ * @param {string} opts.projectRoot - app project root
64
+ * @param {Object} [opts.devBindings] - dev bindings for `target: app` / `target: pack` deps
65
+ * @param {string|null} [opts.appToken] - INFORMER_APP_TOKEN for cross-app request()
66
+ * @param {ReturnType<typeof createDevChannels>} [opts.channels] - the dev channels hub
67
+ * @param {string} [opts.logPrefix] - prefix for notify/email/emit console lines
68
+ * @returns {{ query: Function, apiFetch: Function, build: () => Promise<{ manifest: Object, bag: Object }> }}
69
+ */
70
+ export function createDevBagBuilder({ serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings = {}, appToken = null, channels = createDevChannels(), logPrefix = '[app]' }) {
71
+ // query() implementation — proxies to the workspace _sql endpoint
72
+ async function query(sql, params) {
73
+ if (!devWorkspaceId) {
74
+ throw new Error('query() requires INFORMER_DEV_WORKSPACE. Run: npx informer-workspace init');
75
+ }
76
+
77
+ const resp = await globalThis.fetch(`${serverOrigin}/api/datasources/${devWorkspaceId}/_sql`, {
78
+ method: 'POST',
79
+ headers: { 'Content-Type': 'application/json', Authorization: authHeader },
80
+ body: JSON.stringify({ sql, params: params || [] })
81
+ });
82
+
83
+ if (!resp.ok) {
84
+ const err = await resp.json().catch(() => ({}));
85
+ const detail = err.message || resp.statusText;
86
+ throw new Error(`query() failed: ${resp.status} ${detail} (${serverOrigin}/api/datasources/${devWorkspaceId}/_sql)`);
87
+ }
88
+
89
+ const data = await resp.json();
90
+ return data.rows;
91
+ }
92
+
93
+ // fetch() implementation — proxies API calls to the Informer server
94
+ async function fetchAs(auth, path, opts = {}) {
95
+ const method = (opts.method || 'GET').toUpperCase();
96
+ // Same strict canonicalization as prod (app-sandbox.js normalizeFetchPath);
97
+ // reject non-canonical shapes here instead of silently accepting them.
98
+ const apiPath = normalizeFetchPath(path);
99
+ if (!apiPath) {
100
+ return { status: 400, body: { error: `Invalid fetch path: ${String(path).slice(0, 80)}` }, contentType: 'application/json' };
101
+ }
102
+ const url = `${serverOrigin}${apiPath}`;
103
+ const fetchOpts = {
104
+ method,
105
+ headers: { Authorization: auth, 'Content-Type': 'application/json', ...(opts.headers || {}) }
106
+ };
107
+
108
+ if (opts.body && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
109
+ fetchOpts.body = JSON.stringify(opts.body);
110
+ }
111
+
112
+ // Read the stream exactly once — `.json()` consumes/locks the body, so a
113
+ // `.text()` fallback would throw "Body is unusable" on any non-JSON
114
+ // response (auth-bounce HTML, proxy error page). Parse in memory
115
+ // instead — same as prod's unwrapInject.
116
+ let status, contentType, text;
117
+ try {
118
+ const resp = await globalThis.fetch(url, { ...fetchOpts, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
119
+ status = resp.status;
120
+ contentType = resp.headers.get('content-type') || '';
121
+ text = await resp.text();
122
+ } catch (err) {
123
+ // Transport failure or timeout — fetch throws (TypeError 'fetch failed'
124
+ // with the real reason on err.cause, or a TimeoutError). Return a
125
+ // synthetic 502 so the dependency layer names it (dep + url + cause)
126
+ // rather than a bare unhandled "fetch failed".
127
+ const reason = (err.cause && err.cause.message) || err.message;
128
+ return { status: 502, body: { message: `fetch to ${url} failed: ${reason}` }, contentType: 'application/json' };
129
+ }
130
+ let body;
131
+ try { body = JSON.parse(text); } catch { body = text; }
132
+ return { status, body, contentType };
133
+ }
134
+
135
+ async function apiFetch(path, opts = {}) {
136
+ return await fetchAs(authHeader, path, opts);
137
+ }
138
+
139
+ // Cross-app request() targets /api/apps/<id>/view/_/<path>, whose auth accepts
140
+ // only the token/session strategies — NOT basic auth. In API-key mode the
141
+ // INFORMER_API_KEY Bearer already satisfies that, so reuse it; under basic
142
+ // auth a separate API token (INFORMER_APP_TOKEN) is required, and without one
143
+ // the app proxy's request() throws a pointed error instead of a bare 401.
144
+ // appFetch also stamps x-informer-app-depth:1 so the target runs one hop deep
145
+ // and enforces the same one-hop guard it does in production.
146
+ const appAuth = appToken
147
+ ? `Bearer ${appToken}`
148
+ : (authHeader && authHeader.startsWith('Bearer ') ? authHeader : null);
149
+ const appFetch = appAuth
150
+ ? async (path, opts = {}) => await fetchAs(appAuth, path, { ...opts, headers: { ...(opts.headers || {}), 'x-informer-app-depth': '1' } })
151
+ : null;
152
+
153
+ // notify/email — delivery is a console-logged no-op in dev, but the
154
+ // required-field validation mirrors prod so an app that passes here
155
+ // won't 500 in production.
156
+ const { notify, email } = buildDevMessaging(logPrefix);
157
+ const log = buildDevLog();
158
+ // markdown helper — passthrough in dev (production uses `marked`)
159
+ const markdown = (text) => text;
160
+
161
+ /**
162
+ * The shared bag for one invocation. The manifest is parsed per call
163
+ * (deps, env and channels all come from that one read) so edits to
164
+ * informer.yaml take effect without a dev-server restart.
165
+ */
166
+ async function build() {
167
+ const manifest = await loadManifest(projectRoot);
168
+ const deps = manifestBlock(manifest, 'dependencies');
169
+ const context = buildDevContext({ deps, apiFetch, devBindings, appFetch });
170
+ const env = manifestBlock(manifest, 'env');
171
+
172
+ // emit() writes no app_event row in dev, but still relays a listed
173
+ // event to its `channels:` channel; broadcast() publishes a live
174
+ // frame to the page (see dev-channels.js).
175
+ const emit = createDevEmit({ channels, manifestChannels: manifestBlock(manifest, 'channels'), logPrefix: '[app-event]' });
176
+ const { broadcast } = channels;
177
+
178
+ return {
179
+ manifest,
180
+ bag: { context, query, fetch: apiFetch, emit, broadcast, notify, email, embed, crypto: buildDevCrypto(), markdown, log, env, platform: devPlatform() }
181
+ };
182
+ }
183
+
184
+ return { query, apiFetch, build };
185
+ }
@@ -0,0 +1,325 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { parse as parseUrl } from 'node:url';
4
+ import { createDevBagBuilder, buildDevUser } from './dev-bag.js';
5
+ import { filePathToRoute, matchRoute, walkJsFiles } from './server-routes.js';
6
+ import { createDevChannels, isChannelName, isWildcardName, isEventName, isReservedEvent, channelError, USER_CHANNEL_PREFIX } from './dev-channels.js';
7
+
8
+ /**
9
+ * The dev counterpart of app-channel-handlers.js: a page's `channel()` mock
10
+ * subscribes, unsubscribes and sends over plain POSTs to DEV_CHANNEL_API, and
11
+ * the matching `channels/` file runs in-process the way a deployed handler
12
+ * runs in the sandbox — `join` decides admission, `joined` runs after it,
13
+ * `leave` runs on unsubscribe, and an event-named export answers `send()`.
14
+ * Frames still ride Vite's websocket to every page; admission is what tells
15
+ * the page which frames it may dispatch.
16
+ */
17
+
18
+ export const CHANNELS_DIR = 'channels';
19
+ // The synthetic method channel handlers are matched under (deploy.js rebuildRouteTable).
20
+ export const CHANNEL_METHOD = 'CHANNEL';
21
+ // The deployment's joinTimeoutMs cap; a file's `config.timeout` may only lower it.
22
+ export const HANDLER_TIMEOUT_MS = 5000;
23
+ // Per-user inbound send budget (token bucket).
24
+ export const SEND_RATE = Object.freeze({ perSecond: 10, burst: 30 });
25
+ // Exports with a fixed meaning; every other export must be an event name.
26
+ export const LIFECYCLE_EXPORTS = Object.freeze(['config', 'join', 'joined', 'leave']);
27
+
28
+ // One literal segment of a channel name (app-channel-broadcast.js CHANNEL_SEGMENT).
29
+ const CHANNEL_SEGMENT = /^[\w.-]+$/;
30
+ const USER_SEGMENT = USER_CHANNEL_PREFIX.slice(0, -1);
31
+
32
+ // Named exports as the deploy's scanner would list them: declarations and
33
+ // `export { a, b as c }` lists (`export default` lands as "default").
34
+ const EXPORT_DECL = /^\s*export\s+(?:async\s+)?(?:function\s*\*?\s*|const\s+|let\s+|var\s+|class\s+)([A-Za-z_$][\w$]*)/gm;
35
+ const EXPORT_LIST = /^\s*export\s*\{([^}]*)\}/gm;
36
+ const EXPORT_DEFAULT = /^\s*export\s+default\b/m;
37
+
38
+ /** The export names a channel handler source declares. */
39
+ export function exportNames(source) {
40
+ const names = new Set();
41
+ for (const m of source.matchAll(EXPORT_DECL)) names.add(m[1]);
42
+ for (const m of source.matchAll(EXPORT_LIST)) {
43
+ for (const entry of m[1].split(',')) {
44
+ const parts = entry.trim().split(/\s+as\s+/);
45
+ const name = (parts[1] || parts[0]).trim();
46
+ if (name) names.add(name);
47
+ }
48
+ }
49
+ if (EXPORT_DEFAULT.test(source)) names.add('default');
50
+ return [...names].sort();
51
+ }
52
+
53
+ /**
54
+ * Scan the app's channels/ directory into handler routes, the same
55
+ * file-convention mapping server/ uses: `channels/rooms/[room].js` → `/rooms/:room`.
56
+ *
57
+ * @param {string} projectRoot
58
+ * @returns {Promise<Array<{ path: string, relPath: string, filePath: string }>>}
59
+ */
60
+ export async function scanChannelHandlers(projectRoot) {
61
+ const files = await walkJsFiles(join(projectRoot, CHANNELS_DIR), CHANNELS_DIR);
62
+ return files.map(({ relPath, absPath }) => ({
63
+ path: filePathToRoute(relPath, CHANNELS_DIR),
64
+ relPath,
65
+ filePath: absPath
66
+ }));
67
+ }
68
+
69
+ /**
70
+ * The deploy-time rules for channels/ files (channel-scanner.js), as boot
71
+ * diagnostics: a file may export `config`, `join`, `joined`, `leave` and
72
+ * event-named handlers; it must export something; its path must name a
73
+ * channel a page can subscribe to; two files may not share a path.
74
+ *
75
+ * @param {string} projectRoot
76
+ * @returns {Promise<string[]>} one message per problem, prefixed with the file
77
+ */
78
+ export async function validateChannelHandlers(projectRoot) {
79
+ const problems = [];
80
+ const byPath = new Map();
81
+ for (const handler of await scanChannelHandlers(projectRoot)) {
82
+ const source = await readFile(handler.filePath, 'utf8');
83
+ const names = exportNames(source);
84
+ if (names.length === 0) {
85
+ problems.push(`${handler.relPath}: exports nothing — a channel handler exports join, joined, leave and/or event handlers (and optionally config)`);
86
+ }
87
+ const invalid = names.filter(name => !LIFECYCLE_EXPORTS.includes(name) && (name === 'default' || !isEventName(name) || isReservedEvent(name)));
88
+ if (invalid.length) {
89
+ problems.push(`${handler.relPath}: exports ${invalid.join(', ')} — only config, join, joined, leave and event-named handlers (not error/connected) are allowed`);
90
+ }
91
+ const segments = handler.path === '/' ? [] : handler.path.slice(1).split('/');
92
+ const subscribable = segments.length > 0 && segments.every((segment, i) =>
93
+ segment.startsWith(':') || CHANNEL_SEGMENT.test(segment) || (i === 0 && segment === USER_SEGMENT));
94
+ if (!subscribable) {
95
+ problems.push(`${handler.relPath}: maps to "${handler.path}", a channel no page can subscribe to — name segments use letters, digits, "_", "." and "-" (or a [param]), and channels/index.js names no channel at all`);
96
+ }
97
+ const dup = byPath.get(handler.path);
98
+ if (dup) problems.push(`${handler.relPath}: channel "${handler.path}" is also defined by ${dup}`);
99
+ byPath.set(handler.path, handler.relPath);
100
+ }
101
+ return problems;
102
+ }
103
+
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) {
107
+ const segments = routePath.split('/').filter(Boolean);
108
+ return segments.length > prefix.length && prefix.every((seg, i) => segments[i].startsWith(':') || segments[i] === seg);
109
+ }
110
+
111
+ function readJson(req) {
112
+ return new Promise((resolve, reject) => {
113
+ const chunks = [];
114
+ req.on('data', chunk => chunks.push(chunk));
115
+ req.on('error', reject);
116
+ req.on('end', () => {
117
+ const text = Buffer.concat(chunks).toString('utf8');
118
+ if (!text) return resolve({});
119
+ try { resolve(JSON.parse(text)); } catch { reject(channelError('app_channel_invalid_body', 400)); }
120
+ });
121
+ });
122
+ }
123
+
124
+ function sendJson(res, status, body) {
125
+ res.statusCode = status;
126
+ res.setHeader('Content-Type', 'application/json');
127
+ res.end(JSON.stringify(body));
128
+ }
129
+
130
+ /**
131
+ * Create the Connect middleware behind DEV_CHANNEL_API.
132
+ *
133
+ * POST /subscribe { clientId, channel } → { ok: true } | error
134
+ * POST /unsubscribe { clientId, channel } → { ok: true }
135
+ * POST /send { clientId, channel, event, payload } → { result } | error
136
+ * GET /replay?channel=&since= → { frames, oldest, current }
137
+ *
138
+ * Errors are `{ error: <code> }` with the status the server would use.
139
+ *
140
+ * @param {Object} viteServer - Vite dev server (ssrLoadModule loads the handler files)
141
+ * @param {Object} opts - createDevBagBuilder options plus:
142
+ * @param {Object} [opts.user] - the mocked viewer (window.__INFORMER__.user)
143
+ * @param {string[]} [opts.roles] - the mocked viewer's roles
144
+ * @param {string} [opts.logPrefix]
145
+ * @param {number} [opts.timeoutMs] - the handler wall-clock cap
146
+ * @param {{ perSecond: number, burst: number }} [opts.rate] - the per-user send budget
147
+ * @param {() => number} [opts.now] - clock, for the rate limiter
148
+ * @returns {Function} Connect middleware
149
+ */
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 }) {
151
+ const devUser = buildDevUser(user);
152
+ const bagBuilder = createDevBagBuilder({ serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings, appToken, channels, logPrefix: '[app]' });
153
+ // clientId → channel name → { clientId, channel, params, file }
154
+ const subscriptions = new Map();
155
+ // username → { tokens, ts }
156
+ const buckets = new Map();
157
+
158
+ function recordsFor(clientId) {
159
+ let records = subscriptions.get(clientId);
160
+ if (!records) {
161
+ records = new Map();
162
+ subscriptions.set(clientId, records);
163
+ }
164
+ return records;
165
+ }
166
+
167
+ function takeSendToken(username) {
168
+ const at = now();
169
+ let bucket = buckets.get(username);
170
+ if (!bucket) {
171
+ bucket = { tokens: rate.burst, ts: at };
172
+ buckets.set(username, bucket);
173
+ }
174
+ bucket.tokens = Math.min(rate.burst, bucket.tokens + (Math.max(0, at - bucket.ts) / 1000) * rate.perSecond);
175
+ bucket.ts = at;
176
+ if (bucket.tokens < 1) return false;
177
+ bucket.tokens -= 1;
178
+ return true;
179
+ }
180
+
181
+ // 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.
185
+ async function resolveChannel(channel) {
186
+ const table = (await scanChannelHandlers(projectRoot)).map(h => ({ ...h, method: CHANNEL_METHOD }));
187
+ let match;
188
+ try {
189
+ match = matchRoute(table, CHANNEL_METHOD, `/${channel}`);
190
+ } catch (err) {
191
+ if (err instanceof URIError) throw channelError('app_channel_invalid_name', 400);
192
+ throw err;
193
+ }
194
+ if (match) return { params: match.params, file: match.route };
195
+ if (isWildcardName(channel)) {
196
+ const prefix = channel.slice(0, -2).split('/');
197
+ if (table.some(route => coversUnder(route.path, prefix))) throw channelError('app_channel_wildcard_gated', 403);
198
+ }
199
+ return null;
200
+ }
201
+
202
+ // Run one export with the channel bag under the handler's timeout. A thrown
203
+ // channel error keeps its status; anything else is 500 app_channel_<export>_failed.
204
+ async function runExport(mod, exportName, { channel, params, payload }) {
205
+ const { bag } = await bagBuilder.build();
206
+ const invocation = {
207
+ ...bag,
208
+ channel: { name: channel, params, broadcast: async (event, body, options) => await bag.broadcast(channel, event, body, options) },
209
+ payload: payload === undefined ? null : payload,
210
+ request: { user: { ...devUser }, roles }
211
+ };
212
+ const cap = Math.min(Number(mod.config && mod.config.timeout) || timeoutMs, timeoutMs);
213
+ let timer;
214
+ const timeout = new Promise((_, reject) => {
215
+ timer = setTimeout(() => reject(channelError(`app_channel_${exportName}_timeout`, 504)), cap);
216
+ });
217
+ try {
218
+ return await Promise.race([Promise.resolve().then(() => mod[exportName](invocation)), timeout]);
219
+ } catch (err) {
220
+ if (err && typeof err.statusCode === 'number') throw err;
221
+ console.error(`${logPrefix} ${exportName}("${channel}") failed:`, err);
222
+ throw channelError(`app_channel_${exportName}_failed`, 500);
223
+ } finally {
224
+ clearTimeout(timer);
225
+ }
226
+ }
227
+
228
+ async function subscribe({ clientId, channel }) {
229
+ if (!isChannelName(channel)) throw channelError('app_channel_invalid_name', 400);
230
+ // `@user/<username>` is private to that user; the whole remainder is the username.
231
+ if (channel.startsWith(USER_CHANNEL_PREFIX) && channel.slice(USER_CHANNEL_PREFIX.length) !== devUser.username) {
232
+ throw channelError('app_channel_user_mismatch', 403);
233
+ }
234
+ const resolved = await resolveChannel(channel);
235
+ const record = { clientId, channel, params: resolved ? resolved.params : {}, file: resolved ? resolved.file : null };
236
+ if (!resolved) {
237
+ recordsFor(clientId).set(channel, record);
238
+ console.log(`${logPrefix} join("${channel}") admitted (open channel)`);
239
+ return null;
240
+ }
241
+
242
+ const mod = await viteServer.ssrLoadModule(resolved.file.filePath);
243
+ const required = mod.config && mod.config.roles;
244
+ if (Array.isArray(required) && required.length > 0 && !required.some(r => roles.includes(r))) {
245
+ throw channelError('app_channel_role_required', 403);
246
+ }
247
+ if (typeof mod.join === 'function') {
248
+ // admitted only when the handler returned exactly true
249
+ if (await runExport(mod, 'join', record) !== true) throw channelError('app_channel_join_refused', 403);
250
+ }
251
+ recordsFor(clientId).set(channel, record);
252
+ console.log(`${logPrefix} join("${channel}") admitted${typeof mod.join === 'function' ? '' : ' (no join export)'}`);
253
+ return mod;
254
+ }
255
+
256
+ async function unsubscribe({ clientId, channel }) {
257
+ const records = subscriptions.get(clientId);
258
+ const record = records && records.get(channel);
259
+ if (!record) return;
260
+ records.delete(channel);
261
+ if (!record.file) return;
262
+ try {
263
+ const mod = await viteServer.ssrLoadModule(record.file.filePath);
264
+ if (typeof mod.leave === 'function') await runExport(mod, 'leave', record);
265
+ } catch (err) {
266
+ console.warn(`${logPrefix} leave("${channel}") failed: ${err.message}`);
267
+ }
268
+ }
269
+
270
+ async function send({ clientId, channel, event, payload }) {
271
+ const records = subscriptions.get(clientId);
272
+ const record = records && records.get(channel);
273
+ if (!record) throw channelError('app_channel_not_subscribed', 403);
274
+ if (!isEventName(event)) throw channelError('app_channel_invalid_event', 400);
275
+ if (isReservedEvent(event)) throw channelError('app_channel_reserved_event', 400);
276
+ if (!record.file) throw channelError('app_channel_no_handler', 404);
277
+ const mod = await viteServer.ssrLoadModule(record.file.filePath);
278
+ if (LIFECYCLE_EXPORTS.includes(event) || typeof mod[event] !== 'function') throw channelError('app_channel_no_handler', 404);
279
+ if (!takeSendToken(devUser.username)) throw channelError('app_channel_rate_limited', 429);
280
+ const result = await runExport(mod, event, { ...record, payload });
281
+ console.log(`${logPrefix} send("${channel}", "${event}") handled`);
282
+ return result === undefined ? null : result;
283
+ }
284
+
285
+ return async function devChannelHandlersMiddleware(req, res, next) {
286
+ const parsed = parseUrl(req.url, true);
287
+ const route = `${req.method} ${parsed.pathname}`;
288
+ let body = {};
289
+ try {
290
+ if (route === 'GET /replay') {
291
+ const { channel, since } = parsed.query;
292
+ if (!isChannelName(channel)) throw channelError('app_channel_invalid_name', 400);
293
+ return sendJson(res, 200, channels.replay(channel, Number(since) || 0));
294
+ }
295
+ if (!['POST /subscribe', 'POST /unsubscribe', 'POST /send'].includes(route)) return next();
296
+
297
+ body = await readJson(req);
298
+ if (typeof body.clientId !== 'string' || !body.clientId) throw channelError('app_channel_client_required', 400);
299
+
300
+ if (route === 'POST /subscribe') {
301
+ const mod = await subscribe(body);
302
+ sendJson(res, 200, { ok: true });
303
+ // joined runs once the page has its answer; a failure is the
304
+ // author's to read in the terminal, never the subscribe's.
305
+ if (mod && typeof mod.joined === 'function') {
306
+ const record = recordsFor(body.clientId).get(body.channel);
307
+ setImmediate(() => {
308
+ runExport(mod, 'joined', record).catch(err => console.warn(`${logPrefix} joined("${body.channel}") failed: ${err.message}`));
309
+ });
310
+ }
311
+ return;
312
+ }
313
+ if (route === 'POST /unsubscribe') {
314
+ await unsubscribe(body);
315
+ return sendJson(res, 200, { ok: true });
316
+ }
317
+ return sendJson(res, 200, { result: await send(body) });
318
+ } catch (err) {
319
+ const status = typeof err.statusCode === 'number' ? err.statusCode : 500;
320
+ if (status === 500) console.error(logPrefix, err);
321
+ else if (route === 'POST /subscribe') console.log(`${logPrefix} join("${body.channel}") refused (${err.code || err.message})`);
322
+ sendJson(res, status, { error: err.code || err.message });
323
+ }
324
+ };
325
+ }