@entrinsik/vite-plugin-informer 2.7.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
@@ -1,13 +1,71 @@
1
- import { existsSync } from 'node:fs';
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { parse as parseYaml } from 'yaml';
2
3
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
3
4
  import { resolve } from 'node:path';
4
5
  import { createClient } from './client.js';
5
- import { loadDependencies, validateDependencies, validateDevBindings, resolveAppBinding } from './dev-dependencies.js';
6
+ import { loadDependencies, loadChannels, validateDependencies, validateDevBindings, resolveAppBinding } from './dev-dependencies.js';
6
7
  import { buildDeclarations } from './openapi-to-dts.js';
7
8
  import { loadEnv, envWritePath } from './env.js';
8
9
  import { createMiddleware as createServerRoutes } from './server-routes.js';
9
10
  import { createAgentMiddleware } from './agent-dev.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';
13
+ import { renderDevChannelScript } from './dev-channel-shim.js';
14
+ import { createStreamStore, createUploadsMiddleware, createDownloadsMiddleware } from './dev-streams.js';
15
+ import { streamsClientSource } from './streams-client.js';
10
16
  import { init, migrate } from './workspace.js';
17
+ import { devPlatform } from './dev-platform.js';
18
+
19
+ /**
20
+ * The dev `window.__INFORMER__` context: defaults overlaid with the plugin's
21
+ * `mock` option. Shared by the page injection and the dev channels hub (whose
22
+ * frames carry `report.id` as the app id).
23
+ */
24
+ function buildMock(options) {
25
+ return {
26
+ report: {
27
+ id: 'dev-local',
28
+ name: 'Local Development'
29
+ },
30
+ theme: 'light',
31
+ roles: [],
32
+ // The viewer identity the server injects (window.__INFORMER__.user);
33
+ // override with mock.user to test @user/<username> channels as someone else.
34
+ user: { username: 'dev', displayName: 'Local Developer' },
35
+ ...options.mock,
36
+ // What the platform offers, as the server injects it; a mock
37
+ // override merges into the dev defaults (see dev-platform.js).
38
+ platform: devPlatform(options.mock && options.mock.platform)
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Occupied-spot check for the dev /api mount: does informer.yaml's
44
+ * access.apis declare this method+path? Mirrors the server's matchWhitelist
45
+ * semantics — segment-aware, `*` matches exactly one segment.
46
+ */
47
+ function manifestOccupies (projectRoot, req) {
48
+ let entries;
49
+ try {
50
+ const raw = parseYaml(readFileSync(resolve(projectRoot, 'informer.yaml'), 'utf8')) || {};
51
+ entries = (raw.access && Array.isArray(raw.access.apis)) ? raw.access.apis : [];
52
+ } catch {
53
+ return false; // no/invalid manifest: nothing is occupied
54
+ }
55
+ // Connect strips the mount prefix, so re-add it for whole-path matching.
56
+ const path = `/api${(req.url || '').split('?')[0]}`;
57
+ const segments = path.split('/').filter(Boolean);
58
+ const method = (req.method || 'GET').toUpperCase();
59
+ return entries.some(entry => {
60
+ const spec = typeof entry === 'string'
61
+ ? entry
62
+ : `${(entry && entry.method) || 'GET'} ${(entry && (entry.url || entry.path)) || ''}`;
63
+ const parsed = /^([A-Za-z]+)\s+(\S+)$/.exec(String(spec).trim());
64
+ if (!parsed || parsed[1].toUpperCase() !== method) return false;
65
+ const want = parsed[2].split('/').filter(Boolean);
66
+ return want.length === segments.length && want.every((s, i) => s === '*' || s === segments[i]);
67
+ });
68
+ }
11
69
 
12
70
  /**
13
71
  * Write generated app-dependency type declarations under .informer/ and keep the
@@ -39,7 +97,9 @@ async function writeAppDepTypes (projectRoot, dts) {
39
97
  *
40
98
  * - Proxies /api requests to the Informer server with Basic auth
41
99
  * - Runs server/ route handlers locally via ssrLoadModule (if server/ dir exists)
42
- * - Injects window.__INFORMER__ context mock in dev mode
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
43
103
  * - Sets base to './' so built assets use relative paths
44
104
  *
45
105
  * @param {Object} [options]
@@ -101,7 +161,12 @@ export default function informer(options = {}) {
101
161
  Authorization: authHeader
102
162
  },
103
163
  ...options.proxy
104
- }
164
+ },
165
+ // NOTE: no /informer-api mount. The server
166
+ // retired that spelling — /api is ONE namespace
167
+ // with manifest-driven precedence (a whitelisted
168
+ // access.apis path OCCUPIES its spot; everything
169
+ // else is the app's own routes), mirrored below.
105
170
  }
106
171
  };
107
172
  }
@@ -185,6 +250,50 @@ export default function informer(options = {}) {
185
250
  for (const message of validateDevBindings(deps, options.devBindings || {})) {
186
251
  console.error(`[informer] vite.config.js: ${message}`);
187
252
  }
253
+ // Same for the `channels:` relay block — the deploy 400s on a bad
254
+ // channel or event name; say so now instead of dropping relays.
255
+ try {
256
+ for (const message of validateChannels(await loadChannels(projectRoot))) {
257
+ console.error(`[informer] informer.yaml: ${message}`);
258
+ }
259
+ } catch (err) {
260
+ console.warn(`[informer] Could not read informer.yaml channels: ${err.message}`);
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
+ }
271
+
272
+ // App Channels in dev. Handlers' broadcast() (and the `channels:`
273
+ // relay behind emit()) publish frames on this hub; each frame goes
274
+ // to the page as a custom event on Vite's own dev websocket, which
275
+ // the injected `__INFORMER__.channel` mock listens to via
276
+ // import.meta.hot. Riding the HMR socket means no second websocket,
277
+ // no `ws: true` on the /api proxy and no socket credential to mint
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.
282
+ const channels = createDevChannels({ appId: buildMock(options).report.id });
283
+ channels.emitter.on(BROADCAST_EVENT, (frame) => {
284
+ server.ws.send({ type: 'custom', event: DEV_CHANNEL_EVENT, data: frame });
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
+ }));
188
297
 
189
298
  // Generate .d.ts types for bound `target: app` / `target: pack`
190
299
  // deps from their published OpenAPI docs, so server/ handlers get
@@ -249,6 +358,16 @@ export default function informer(options = {}) {
249
358
  console.warn(`[informer] app-dependency type generation skipped: ${err.message}`);
250
359
  }
251
360
 
361
+ // App streams (I5-12979): the dev origin's /_uploads and /_downloads
362
+ // — the same protocol the deployed app speaks on its own origin —
363
+ // backed by one in-memory store the server routes' `uploads` /
364
+ // `downloads` services share. Mounted regardless of server/: the
365
+ // client helper is always on __INFORMER__, so a page may stage an
366
+ // upload before any handler exists to consume it.
367
+ const streamStore = createStreamStore();
368
+ server.middlewares.use('/_uploads', createUploadsMiddleware(streamStore));
369
+ server.middlewares.use('/_downloads', createDownloadsMiddleware(streamStore));
370
+
252
371
  // Mount server-side route handlers if a server/ directory exists
253
372
  const serverDir = resolve(projectRoot, 'server');
254
373
 
@@ -259,14 +378,31 @@ export default function informer(options = {}) {
259
378
  devWorkspaceId,
260
379
  projectRoot,
261
380
  roles: (options.mock && options.mock.roles) || [],
381
+ user: buildMock(options).user,
262
382
  // Dev-only bindings for `target: app` / `target: pack`
263
383
  // slots (app: overrides the manifest defaultBinding; pack:
264
384
  // names the locally-installed pack app). Shape:
265
385
  // devBindings: { kanban: { app: 'admin:kanban' } }
266
386
  devBindings: options.devBindings || {},
267
- appToken
387
+ appToken,
388
+ channels,
389
+ streamStore
268
390
  });
269
391
  server.middlewares.use('/api/_server', serverRoutes);
392
+ // Occupied-spot precedence (App API v2, matches production
393
+ // dispatch in view-api.js): a platform API declared in
394
+ // informer.yaml's access.apis OCCUPIES its /api path — those
395
+ // requests skip the local handlers (next() falls through to
396
+ // the /api proxy above); every unclaimed path dispatches the
397
+ // app's own routes. The manifest is re-read per request so
398
+ // edits apply without a restart. Dev limitation: only raw
399
+ // access.apis entries are consulted (dependency-derived
400
+ // grants proxy through their typed context slots anyway).
401
+ // /api/_server stays as the legacy exclusive spelling.
402
+ server.middlewares.use('/api', (req, res, next) => {
403
+ if (manifestOccupies(projectRoot, req)) return next();
404
+ return serverRoutes(req, res, next);
405
+ });
270
406
  }
271
407
 
272
408
  // Mount agent dev middleware if tools/, mcp/, or informer.yaml agents exist
@@ -281,7 +417,8 @@ export default function informer(options = {}) {
281
417
  devWorkspaceId,
282
418
  projectRoot,
283
419
  devBindings: options.devBindings || {},
284
- appToken
420
+ appToken,
421
+ channels
285
422
  });
286
423
  server.middlewares.use('/api/_agent', agentDev);
287
424
  }
@@ -293,22 +430,25 @@ export default function informer(options = {}) {
293
430
  handler(html) {
294
431
  if (!isDev) return html;
295
432
 
296
- const mock = {
297
- report: {
298
- id: 'dev-local',
299
- name: 'Local Development'
300
- },
301
- theme: 'light',
302
- roles: [],
303
- ...options.mock
304
- };
433
+ const mock = buildMock(options);
305
434
 
435
+ // The context is a classic script (synchronous, so it exists
436
+ // before anything else runs). The streams client (upload /
437
+ // downloadUrl) rides inside it: the one piece of the mock that is
438
+ // code, not data, the same helper the deployed page gets,
439
+ // speaking to the /_uploads and /_downloads mounted in
440
+ // configureServer. The channel mock follows as a module script
441
+ // because only a Vite-processed module gets import.meta.hot.
306
442
  const script = `<script>
307
443
  (function() {
308
444
  'use strict';
309
445
  window.__INFORMER__ = ${JSON.stringify(mock)};
446
+ var __streams = ${streamsClientSource()};
447
+ window.__INFORMER__.upload = __streams.upload;
448
+ window.__INFORMER__.downloadUrl = __streams.downloadUrl;
310
449
  })();
311
- </script>`;
450
+ </script>
451
+ ${renderDevChannelScript({ hub: Boolean(serverOrigin) })}`;
312
452
 
313
453
  // Insert after <head> tag, matching server behavior
314
454
  const headIdx = html.indexOf('<head>');
@@ -1,14 +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 { loadDependencies, buildDevContext, loadAppEnv, buildDevCrypto, buildDevMessaging, normalizeFetchPath } from './dev-dependencies.js';
4
+ import { createDevBagBuilder, buildDevUser } from './dev-bag.js';
5
+ import { createDevChannels } from './dev-channels.js';
6
+ import { createStreamStore, createStreamServices, isStreamRef, serveDownload as serveDevDownload } from './dev-streams.js';
5
7
 
6
8
  const VALID_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
7
9
 
8
- // Cap dev proxy calls at 30s so a hung upstream fails loudly instead of
9
- // hanging the handler.
10
- const FETCH_TIMEOUT_MS = 30000;
11
-
12
10
  // Strict base64 — see view-api.js for the rationale. Mirror kept identical to
13
11
  // keep dev and prod behavior aligned, down to the scan: the quantified
14
12
  // /^[A-Za-z0-9+/]+={0,2}$/ throws "RangeError: Maximum call stack size
@@ -26,17 +24,19 @@ function isBase64(s) {
26
24
  }
27
25
 
28
26
  /**
29
- * 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).
30
29
  *
31
30
  * Examples:
32
- * server/orders/index.js -> /orders
33
- * server/orders/[id].js -> /orders/:id
31
+ * server/orders/index.js -> /orders
32
+ * server/orders/[id].js -> /orders/:id
34
33
  * server/orders/[id]/approve.js -> /orders/:id/approve
35
- * server/index.js -> /
34
+ * server/index.js -> /
35
+ * channels/rooms/[room].js -> /rooms/:room (dirPrefix 'channels')
36
36
  */
37
- function filePathToRoute(filePath) {
37
+ function filePathToRoute(filePath, dirPrefix = 'server') {
38
38
  let route = filePath
39
- .replace(/^server\//, '')
39
+ .replace(new RegExp(`^${dirPrefix}/`), '')
40
40
  .replace(/\.js$/, '');
41
41
 
42
42
  route = route.replace(/\[([^\]]+)\]/g, ':$1');
@@ -132,7 +132,7 @@ async function scanRoutes(serverDir) {
132
132
  }));
133
133
  }
134
134
 
135
- export { filePathToRoute, walkJsFiles };
135
+ export { filePathToRoute, matchRoute, walkJsFiles };
136
136
 
137
137
  async function walkJsFiles(dir, basePath) {
138
138
  const results = [];
@@ -158,6 +158,54 @@ async function walkJsFiles(dir, basePath) {
158
158
  return results;
159
159
  }
160
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
+
161
209
  /**
162
210
  * Create Connect middleware for dev-mode server route execution.
163
211
  *
@@ -170,92 +218,20 @@ async function walkJsFiles(dir, basePath) {
170
218
  * @param {string[]} [opts.roles] - dev user roles surfaced on request.roles
171
219
  * @param {Object} [opts.devBindings] - dev bindings for `target: app` deps
172
220
  * @param {string|null} [opts.appToken] - INFORMER_APP_TOKEN for cross-app request()
221
+ * @param {ReturnType<typeof createDevChannels>} [opts.channels] - the dev channels hub
222
+ * behind `broadcast()` and the `channels:` relay (a private one when omitted)
223
+ * @param {Object} [opts.streamStore] - dev stream store shared with the /_uploads
224
+ * and /_downloads middleware (createStreamStore()); a private one when omitted
173
225
  * @returns {Function} Connect middleware
174
226
  */
175
- export function createMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, roles, devBindings, appToken }) {
227
+ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, roles, user, devBindings, appToken, channels = createDevChannels(), streamStore }) {
228
+ // `@user/${request.user.username}` on the server names the channel the
229
+ // page subscribes to.
230
+ const devUser = buildDevUser(user);
176
231
  const serverDir = join(projectRoot, 'server');
177
-
178
- // query() implementation proxies to the workspace _sql endpoint
179
- async function query(sql, params) {
180
- if (!devWorkspaceId) {
181
- throw new Error('query() requires INFORMER_DEV_WORKSPACE. Run: npx informer-workspace init');
182
- }
183
-
184
- const resp = await globalThis.fetch(`${serverOrigin}/api/datasources/${devWorkspaceId}/_sql`, {
185
- method: 'POST',
186
- headers: { 'Content-Type': 'application/json', Authorization: authHeader },
187
- body: JSON.stringify({ sql, params: params || [] })
188
- });
189
-
190
- if (!resp.ok) {
191
- const err = await resp.json().catch(() => ({}));
192
- const detail = err.message || resp.statusText;
193
- throw new Error(`query() failed: ${resp.status} ${detail} (${serverOrigin}/api/datasources/${devWorkspaceId}/_sql)`);
194
- }
195
-
196
- const data = await resp.json();
197
- return data.rows;
198
- }
199
-
200
- // fetch() implementation — proxies API calls to the Informer server
201
- async function fetchAs(auth, path, opts = {}) {
202
- const method = (opts.method || 'GET').toUpperCase();
203
- // Same strict canonicalization as prod (app-sandbox.js normalizeFetchPath);
204
- // reject non-canonical shapes here instead of silently accepting them.
205
- const apiPath = normalizeFetchPath(path);
206
- if (!apiPath) {
207
- return { status: 400, body: { error: `Invalid fetch path: ${String(path).slice(0, 80)}` }, contentType: 'application/json' };
208
- }
209
- const url = `${serverOrigin}${apiPath}`;
210
- const fetchOpts = {
211
- method,
212
- headers: { Authorization: auth, 'Content-Type': 'application/json', ...(opts.headers || {}) }
213
- };
214
-
215
- if (opts.body && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
216
- fetchOpts.body = JSON.stringify(opts.body);
217
- }
218
-
219
- // Read the stream exactly once — `.json()` consumes/locks the body, so a
220
- // `.text()` fallback would throw "Body is unusable" on any non-JSON
221
- // response (auth-bounce HTML, proxy error page). Parse in memory
222
- // instead — same as prod's unwrapInject.
223
- let status, contentType, text;
224
- try {
225
- const resp = await globalThis.fetch(url, { ...fetchOpts, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
226
- status = resp.status;
227
- contentType = resp.headers.get('content-type') || '';
228
- text = await resp.text();
229
- } catch (err) {
230
- // Transport failure or timeout — fetch throws (TypeError 'fetch failed'
231
- // with the real reason on err.cause, or a TimeoutError). Return a
232
- // synthetic 502 so the dependency layer names it (dep + url + cause)
233
- // rather than a bare unhandled "fetch failed".
234
- const reason = (err.cause && err.cause.message) || err.message;
235
- return { status: 502, body: { message: `fetch to ${url} failed: ${reason}` }, contentType: 'application/json' };
236
- }
237
- let body;
238
- try { body = JSON.parse(text); } catch { body = text; }
239
- return { status, body, contentType };
240
- }
241
-
242
- async function apiFetch(path, opts = {}) {
243
- return await fetchAs(authHeader, path, opts);
244
- }
245
-
246
- // Cross-app request() targets /api/apps/<id>/view/_/<path>, whose auth accepts
247
- // only the token/session strategies — NOT basic auth. In API-key mode the
248
- // INFORMER_API_KEY Bearer already satisfies that, so reuse it; under basic
249
- // auth a separate API token (INFORMER_APP_TOKEN) is required, and without one
250
- // the app proxy's request() throws a pointed error instead of a bare 401.
251
- // appFetch also stamps x-informer-app-depth:1 so the target runs one hop deep
252
- // and enforces the same one-hop guard it does in production.
253
- const appAuth = appToken
254
- ? `Bearer ${appToken}`
255
- : (authHeader && authHeader.startsWith('Bearer ') ? authHeader : null);
256
- const appFetch = appAuth
257
- ? async (path, opts = {}) => await fetchAs(appAuth, path, { ...opts, headers: { ...(opts.headers || {}), 'x-informer-app-depth': '1' } })
258
- : null;
232
+ const streams = streamStore || createStreamStore();
233
+ const bagBuilder = createDevBagBuilder({ serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings, appToken, channels, logPrefix: '[app]' });
234
+ const { query } = bagBuilder;
259
235
 
260
236
  return async function serverRoutesMiddleware(req, res, next) {
261
237
  try {
@@ -300,40 +276,6 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
300
276
  try { body = JSON.parse(rawBody); } catch { body = rawBody; }
301
277
  }
302
278
 
303
- // crypto helper — mirrors the prod sandbox crypto surface
304
- const cryptoHelper = buildDevCrypto();
305
-
306
- // log helper — mirrors sandbox log(message, data) + log.info/warn/error/debug
307
- const logCall = (level, message, data) => {
308
- const msg = typeof message === 'string' ? message : JSON.stringify(message);
309
- const args = [`[app-log] [${level}] ${msg}`];
310
- if (data) args.push(data);
311
- console.log(...args);
312
- };
313
- const log = Object.assign(
314
- (message, data) => logCall('info', message, data),
315
- {
316
- debug: (message, data) => logCall('debug', message, data),
317
- info: (message, data) => logCall('info', message, data),
318
- warn: (message, data) => logCall('warn', message, data),
319
- error: (message, data) => logCall('error', message, data)
320
- }
321
- );
322
-
323
- // markdown helper — passthrough in dev (production uses `marked`)
324
- const markdown = (text) => text;
325
-
326
- // emit helper — no-op in dev (logs to console)
327
- const emit = (event, payload) => {
328
- console.log(`[app-event] emit("${event}",`, JSON.stringify(payload), ')');
329
- return { ok: true };
330
- };
331
-
332
- // notify/email — delivery is a console-logged no-op in dev, but the
333
- // required-field validation mirrors prod so an app that passes here
334
- // won't 500 in production.
335
- const { notify, email } = buildDevMessaging('[app]');
336
-
337
279
  // Build request context
338
280
  const request = {
339
281
  method: req.method.toUpperCase(),
@@ -344,92 +286,75 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
344
286
  rawBody,
345
287
  headers: req.headers,
346
288
  roles: roles || [],
347
- user: {
348
- username: 'dev-user',
349
- displayName: 'Dev User',
350
- email: null,
351
- timezone: null
352
- }
289
+ user: { ...devUser }
353
290
  };
354
291
 
355
- // respond() sends early response, handler continues in background
292
+ // App streams (I5-12979): per-invocation uploads/downloads services.
293
+ // An upload handle passed as a query() parameter is swapped for a
294
+ // \x bytea literal on the way to the _sql proxy (prod passes the
295
+ // bytes as a Buffer parameter — same column-typed coercion).
296
+ const streamServices = createStreamServices({ store: streams, query: devWorkspaceId ? query : null });
297
+ const streamQuery = async (sql, params) => await query(sql, await streamServices.resolveQueryParams(params || []));
298
+
299
+ // Serve a download handle as the response, like view-api.js does
300
+ // when a handler returns one (single-use, prod headers).
301
+ function serveDownloadHandle(ref) {
302
+ const item = streams.get('download', ref.id);
303
+ if (!item) throw new Error(`Unknown download ${ref.id} — was it discarded?`);
304
+ // Do NOT force complete: production 404s an unsealed download
305
+ // (app-streams.js#serveDownload), so forcing it here would hide
306
+ // a half-written download that fails once deployed.
307
+ if (!item.complete) {
308
+ throw new Error(`Download ${ref.id} was never ended — call end() (or return it) before serving it.`);
309
+ }
310
+ serveDevDownload(streams, item, res);
311
+ }
312
+
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.
356
318
  let responded = false;
357
- function respond(earlyBody) {
319
+ async function respond(earlyBody) {
358
320
  if (responded) return;
321
+ if (isStreamRef(earlyBody, 'download')) {
322
+ // Seal it first, as production does (app-sandbox.js
323
+ // respondCallback), then claim the response — not before,
324
+ // or a failure here would leave the request unanswered.
325
+ await streamServices.endDownload(earlyBody.id);
326
+ responded = true;
327
+ return serveDownloadHandle(earlyBody);
328
+ }
359
329
  responded = true;
330
+ if (isDescriptor(earlyBody)) return sendDescriptor(res, earlyBody);
360
331
  res.statusCode = 200;
361
332
  res.setHeader('Content-Type', 'application/json');
362
333
  res.end(JSON.stringify(earlyBody));
363
334
  }
364
335
 
365
- // Build the dependency-injection context the same way the prod
366
- // sandbox does, so handlers using `await context.myDep.method(...)`
367
- // work locally. Loaded per request so edits to informer.yaml take
368
- // effect without a dev-server restart.
369
- const deps = await loadDependencies(projectRoot);
370
- const context = buildDevContext({ deps, apiFetch, devBindings, appFetch });
371
- const env = await loadAppEnv(projectRoot);
336
+ const { bag } = await bagBuilder.build();
372
337
 
373
338
  // Call handler — bag mirrors the prod sandbox (see app-sandbox.js).
374
- const result = await handler({ request, context, query, fetch: apiFetch, respond, emit, notify, email, crypto: cryptoHelper, markdown, log, env });
339
+ const result = await handler({ ...bag, request, query: streamQuery, respond, uploads: streamServices.uploads, downloads: streamServices.downloads });
340
+
341
+ // Seal any download the handler left open (prod finalizes the same way).
342
+ await streamServices.finalize();
375
343
 
376
344
  // If respond() was already called, the response is already sent
377
345
  if (responded) return;
378
346
 
379
- // Normalize response (mirrors app-sandbox.js buildInvokeScript logic).
380
- // The encoding allow-list and contract checks are enforced inside the
381
- // ivm in production; replicate them here so handlers see the same
382
- // errors in dev (where there's no isolate to attribute the throw to).
383
- // If you change this, mirror it in modules/app/routes/view-api.js.
384
- let status, responseBody, responseHeaders, encoding;
347
+ // A returned download handle streams as the response.
348
+ if (isStreamRef(result, 'download')) return serveDownloadHandle(result);
385
349
 
350
+ // Normalize the return value (mirrors app-sandbox.js buildInvokeScript).
386
351
  if (result === undefined || result === null) {
387
- status = 204;
388
- responseBody = null;
389
- responseHeaders = {};
390
- } else if (typeof result === 'object' && typeof result.status === 'number') {
391
- encoding = typeof result.encoding === 'string' ? result.encoding : null;
392
- if (encoding !== null && encoding !== 'base64') {
393
- throw new Error(`Unknown response encoding: ${JSON.stringify(encoding)} (expected 'base64' or omitted)`);
394
- }
395
- if (encoding === 'base64' && typeof result.body !== 'string') {
396
- throw new Error(`encoding: 'base64' requires body to be a base64-encoded string, got ${typeof result.body}`);
397
- }
398
- status = result.status || 200;
399
- responseBody = result.body !== undefined ? result.body : null;
400
- responseHeaders = result.headers || {};
401
- } else {
402
- status = 200;
403
- responseBody = result;
404
- responseHeaders = { 'content-type': 'application/json' };
405
- }
406
-
407
- res.statusCode = status;
408
- for (const [key, value] of Object.entries(responseHeaders)) {
409
- res.setHeader(key, value);
410
- }
411
-
412
- if (responseBody === null) {
352
+ res.statusCode = 204;
413
353
  res.end();
414
- } else if (encoding === 'base64' && typeof responseBody === 'string') {
415
- if (!isBase64(responseBody)) {
416
- throw new Error('Handler returned malformed base64 body');
417
- }
418
- res.end(Buffer.from(responseBody, 'base64'));
419
- } else if (typeof responseBody === 'string') {
420
- // Pre-PR the dev middleware always JSON.stringify'd the body, so
421
- // a handler returning { body: 'hello' } emitted "hello" (with
422
- // quotes) — diverging from prod which passed strings verbatim.
423
- // This branch fixes that parity.
424
- if (!res.getHeader('content-type')) {
425
- res.setHeader('Content-Type', 'application/json');
426
- }
427
- res.end(responseBody);
354
+ } else if (isDescriptor(result)) {
355
+ sendDescriptor(res, result);
428
356
  } else {
429
- if (!res.getHeader('content-type')) {
430
- res.setHeader('Content-Type', 'application/json');
431
- }
432
- res.end(JSON.stringify(responseBody));
357
+ sendDescriptor(res, { status: 200, headers: { 'content-type': 'application/json' }, body: result });
433
358
  }
434
359
  } catch (err) {
435
360
  viteServer.ssrFixStacktrace(err);