@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/src/index.js CHANGED
@@ -8,7 +8,8 @@ import { buildDeclarations } from './openapi-to-dts.js';
8
8
  import { loadEnv, envWritePath } from './env.js';
9
9
  import { createMiddleware as createServerRoutes } from './server-routes.js';
10
10
  import { createAgentMiddleware } from './agent-dev.js';
11
- import { createDevChannels, validateChannels, BROADCAST_EVENT, DEV_CHANNEL_EVENT } from './dev-channels.js';
11
+ import { createDevChannels, validateChannels, BROADCAST_EVENT, DEV_CHANNEL_EVENT, DEV_CHANNEL_API } from './dev-channels.js';
12
+ import { createDevChannelHandlers, validateChannelHandlers } from './dev-channel-handlers.js';
12
13
  import { renderDevChannelScript } from './dev-channel-shim.js';
13
14
  import { createStreamStore, createUploadsMiddleware, createDownloadsMiddleware } from './dev-streams.js';
14
15
  import { streamsClientSource } from './streams-client.js';
@@ -96,8 +97,9 @@ async function writeAppDepTypes (projectRoot, dts) {
96
97
  *
97
98
  * - Proxies /api requests to the Informer server with Basic auth
98
99
  * - Runs server/ route handlers locally via ssrLoadModule (if server/ dir exists)
99
- * - Injects window.__INFORMER__ context mock in dev mode, including a
100
- * `channel()` mock fed by handler broadcast() over Vite's dev websocket
100
+ * - Runs channels/ handlers locally (join / joined / leave / send) behind
101
+ * the `channel()` mock injected with the window.__INFORMER__ context in
102
+ * dev mode, whose frames arrive over Vite's dev websocket
101
103
  * - Sets base to './' so built assets use relative paths
102
104
  *
103
105
  * @param {Object} [options]
@@ -257,6 +259,15 @@ export default function informer(options = {}) {
257
259
  } catch (err) {
258
260
  console.warn(`[informer] Could not read informer.yaml channels: ${err.message}`);
259
261
  }
262
+ // And for channels/ files — the deploy 400s on a stray export or a
263
+ // path no page can subscribe to.
264
+ try {
265
+ for (const message of await validateChannelHandlers(projectRoot)) {
266
+ console.error(`[informer] ${message}`);
267
+ }
268
+ } catch (err) {
269
+ console.warn(`[informer] Could not read channels/: ${err.message}`);
270
+ }
260
271
 
261
272
  // App Channels in dev. Handlers' broadcast() (and the `channels:`
262
273
  // relay behind emit()) publish frames on this hub; each frame goes
@@ -265,10 +276,24 @@ export default function informer(options = {}) {
265
276
  // import.meta.hot. Riding the HMR socket means no second websocket,
266
277
  // no `ws: true` on the /api proxy and no socket credential to mint
267
278
  // — the dev server already owns a live connection to every page.
279
+ // Every page receives every frame; the subscribe loop mounted below
280
+ // (join / joined / leave / send against channels/ files) is what
281
+ // tells a page which channels it may dispatch.
268
282
  const channels = createDevChannels({ appId: buildMock(options).report.id });
269
283
  channels.emitter.on(BROADCAST_EVENT, (frame) => {
270
284
  server.ws.send({ type: 'custom', event: DEV_CHANNEL_EVENT, data: frame });
271
285
  });
286
+ server.middlewares.use(DEV_CHANNEL_API, createDevChannelHandlers(server, {
287
+ serverOrigin,
288
+ authHeader,
289
+ devWorkspaceId,
290
+ projectRoot,
291
+ devBindings: options.devBindings || {},
292
+ appToken,
293
+ channels,
294
+ user: buildMock(options).user,
295
+ roles: (options.mock && options.mock.roles) || []
296
+ }));
272
297
 
273
298
  // Generate .d.ts types for bound `target: app` / `target: pack`
274
299
  // deps from their published OpenAPI docs, so server/ handlers get
@@ -1,25 +1,12 @@
1
1
  import { readdir, stat } from 'node:fs/promises';
2
- import { join, relative, posix } from 'node:path';
2
+ import { join } from 'node:path';
3
3
  import { parse as parseUrl } from 'node:url';
4
- import { loadManifest, manifestBlock, buildDevContext, buildDevCrypto, buildDevMessaging, normalizeFetchPath } from './dev-dependencies.js';
5
- import { createDevChannels, createDevEmit } from './dev-channels.js';
6
- import { devPlatform } from './dev-platform.js';
4
+ import { createDevBagBuilder, buildDevUser } from './dev-bag.js';
5
+ import { createDevChannels } from './dev-channels.js';
7
6
  import { createStreamStore, createStreamServices, isStreamRef, serveDownload as serveDevDownload } from './dev-streams.js';
8
7
 
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
- };
16
-
17
8
  const VALID_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
18
9
 
19
- // Cap dev proxy calls at 30s so a hung upstream fails loudly instead of
20
- // hanging the handler.
21
- const FETCH_TIMEOUT_MS = 30000;
22
-
23
10
  // Strict base64 — see view-api.js for the rationale. Mirror kept identical to
24
11
  // keep dev and prod behavior aligned, down to the scan: the quantified
25
12
  // /^[A-Za-z0-9+/]+={0,2}$/ throws "RangeError: Maximum call stack size
@@ -37,17 +24,19 @@ function isBase64(s) {
37
24
  }
38
25
 
39
26
  /**
40
- * Convert a file path under server/ to a route path.
27
+ * Convert a file path under a handler directory to a route path (mirrors the
28
+ * server's route-scanner.js filePathToRoute).
41
29
  *
42
30
  * Examples:
43
- * server/orders/index.js -> /orders
44
- * server/orders/[id].js -> /orders/:id
31
+ * server/orders/index.js -> /orders
32
+ * server/orders/[id].js -> /orders/:id
45
33
  * server/orders/[id]/approve.js -> /orders/:id/approve
46
- * server/index.js -> /
34
+ * server/index.js -> /
35
+ * channels/rooms/[room].js -> /rooms/:room (dirPrefix 'channels')
47
36
  */
48
- function filePathToRoute(filePath) {
37
+ function filePathToRoute(filePath, dirPrefix = 'server') {
49
38
  let route = filePath
50
- .replace(/^server\//, '')
39
+ .replace(new RegExp(`^${dirPrefix}/`), '')
51
40
  .replace(/\.js$/, '');
52
41
 
53
42
  route = route.replace(/\[([^\]]+)\]/g, ':$1');
@@ -143,7 +132,7 @@ async function scanRoutes(serverDir) {
143
132
  }));
144
133
  }
