@entrinsik/vite-plugin-informer 2.11.0 → 2.13.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.
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { extname } from 'node:path';
2
+ import { extname, basename } from 'node:path';
3
3
 
4
4
  /**
5
5
  * Dev-mode app streams (I5-12979) — the local stand-in for the production
@@ -151,6 +151,47 @@ export function createStreamStore(limits = LIMITS) {
151
151
  return { create, get, bytes, remove, reserve, seal, sweep, touch, items, limits };
152
152
  }
153
153
 
154
+ /**
155
+ * The staged streams of one type, oldest first (I5-13030). Dev has a single
156
+ * user, so the store IS their ledger — no per-user filter to apply.
157
+ */
158
+ export function listStreams(store, type) {
159
+ store.sweep();
160
+ return [...store.items.values()].filter(item => item.type === type).sort((a, b) => a.createdAt - b.createdAt);
161
+ }
162
+
163
+ /** Where the dev origin serves a download: the same shape the bag's download.url takes. */
164
+ export function downloadUrl(item, base = '') {
165
+ return `${base}/_downloads/${encodeURIComponent(item.id)}${item.filename ? `/${encodeURIComponent(item.filename)}` : ''}`;
166
+ }
167
+
168
+ /**
169
+ * MIRRORED from modules/app/lib/app-stream-forwarding.js (I5-13030) and pinned
170
+ * by the parity test: how a filled stream is named when the guest did not
171
+ * name it — the upstream's Content-Disposition, else the request path.
172
+ */
173
+ export function filenameFromDisposition(header) {
174
+ if (typeof header !== 'string') return null;
175
+ const utf8 = /filename\*\s*=\s*(?:UTF-8|utf-8)''([^;]+)/.exec(header);
176
+ if (utf8) {
177
+ try { return decodeURIComponent(utf8[1].trim()); } catch { /* fall through to the plain form */ }
178
+ }
179
+ const plain = /filename\s*=\s*"?([^";]+)"?/.exec(header);
180
+ return plain ? plain[1].trim() : null;
181
+ }
182
+
183
+ export function filenameFor(headers, url) {
184
+ const named = filenameFromDisposition(headers && headers['content-disposition']);
185
+ if (named) return basename(named);
186
+ if (typeof url === 'string') {
187
+ try {
188
+ const last = basename(new URL(url, 'http://upstream.invalid').pathname);
189
+ if (last && last !== '/') return last;
190
+ } catch { /* not a URL we can name a file after */ }
191
+ }
192
+ return 'download';
193
+ }
194
+
154
195
  /** Guest-facing handle: never the store item itself. */
