@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 +3 -1
- package/bin/workspace.js +15 -7
- package/package.json +1 -1
- package/src/dev-bag.js +185 -0
- package/src/dev-channel-handlers.js +325 -0
- package/src/dev-channel-shim.js +248 -37
- package/src/dev-channels.js +119 -27
- package/src/dev-platform.js +3 -0
- package/src/env.js +17 -1
- package/src/index.js +28 -3
- package/src/server-routes.js +78 -211
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 (
|
|
48
|
-
console.error(`Workspace already initialized: ${
|
|
49
|
-
console.error(
|
|
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
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
|
+
}
|