145
134
 
146
- export { filePathToRoute, walkJsFiles };
135
+ export { filePathToRoute, matchRoute, walkJsFiles };
147
136
 
148
137
  async function walkJsFiles(dir, basePath) {
149
138
  const results = [];
@@ -169,6 +158,54 @@ async function walkJsFiles(dir, basePath) {
169
158
  return results;
170
159
  }
171
160
 
161
+ /**
162
+ * Write a handler's `{ status, headers?, body?, encoding? }` response
163
+ * descriptor (mirrors app-sandbox.js buildInvokeScript / respondCallback).
164
+ * The encoding allow-list and contract checks are enforced inside the ivm in
165
+ * production; replicated here so handlers see the same errors in dev (where
166
+ * there's no isolate to attribute the throw to). If you change this, mirror
167
+ * it in modules/app/routes/view-api.js.
168
+ */
169
+ function sendDescriptor(res, result) {
170
+ const encoding = typeof result.encoding === 'string' ? result.encoding : null;
171
+ if (encoding !== null && encoding !== 'base64') {
172
+ throw new Error(`Unknown response encoding: ${JSON.stringify(encoding)} (expected 'base64' or omitted)`);
173
+ }
174
+ if (encoding === 'base64' && typeof result.body !== 'string') {
175
+ throw new Error(`encoding: 'base64' requires body to be a base64-encoded string, got ${typeof result.body}`);
176
+ }
177
+ const responseBody = result.body !== undefined ? result.body : null;
178
+
179
+ res.statusCode = result.status || 200;
180
+ for (const [key, value] of Object.entries(result.headers || {})) {
181
+ res.setHeader(key, value);
182
+ }
183
+
184
+ if (responseBody === null) {
185
+ res.end();
186
+ } else if (encoding === 'base64') {
187
+ if (!isBase64(responseBody)) {
188
+ throw new Error('Handler returned malformed base64 body');
189
+ }
190
+ res.end(Buffer.from(responseBody, 'base64'));
191
+ } else if (typeof responseBody === 'string') {
192
+ // Strings go out verbatim, as prod passes them.
193
+ if (!res.getHeader('content-type')) {
194
+ res.setHeader('Content-Type', 'application/json');
195
+ }
196
+ res.end(responseBody);
197
+ } else {
198
+ if (!res.getHeader('content-type')) {
199
+ res.setHeader('Content-Type', 'application/json');
200
+ }
201
+ res.end(JSON.stringify(responseBody));
202
+ }
203
+ }
204
+
205
+ function isDescriptor(value) {
206
+ return value !== null && typeof value === 'object' && typeof value.status === 'number';
207
+ }
208
+
172
209
  /**
173
210
  * Create Connect middleware for dev-mode server route execution.
174
211
  *
@@ -188,99 +225,13 @@ async function walkJsFiles(dir, basePath) {
188
225
  * @returns {Function} Connect middleware
189
226
  */
190
227
  export function createMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, roles, user, devBindings, appToken, channels = createDevChannels(), streamStore }) {
191
- // The viewer identity dev handlers see — the same object the page gets as
192
- // window.__INFORMER__.user, so `@user/${request.user.username}` on the
193
- // server names the channel the page subscribes to.
194
- const devUser = {
195
- username: (user && user.username) || 'dev',
196
- displayName: (user && user.displayName) || 'Local Developer',
197
- email: (user && user.email) || null,
198
- timezone: (user && user.timezone) || null
199
- };
228
+ // `@user/${request.user.username}` on the server names the channel the
229
+ // page subscribes to.
230
+ const devUser = buildDevUser(user);
200
231
  const serverDir = join(projectRoot, 'server');
201
232
  const streams = streamStore || createStreamStore();
202
-
203
- // query() implementation proxies to the workspace _sql endpoint
204
- async function query(sql, params) {
205
- if (!devWorkspaceId) {
206
- throw new Error('query() requires INFORMER_DEV_WORKSPACE. Run: npx informer-workspace init');
207
- }
208
-
209
- const resp = await globalThis.fetch(`${serverOrigin}/api/datasources/${devWorkspaceId}/_sql`, {
210
- method: 'POST',
211
- headers: { 'Content-Type': 'application/json', Authorization: authHeader },
212
- body: JSON.stringify({ sql, params: params || [] })
213
- });
214
-
215
- if (!resp.ok) {
216
- const err = await resp.json().catch(() => ({}));
217
- const detail = err.message || resp.statusText;
218
- throw new Error(`query() failed: ${resp.status} ${detail} (${serverOrigin}/api/datasources/${devWorkspaceId}/_sql)`);
219
- }
220
-
221
- const data = await resp.json();
222
- return data.rows;
223
- }
224
-
225
- // fetch() implementation — proxies API calls to the Informer server
226
- async function fetchAs(auth, path, opts = {}) {
227
- const method = (opts.method || 'GET').toUpperCase();
228
- // Same strict canonicalization as prod (app-sandbox.js normalizeFetchPath);
229
- // reject non-canonical shapes here instead of silently accepting them.
230
- const apiPath = normalizeFetchPath(path);
231
- if (!apiPath) {
232
- return { status: 400, body: { error: `Invalid fetch path: ${String(path).slice(0, 80)}` }, contentType: 'application/json' };
233
- }
234
- const url = `${serverOrigin}${apiPath}`;
235
- const fetchOpts = {
236
- method,
237
- headers: { Authorization: auth, 'Content-Type': 'application/json', ...(opts.headers || {}) }
238
- };
239
-
240
- if (opts.body && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
241
- fetchOpts.body = JSON.stringify(opts.body);
242
- }
243
-
244
- // Read the stream exactly once — `.json()` consumes/locks the body, so a
245
- // `.text()` fallback would throw "Body is unusable" on any non-JSON
246
- // response (auth-bounce HTML, proxy error page). Parse in memory
247
- // instead — same as prod's unwrapInject.
248
- let status, contentType, text;
249
- try {
250
- const resp = await globalThis.fetch(url, { ...fetchOpts, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
251
- status = resp.status;
252
- contentType = resp.headers.get('content-type') || '';
253
- text = await resp.text();
254
- } catch (err) {
255
- // Transport failure or timeout — fetch throws (TypeError 'fetch failed'
256
- // with the real reason on err.cause, or a TimeoutError). Return a
257
- // synthetic 502 so the dependency layer names it (dep + url + cause)
258
- // rather than a bare unhandled "fetch failed".
259
- const reason = (err.cause && err.cause.message) || err.message;
260
- return { status: 502, body: { message: `fetch to ${url} failed: ${reason}` }, contentType: 'application/json' };
261
- }
262
- let body;
263
- try { body = JSON.parse(text); } catch { body = text; }
264
- return { status, body, contentType };
265
- }
266
-
267
- async function apiFetch(path, opts = {}) {
268
- return await fetchAs(authHeader, path, opts);
269
- }
270
-
271
- // Cross-app request() targets /api/apps/<id>/view/_/<path>, whose auth accepts
272
- // only the token/session strategies — NOT basic auth. In API-key mode the
273
- // INFORMER_API_KEY Bearer already satisfies that, so reuse it; under basic
274
- // auth a separate API token (INFORMER_APP_TOKEN) is required, and without one
275
- // the app proxy's request() throws a pointed error instead of a bare 401.
276
- // appFetch also stamps x-informer-app-depth:1 so the target runs one hop deep
277
- // and enforces the same one-hop guard it does in production.
278
- const appAuth = appToken
279
- ? `Bearer ${appToken}`
280
- : (authHeader && authHeader.startsWith('Bearer ') ? authHeader : null);
281
- const appFetch = appAuth
282
- ? async (path, opts = {}) => await fetchAs(appAuth, path, { ...opts, headers: { ...(opts.headers || {}), 'x-informer-app-depth': '1' } })
283
- : null;
233
+ const bagBuilder = createDevBagBuilder({ serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings, appToken, channels, logPrefix: '[app]' });
234
+ const { query } = bagBuilder;
284
235
 
285
236
  return async function serverRoutesMiddleware(req, res, next) {
286
237
  try {
@@ -325,34 +276,6 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
325
276
  try { body = JSON.parse(rawBody); } catch { body = rawBody; }
326
277
  }
327
278
 
328
- // crypto helper — mirrors the prod sandbox crypto surface
329
- const cryptoHelper = buildDevCrypto();
330
-
331
- // log helper — mirrors sandbox log(message, data) + log.info/warn/error/debug
332
- const logCall = (level, message, data) => {
333
- const msg = typeof message === 'string' ? message : JSON.stringify(message);
334
- const args = [`[app-log] [${level}] ${msg}`];
335
- if (data) args.push(data);
336
- console.log(...args);
337
- };
338
- const log = Object.assign(
339
- (message, data) => logCall('info', message, data),
340
- {
341
- debug: (message, data) => logCall('debug', message, data),
342
- info: (message, data) => logCall('info', message, data),
343
- warn: (message, data) => logCall('warn', message, data),
344
- error: (message, data) => logCall('error', message, data)
345
- }
346
- );
347
-
348
- // markdown helper — passthrough in dev (production uses `marked`)
349
- const markdown = (text) => text;
350
-
351
- // notify/email — delivery is a console-logged no-op in dev, but the
352
- // required-field validation mirrors prod so an app that passes here
353
- // won't 500 in production.
354
- const { notify, email } = buildDevMessaging('[app]');
355
-
356
279
  // Build request context
357
280
  const request = {
358
281
  method: req.method.toUpperCase(),
@@ -387,8 +310,11 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
387
310
  serveDevDownload(streams, item, res);
388
311
  }
389
312
 
390
- // respond() — sends early response, handler continues in background.
391
- // respond(download) streams the staged bytes instead of JSON.
313
+ // respond() — sends the early response, the handler continues in
314
+ // the background. A `{ status, headers?, body?, encoding? }`
315
+ // descriptor is honored as such (app-sandbox.js respondCallback);
316
+ // anything else is wrapped as 200 JSON; a download handle streams
317
+ // the staged bytes.
392
318
  let responded = false;
393
319
  async function respond(earlyBody) {
394
320
  if (responded) return;
@@ -401,29 +327,16 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
401
327
  return serveDownloadHandle(earlyBody);
402
328
  }
403
329
  responded = true;
330
+ if (isDescriptor(earlyBody)) return sendDescriptor(res, earlyBody);
404
331
  res.statusCode = 200;
405
332
  res.setHeader('Content-Type', 'application/json');
406
333
  res.end(JSON.stringify(earlyBody));
407
334
  }
408
335
 
409
- // Build the dependency-injection context the same way the prod
410
- // sandbox does, so handlers using `await context.myDep.method(...)`
411
- // work locally. The manifest is parsed once per request (deps, env
412
- // and channels all come from that one read) so edits to
413
- // informer.yaml take effect without a dev-server restart.
414
- const manifest = await loadManifest(projectRoot);
415
- const deps = manifestBlock(manifest, 'dependencies');
416
- const context = buildDevContext({ deps, apiFetch, devBindings, appFetch });
417
- const env = manifestBlock(manifest, 'env');
418
-
419
- // emit() writes no app_event row in dev, but still relays a listed
420
- // event to its `channels:` channel; broadcast() publishes a live
421
- // frame to the page (see dev-channels.js).
422
- const emit = createDevEmit({ channels, manifestChannels: manifestBlock(manifest, 'channels'), logPrefix: '[app-event]' });
423
- const { broadcast } = channels;
336
+ const { bag } = await bagBuilder.build();
424
337
 
425
338
  // Call handler — bag mirrors the prod sandbox (see app-sandbox.js).
426
- const result = await handler({ request, context, query: streamQuery, fetch: apiFetch, respond, emit, broadcast, notify, email, embed, crypto: cryptoHelper, markdown, uploads: streamServices.uploads, downloads: streamServices.downloads, log, env, platform: devPlatform() });
339
+ const result = await handler({ ...bag, request, query: streamQuery, respond, uploads: streamServices.uploads, downloads: streamServices.downloads });
427
340
 
428
341
  // Seal any download the handler left open (prod finalizes the same way).
429
342
  await streamServices.finalize();
@@ -434,60 +347,14 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
434
347
  // A returned download handle streams as the response.
435
348
  if (isStreamRef(result, 'download')) return serveDownloadHandle(result);
436
349
 
437
- // Normalize response (mirrors app-sandbox.js buildInvokeScript logic).
438
- // The encoding allow-list and contract checks are enforced inside the
439
- // ivm in production; replicate them here so handlers see the same
440
- // errors in dev (where there's no isolate to attribute the throw to).
441
- // If you change this, mirror it in modules/app/routes/view-api.js.
442
- let status, responseBody, responseHeaders, encoding;
443
-
350
+ // Normalize the return value (mirrors app-sandbox.js buildInvokeScript).
444
351
  if (result === undefined || result === null) {
445
- status = 204;
446
- responseBody = null;
447
- responseHeaders = {};
448
- } else if (typeof result === 'object' && typeof result.status === 'number') {
449
- encoding = typeof result.encoding === 'string' ? result.encoding : null;
450
- if (encoding !== null && encoding !== 'base64') {
451
- throw new Error(`Unknown response encoding: ${JSON.stringify(encoding)} (expected 'base64' or omitted)`);
452
- }
453
- if (encoding === 'base64' && typeof result.body !== 'string') {
454
- throw new Error(`encoding: 'base64' requires body to be a base64-encoded string, got ${typeof result.body}`);
455
- }
456
- status = result.status || 200;
457
- responseBody = result.body !== undefined ? result.body : null;
458
- responseHeaders = result.headers || {};
459
- } else {
460
- status = 200;
461
- responseBody = result;
462
- responseHeaders = { 'content-type': 'application/json' };
463
- }
464
-
465
- res.statusCode = status;
466
- for (const [key, value] of Object.entries(responseHeaders)) {
467
- res.setHeader(key, value);
468
- }
469
-
470
- if (responseBody === null) {
352
+ res.statusCode = 204;
471
353
  res.end();
472
- } else if (encoding === 'base64' && typeof responseBody === 'string') {
473
- if (!isBase64(responseBody)) {
474
- throw new Error('Handler returned malformed base64 body');
475
- }
476
- res.end(Buffer.from(responseBody, 'base64'));
477
- } else if (typeof responseBody === 'string') {
478
- // Pre-PR the dev middleware always JSON.stringify'd the body, so
479
- // a handler returning { body: 'hello' } emitted "hello" (with
480
- // quotes) — diverging from prod which passed strings verbatim.
481
- // This branch fixes that parity.
482
- if (!res.getHeader('content-type')) {
483
- res.setHeader('Content-Type', 'application/json');
484
- }
485
- res.end(responseBody);
354
+ } else if (isDescriptor(result)) {
355
+ sendDescriptor(res, result);
486
356
  } else {
487
- if (!res.getHeader('content-type')) {
488
- res.setHeader('Content-Type', 'application/json');
489
- }
490
- res.end(JSON.stringify(responseBody));
357
+ sendDescriptor(res, { status: 200, headers: { 'content-type': 'application/json' }, body: result });
491
358
  }
492
359
  } catch (err) {
493
360
  viteServer.ssrFixStacktrace(err);