155
196
  export function toHandle(item, extra = {}) {
156
197
  return {
@@ -402,35 +443,38 @@ export function createStreamServices({ store, query, base = '' }) {
402
443
  return { rowCount };
403
444
  }
404
445
 
446
+ /** A live upload handle — what uploads.get() and a filled `into: 'upload'` both hand out. */
447
+ function uploadHandle(item) {
448
+ return {
449
+ ...toHandle(item),
450
+ text: async (encoding) => {
451
+ if (encoding !== undefined && encoding !== null && !Buffer.isEncoding(encoding)) throw httpError(422, `Unknown text encoding "${encoding}"`);
452
+ return inline(item).toString(encoding || 'utf8');
453
+ },
454
+ json: async () => {
455
+ const text = inline(item).toString('utf8');
456
+ try {
457
+ return JSON.parse(text);
458
+ } catch (err) {
459
+ throw httpError(422, `Upload is not valid JSON: ${err.message}`);
460
+ }
461
+ },
462
+ base64: async () => inline(item).toString('base64'),
463
+ extractText: async () => { throw httpError(501, 'upload.extractText() is not available in dev — the platform text extractor runs on the Informer server; test it against a deployed app'); },
464
+ copyInto: async (table, opts) => await copyInto(item, table, opts || {}),
465
+ discard: async () => { store.remove(item.id); return true; }
466
+ };
467
+ }
468
+
405
469
  const uploads = {
406
470
  async get(id) {
407
- const item = ownedUpload(id);
408
- return {
409
- ...toHandle(item),
410
- text: async (encoding) => {
411
- if (encoding !== undefined && encoding !== null && !Buffer.isEncoding(encoding)) throw httpError(422, `Unknown text encoding "${encoding}"`);
412
- return inline(item).toString(encoding || 'utf8');
413
- },
414
- json: async () => {
415
- const text = inline(item).toString('utf8');
416
- try {
417
- return JSON.parse(text);
418
- } catch (err) {
419
- throw httpError(422, `Upload is not valid JSON: ${err.message}`);
420
- }
421
- },
422
- base64: async () => inline(item).toString('base64'),
423
- extractText: async () => { throw httpError(501, 'upload.extractText() is not available in dev — the platform text extractor runs on the Informer server; test it against a deployed app'); },
424
- copyInto: async (table, opts) => await copyInto(item, table, opts || {}),
425
- discard: async () => { store.remove(item.id); return true; }
426
- };
471
+ return uploadHandle(ownedUpload(id));
427
472
  }
428
473
  };
429
474
 
430
475
  function downloadHandle(item) {
431
- const url = `${base}/_downloads/${encodeURIComponent(item.id)}${item.filename ? `/${encodeURIComponent(item.filename)}` : ''}`;
432
476
  const handle = {
433
- ...toHandle(item, { url }),
477
+ ...toHandle(item, { url: downloadUrl(item, base) }),
434
478
  async write(chunk) {
435
479
  writableDownload(item.id);
436
480
  const buf = toBytes(chunk);
@@ -504,7 +548,93 @@ export function createStreamServices({ store, query, base = '' }) {
504
548
  }
505
549
  }
506
550
 
507
- return { uploads, downloads, resolveQueryParams, endDownload, finalize };
551
+ /**
552
+ * Forwarding to an integration (I5-13030). Prod streams the bytes between
553
+ * the staging store and the upstream; the dev proxy reaches the real
554
+ * request route over HTTP with a JSON body, so here the bytes ride that
555
+ * route's base64 envelope instead — the same semantics, under the
556
+ * envelope's own cap (dev-dependencies.js names the gap when a file is
557
+ * over it). What the store does is identical: an outbound handle is read,
558
+ * an `into` target is claimed before the call and filled after it.
559
+ */
560
+ const forwarding = {
561
+ /** The bytes behind an owned, sealed upload reference, with what describes them. */
562
+ body(ref) {
563
+ const item = ownedUpload(ref.id);
564
+ return { bytes: store.bytes(item), contentType: item.contentType || 'application/octet-stream', filename: item.filename, size: item.size };
565
+ },
566
+ /**
567
+ * Claim an `into` target now — a download the guest created (unsealed,
568
+ * unwritten) or 'upload' for a fresh one — and return fill(), which
569
+ * lands the upstream body and seals it. Over the byte cap, fill() fails
570
+ * 413 and leaves a download empty and unsealed, a fresh upload gone.
571
+ */
572
+ receiver(target, url) {
573
+ if (target === 'upload') {
574
+ store.reserve(limits.maxUploadBytes);
575
+ const item = store.create('upload', { filename: null, contentType: null, size: 0, chunks: 0, reserved: limits.maxUploadBytes });
576
+ let settled = false;
577
+ return {
578
+ // Prod's appStream.release: the upstream delivered no body,
579
+ // so give the claim back rather than leaving an upload
580
+ // nobody holds a handle to until the TTL.
581
+ release() {
582
+ if (settled) return;
583
+ settled = true;
584
+ store.remove(item.id);
585
+ },
586
+ fill(bytes, headers = {}) {
587
+ settled = true;
588
+ if (bytes.length > limits.maxUploadBytes) {
589
+ store.remove(item.id);
590
+ throw httpError(413, `The upstream body exceeds the ${limits.maxUploadBytes} byte limit`, { maxUploadBytes: limits.maxUploadBytes });
591
+ }
592
+ item.contentType = (headers['content-type'] || 'application/octet-stream').split(';')[0].trim();
593
+ item.filename = filenameFor(headers, url);
594
+ // An `into` fill is the append producer, which never sets
595
+ // a chunk size — prod reports null, and so must the
596
+ // listing below, not just the handle returned here.
597
+ item.chunkSize = null;
598
+ item.chunks = 1;
599
+ item.chunkData.set(1, Buffer.from(bytes));
600
+ item.size = bytes.length;
601
+ item.complete = true;
602
+ store.seal(item);
603
+ // The same extra KEYS prod's handle carries for a filled
604
+ // upload. Dev is one in-memory buffer, so chunks is always
605
+ // 1 where prod reports the real append-producer count.
606
+ return { ...uploadHandle(item), complete: true, chunks: 1, chunkSize: null };
607
+ }
608
+ };
609
+ }
610
+ if (isStreamRef(target, 'download')) {
611
+ const item = writableDownload(target.id);
612
+ if (item.parts.length) throw httpError(409, 'Download already has bytes; `into` fills a download from empty and seals it');
613
+ return {
614
+ // The guest still owns this one, so there is nothing to
615
+ // reclaim — it stays empty and unsealed, as prod leaves it.
616
+ release() {},
617
+ fill(bytes, headers = {}) {
618
+ if (bytes.length > limits.maxUploadBytes) {
619
+ item.parts = [];
620
+ item.size = 0;
621
+ throw httpError(413, `The upstream body exceeds the ${limits.maxUploadBytes} byte limit`, { maxUploadBytes: limits.maxUploadBytes });
622
+ }
623
+ if (!item.contentType) item.contentType = (headers['content-type'] || 'application/octet-stream').split(';')[0].trim();
624
+ if (!item.filename) item.filename = filenameFor(headers, url);
625
+ item.parts = [Buffer.from(bytes)];
626
+ item.size = bytes.length;
627
+ pending.delete(item.id);
628
+ store.seal(item);
629
+ return { ...downloadHandle(item), complete: true };
630
+ }
631
+ };
632
+ }
633
+ throw httpError(400, `request(): \`into\` must be a download handle or 'upload'`);
634
+ }
635
+ };
636
+
637
+ return { uploads, downloads, resolveQueryParams, endDownload, finalize, forwarding };
508
638
  }
509
639
 
510
640
  // What a guest hands write(): a string or bytes. Anything else is a 422 in
@@ -591,7 +721,7 @@ async function readBody(req) {
591
721
 
592
722
  function uploadSummary(item) {
593
723
  const { received, missing } = missingChunks(item);
594
- return toHandle(item, { chunkSize: item.chunkSize, chunks: item.chunks, complete: item.complete, received, missing });
724
+ return toHandle(item, { chunkSize: Number(item.chunkSize) || null, chunks: item.chunks, complete: item.complete, received, missing });
595
725
  }
596
726
 
597
727
  /**
@@ -612,6 +742,11 @@ export function createUploadsMiddleware(store) {
612
742
  res.setHeader('Location', `/_uploads/${item.id}`);
613
743
  return sendJson(res, 201, uploadSummary(item));
614
744
  }
745
+ // GET /_uploads — what is staged (I5-13030). Dev has one user, so
746
+ // the store's uploads are all theirs, oldest first like prod.
747
+ if (parts.length === 0 && method === 'GET') {
748
+ return sendJson(res, 200, listStreams(store, 'upload').map(item => toHandle(item, { chunkSize: Number(item.chunkSize) || null, chunks: item.chunks, complete: item.complete })));
749
+ }
615
750
  if (parts.length === 0) return next();
616
751
 
617
752
  const item = store.get('upload', parts[0]);
@@ -639,14 +774,28 @@ export function createUploadsMiddleware(store) {
639
774
  };
640
775
  }
641
776
 
642
- /** Connect middleware for GET /_downloads/{id}/{filename?}?keep&inline. */
777
+ /**
778
+ * Connect middleware for GET /_downloads/{id}/{filename?}?keep&inline, plus
779
+ * (I5-13030) GET /_downloads to list what is staged and DELETE /_downloads/{id}
780
+ * to drop one unserved.
781
+ */
643
782
  export function createDownloadsMiddleware(store) {
644
783
  return function downloadsMiddleware(req, res, next) {
645
784
  try {
646
- if (req.method.toUpperCase() !== 'GET') return next();
785
+ const method = req.method.toUpperCase();
647
786
  const url = new URL(req.url, 'http://dev.local');
648
787
  const [id] = url.pathname.split('/').filter(Boolean);
788
+ if (!id && method === 'GET') {
789
+ return sendJson(res, 200, listStreams(store, 'download').map(item => toHandle(item, { complete: item.complete, url: downloadUrl(item) })));
790
+ }
649
791
  if (!id) return next();
792
+ if (method === 'DELETE') {
793
+ if (!store.get('download', id)) throw httpError(404, 'Not Found');
794
+ store.remove(id);
795
+ res.statusCode = 204;
796
+ return res.end();
797
+ }
798
+ if (method !== 'GET') return next();
650
799
  const item = store.get('download', id);
651
800
  if (!item || !item.complete) throw httpError(404, 'Not Found');
652
801
  return serveDownload(store, item, res, {
package/src/index.js CHANGED
@@ -39,6 +39,36 @@ function buildMock(options) {
39
39
  };
40
40
  }
41
41
 
42
+ /**
43
+ * The app id dev surfaces address on the server (the opt-in dev `embed()`
44
+ * posts to its `_embed` route). `informer-init` generates it locally into
45
+ * package.json `informer.id` and the first deploy creates the app under it.
46
+ * Absent, the surface says so at call time rather than the dev server
47
+ * refusing to start.
48
+ */
49
+ function readAppId(projectRoot) {
50
+ const pkgPath = resolve(projectRoot, 'package.json');
51
+ let raw;
52
+ try {
53
+ raw = readFileSync(pkgPath, 'utf8');
54
+ } catch (err) {
55
+ // Unreadable is not the same as "no id set", and the two want
56
+ // different fixes. Saying which one it is beats telling someone to
57
+ // set a field in a file they are already looking at — or that the
58
+ // dev server never found.
59
+ if (err.code !== 'ENOENT') console.warn(`[informer] Could not read ${pkgPath}: ${err.message}`);
60
+ else console.warn(`[informer] No package.json at ${pkgPath}; dev surfaces that address the deployed app are unavailable.`);
61
+ return null;
62
+ }
63
+ try {
64
+ const pkg = JSON.parse(raw);
65
+ return (pkg.informer && pkg.informer.id) || null;
66
+ } catch (err) {
67
+ console.warn(`[informer] Could not parse ${pkgPath}: ${err.message}`);
68
+ return null;
69
+ }
70
+ }
71
+
42
72
  /**
43
73
  * Occupied-spot check for the dev /api mount: does informer.yaml's
44
74
  * access.apis declare this method+path? Mirrors the server's matchWhitelist
@@ -103,8 +133,10 @@ async function writeAppDepTypes (projectRoot, dts) {
103
133
  * - Sets base to './' so built assets use relative paths
104
134
  *
105
135
  * @param {Object} [options]
106
- * @param {{ report?: object, theme?: 'light'|'dark', roles?: string[] }} [options.mock]
107
- * window.__INFORMER__ mock injected in dev.
136
+ * @param {{ report?: object, theme?: 'light'|'dark', roles?: string[], user?: object, platform?: object }} [options.mock]
137
+ * window.__INFORMER__ mock injected in dev. `platform` merges into the dev
138
+ * descriptor, so `{ capabilities: { embeddings: true } }` opts one capability
139
+ * in and leaves the rest. See InformerPluginOptions in index.d.ts.
108
140
  * @param {Object} [options.devBindings] - dev bindings for `target: app` and
109
141
  * `target: pack` deps. App slots can't be defaultBound in the manifest; pack
110
142
  * slots resolve their marketplace pin via installs dev doesn't have, so the
@@ -181,6 +213,15 @@ export default function informer(options = {}) {
181
213
  const projectRoot = process.cwd();
182
214
  const migrationsDir = resolve(projectRoot, 'migrations');
183
215
 
216
+ // One merged descriptor for the whole dev server. Built once
217
+ // rather than per consumer so "the channel hub, the handler bags
218
+ // and the browser all see the same mock" is structural — which is
219
+ // the property the embeddings opt-in depends on, since the flag
220
+ // the app reads and the flag that binds embed() must be the same
221
+ // flag. (transformIndexHtml is a separate hook and builds its own.)
222
+ const mock = buildMock(options);
223
+ const appId = readAppId(projectRoot);
224
+
184
225
  // One API client for the whole dev-server setup — createClient just
185
226
  // builds an auth header (no I/O), so it's hoisted out of the two
186
227
  // branches that each used to rebuild an identical one.
@@ -279,7 +320,7 @@ export default function informer(options = {}) {
279
320
  // Every page receives every frame; the subscribe loop mounted below
280
321
  // (join / joined / leave / send against channels/ files) is what
281
322
  // tells a page which channels it may dispatch.
282
- const channels = createDevChannels({ appId: buildMock(options).report.id });
323
+ const channels = createDevChannels({ appId: mock.report.id });
283
324
  channels.emitter.on(BROADCAST_EVENT, (frame) => {
284
325
  server.ws.send({ type: 'custom', event: DEV_CHANNEL_EVENT, data: frame });
285
326
  });
@@ -291,8 +332,10 @@ export default function informer(options = {}) {
291
332
  devBindings: options.devBindings || {},
292
333
  appToken,
293
334
  channels,
294
- user: buildMock(options).user,
295
- roles: (options.mock && options.mock.roles) || []
335
+ user: mock.user,
336
+ roles: (options.mock && options.mock.roles) || [],
337
+ platform: mock.platform,
338
+ appId
296
339
  }));
297
340
 
298
341
  // Generate .d.ts types for bound `target: app` / `target: pack`
@@ -368,6 +411,11 @@ export default function informer(options = {}) {
368
411
  server.middlewares.use('/_uploads', createUploadsMiddleware(streamStore));
369
412
  server.middlewares.use('/_downloads', createDownloadsMiddleware(streamStore));
370
413
 
414
+ // The merged platform descriptor (dev defaults + mock.platform) is
415
+ // what the handler bags see too, not only the browser: an app that
416
+ // opts into `embeddings` there must find embed() bound, not throwing.
417
+ const platform = mock.platform;
418
+
371
419
  // Mount server-side route handlers if a server/ directory exists
372
420
  const serverDir = resolve(projectRoot, 'server');
373
421
 
@@ -378,7 +426,7 @@ export default function informer(options = {}) {
378
426
  devWorkspaceId,
379
427
  projectRoot,
380
428
  roles: (options.mock && options.mock.roles) || [],
381
- user: buildMock(options).user,
429
+ user: mock.user,
382
430
  // Dev-only bindings for `target: app` / `target: pack`
383
431
  // slots (app: overrides the manifest defaultBinding; pack:
384
432
  // names the locally-installed pack app). Shape:
@@ -386,7 +434,9 @@ export default function informer(options = {}) {
386
434
  devBindings: options.devBindings || {},
387
435
  appToken,
388
436
  channels,
389
- streamStore
437
+ streamStore,
438
+ platform,
439
+ appId
390
440
  });
391
441
  server.middlewares.use('/api/_server', serverRoutes);
392
442
  // Occupied-spot precedence (App API v2, matches production
@@ -418,7 +468,9 @@ export default function informer(options = {}) {
418
468
  projectRoot,
419
469
  devBindings: options.devBindings || {},
420
470
  appToken,
421
- channels
471
+ channels,
472
+ platform,
473
+ appId
422
474
  });
423
475
  server.middlewares.use('/api/_agent', agentDev);
424
476
  }
@@ -446,6 +498,7 @@ export default function informer(options = {}) {
446
498
  var __streams = ${streamsClientSource()};
447
499
  window.__INFORMER__.upload = __streams.upload;
448
500
  window.__INFORMER__.downloadUrl = __streams.downloadUrl;
501
+ window.__INFORMER__.streams = __streams.streams;
449
502
  })();
450
503
  </script>
451
504
  ${renderDevChannelScript({ hub: Boolean(serverOrigin) })}`;
@@ -109,6 +109,10 @@ function slotInterface (iface, spec) {
109
109
  return [
110
110
  `export interface ${iface} {`,
111
111
  overloads.join('\n'),
112
+ ` /** Link into the target app's UI: its view root, or a path, query or`,
113
+ ` * fragment under it. Absolute wherever the deployment names an`,
114
+ ` * external address — always, when apps serve from their own origins. */`,
115
+ ' url(path?: string): Promise<string>;',
112
116
  '}'
113
117
  ].join('\n');
114
118
  }
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import { parse as parseUrl } from 'node:url';
4
4
  import { createDevBagBuilder, buildDevUser } from './dev-bag.js';
5
5
  import { createDevChannels } from './dev-channels.js';
6
+ import { devPlatform } from './dev-platform.js';
6
7
  import { createStreamStore, createStreamServices, isStreamRef, serveDownload as serveDevDownload } from './dev-streams.js';
7
8
 
8
9
  const VALID_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
@@ -163,8 +164,8 @@ async function walkJsFiles(dir, basePath) {
163
164
  * descriptor (mirrors app-sandbox.js buildInvokeScript / respondCallback).
164
165
  * The encoding allow-list and contract checks are enforced inside the ivm in
165
166
  * 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.
167
+ * there's no isolate to attribute the throw to). The header rule is shared
168
+ * with prod's modules/app/lib/handler-headers.js — change both.
168
169
  */
169
170
  function sendDescriptor(res, result) {
170
171
  const encoding = typeof result.encoding === 'string' ? result.encoding : null;
@@ -176,28 +177,38 @@ function sendDescriptor(res, result) {
176
177
  }
177
178
  const responseBody = result.body !== undefined ? result.body : null;
178
179
 
180
+ // Validate before writing anything: prod rejects a malformed body while
181
+ // building the response, so nothing of the handler's reaches the client. Left
182
+ // below the header loop, a 500 here still carried the handler's
183
+ // Content-Disposition and the browser saved a .pdf full of JSON.
184
+ if (encoding === 'base64' && !isBase64(responseBody)) {
185
+ throw new Error('Handler returned malformed base64 body');
186
+ }
187
+
179
188
  res.statusCode = result.status || 200;
189
+ // Lowercased for wire parity with Hapi. Node's header lookup is
190
+ // case-insensitive either way, which is why dev never reproduced I5-12972.
180
191
  for (const [key, value] of Object.entries(result.headers || {})) {
181
- res.setHeader(key, value);
192
+ res.setHeader(key.toLowerCase(), value);
193
+ }
194
+
195
+ // Same rule as prod's applyHandlerHeaders, and applied before the body is
196
+ // branched on so it covers base64 too. Dev used to leave a binary response
197
+ // unlabelled, which the browser sniffed happily while prod shipped it as
198
+ // application/json — the I5-12972 failure, arrived at from the other side.
199
+ const declaredType = res.getHeader('content-type');
200
+ if (typeof declaredType !== 'string' || !declaredType.trim()) {
201
+ res.setHeader('content-type', 'application/json');
182
202
  }
183
203
 
184
204
  if (responseBody === null) {
185
205
  res.end();
186
206
  } else if (encoding === 'base64') {
187
- if (!isBase64(responseBody)) {
188
- throw new Error('Handler returned malformed base64 body');
189
- }
190
207
  res.end(Buffer.from(responseBody, 'base64'));
191
208
  } else if (typeof responseBody === 'string') {
192
209
  // Strings go out verbatim, as prod passes them.
193
- if (!res.getHeader('content-type')) {
194
- res.setHeader('Content-Type', 'application/json');
195
- }
196
210
  res.end(responseBody);
197
211
  } else {
198
- if (!res.getHeader('content-type')) {
199
- res.setHeader('Content-Type', 'application/json');
200
- }
201
212
  res.end(JSON.stringify(responseBody));
202
213
  }
203
214
  }
@@ -224,13 +235,13 @@ function isDescriptor(value) {
224
235
  * and /_downloads middleware (createStreamStore()); a private one when omitted
225
236
  * @returns {Function} Connect middleware
226
237
  */
227
- export function createMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, roles, user, devBindings, appToken, channels = createDevChannels(), streamStore }) {
238
+ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, roles, user, devBindings, appToken, channels = createDevChannels(), streamStore, platform = devPlatform(), appId = null }) {
228
239
  // `@user/${request.user.username}` on the server names the channel the
229
240
  // page subscribes to.
230
241
  const devUser = buildDevUser(user);
231
242
  const serverDir = join(projectRoot, 'server');
232
243
  const streams = streamStore || createStreamStore();
233
- const bagBuilder = createDevBagBuilder({ serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings, appToken, channels, logPrefix: '[app]' });
244
+ const bagBuilder = createDevBagBuilder({ serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings, appToken, channels, logPrefix: '[app]', platform, appId });
234
245
  const { query } = bagBuilder;
235
246
 
236
247
  return async function serverRoutesMiddleware(req, res, next) {
@@ -333,7 +344,7 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
333
344
  res.end(JSON.stringify(earlyBody));
334
345
  }
335
346
 
336
- const { bag } = await bagBuilder.build();
347
+ const { bag } = await bagBuilder.build({ forwarding: streamServices.forwarding });
337
348
 
338
349
  // Call handler — bag mirrors the prod sandbox (see app-sandbox.js).
339
350
  const result = await handler({ ...bag, request, query: streamQuery, respond, uploads: streamServices.uploads, downloads: streamServices.downloads });