@entrinsik/vite-plugin-informer 2.10.0 → 2.12.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/env.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import dotenv from 'dotenv';
2
- import { existsSync } from 'node:fs';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
3
  import { resolve, dirname, parse as parsePath } from 'node:path';
4
4
 
5
5
  /**
@@ -71,6 +71,22 @@ export function envWritePath({ mode, cwd } = {}) {
71
71
  return resolve(dir, useMode ? `.env.${mode}` : '.env');
72
72
  }
73
73
 
74
+ /**
75
+ * A variable as defined in the app's OWN env file (the one envWritePath
76
+ * names), ignoring the shell and any parent .env loadEnv walked up to. Null
77
+ * when the file is missing or leaves the variable unset or empty.
78
+ *
79
+ * @param {string} name
80
+ * @param {{ mode?: string, cwd?: string }} options
81
+ * @returns {string|null}
82
+ */
83
+ export function localEnvValue(name, { mode, cwd } = {}) {
84
+ const path = envWritePath({ mode, cwd });
85
+ if (!existsSync(path)) return null;
86
+ const value = dotenv.parse(readFileSync(path, 'utf8'))[name];
87
+ return value ? value : null;
88
+ }
89
+
74
90
  /**
75
91
  * Parse --mode <name> from a process.argv array.
76
92
  *
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';
@@ -38,6 +39,36 @@ function buildMock(options) {
38
39
  };
39
40
  }
40
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
+
41
72
  /**
42
73
  * Occupied-spot check for the dev /api mount: does informer.yaml's
43
74
  * access.apis declare this method+path? Mirrors the server's matchWhitelist
@@ -96,13 +127,16 @@ async function writeAppDepTypes (projectRoot, dts) {
96
127
  *
97
128
  * - Proxies /api requests to the Informer server with Basic auth
98
129
  * - 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
130
+ * - Runs channels/ handlers locally (join / joined / leave / send) behind
131
+ * the `channel()` mock injected with the window.__INFORMER__ context in
132
+ * dev mode, whose frames arrive over Vite's dev websocket
101
133
  * - Sets base to './' so built assets use relative paths
102
134
  *
103
135
  * @param {Object} [options]
104
- * @param {{ report?: object, theme?: 'light'|'dark', roles?: string[] }} [options.mock]
105
- * 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.
106
140
  * @param {Object} [options.devBindings] - dev bindings for `target: app` and
107
141
  * `target: pack` deps. App slots can't be defaultBound in the manifest; pack
108
142
  * slots resolve their marketplace pin via installs dev doesn't have, so the
@@ -179,6 +213,15 @@ export default function informer(options = {}) {
179
213
  const projectRoot = process.cwd();
180
214
  const migrationsDir = resolve(projectRoot, 'migrations');
181
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
+
182
225
  // One API client for the whole dev-server setup — createClient just
183
226
  // builds an auth header (no I/O), so it's hoisted out of the two
184
227
  // branches that each used to rebuild an identical one.
@@ -257,6 +300,15 @@ export default function informer(options = {}) {
257
300
  } catch (err) {
258
301
  console.warn(`[informer] Could not read informer.yaml channels: ${err.message}`);
259
302
  }
303
+ // And for channels/ files — the deploy 400s on a stray export or a
304
+ // path no page can subscribe to.
305
+ try {
306
+ for (const message of await validateChannelHandlers(projectRoot)) {
307
+ console.error(`[informer] ${message}`);
308
+ }
309
+ } catch (err) {
310
+ console.warn(`[informer] Could not read channels/: ${err.message}`);
311
+ }
260
312
 
261
313
  // App Channels in dev. Handlers' broadcast() (and the `channels:`
262
314
  // relay behind emit()) publish frames on this hub; each frame goes
@@ -265,10 +317,26 @@ export default function informer(options = {}) {
265
317
  // import.meta.hot. Riding the HMR socket means no second websocket,
266
318
  // no `ws: true` on the /api proxy and no socket credential to mint
267
319
  // — the dev server already owns a live connection to every page.
268
- const channels = createDevChannels({ appId: buildMock(options).report.id });
320
+ // Every page receives every frame; the subscribe loop mounted below
321
+ // (join / joined / leave / send against channels/ files) is what
322
+ // tells a page which channels it may dispatch.
323
+ const channels = createDevChannels({ appId: mock.report.id });
269
324
  channels.emitter.on(BROADCAST_EVENT, (frame) => {
270
325
  server.ws.send({ type: 'custom', event: DEV_CHANNEL_EVENT, data: frame });
271
326
  });
327
+ server.middlewares.use(DEV_CHANNEL_API, createDevChannelHandlers(server, {
328
+ serverOrigin,
329
+ authHeader,
330
+ devWorkspaceId,
331
+ projectRoot,
332
+ devBindings: options.devBindings || {},
333
+ appToken,
334
+ channels,
335
+ user: mock.user,
336
+ roles: (options.mock && options.mock.roles) || [],
337
+ platform: mock.platform,
338
+ appId
339
+ }));
272
340
 
273
341
  // Generate .d.ts types for bound `target: app` / `target: pack`
274
342
  // deps from their published OpenAPI docs, so server/ handlers get
@@ -343,6 +411,11 @@ export default function informer(options = {}) {
343
411
  server.middlewares.use('/_uploads', createUploadsMiddleware(streamStore));
344
412
  server.middlewares.use('/_downloads', createDownloadsMiddleware(streamStore));
345
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
+
346
419
  // Mount server-side route handlers if a server/ directory exists
347
420
  const serverDir = resolve(projectRoot, 'server');
348
421
 
@@ -353,7 +426,7 @@ export default function informer(options = {}) {
353
426
  devWorkspaceId,
354
427
  projectRoot,
355
428
  roles: (options.mock && options.mock.roles) || [],
356
- user: buildMock(options).user,
429
+ user: mock.user,
357
430
  // Dev-only bindings for `target: app` / `target: pack`
358
431
  // slots (app: overrides the manifest defaultBinding; pack:
359
432
  // names the locally-installed pack app). Shape:
@@ -361,7 +434,9 @@ export default function informer(options = {}) {
361
434
  devBindings: options.devBindings || {},
362
435
  appToken,
363
436
  channels,
364
- streamStore
437
+ streamStore,
438
+ platform,
439
+ appId
365
440
  });
366
441
  server.middlewares.use('/api/_server', serverRoutes);
367
442
  // Occupied-spot precedence (App API v2, matches production
@@ -393,7 +468,9 @@ export default function informer(options = {}) {
393
468
  projectRoot,
394
469
  devBindings: options.devBindings || {},
395
470
  appToken,
396
- channels
471
+ channels,
472
+ platform,
473
+ appId
397
474
  });
398
475
  server.middlewares.use('/api/_agent', agentDev);
399
476
  }
@@ -421,6 +498,7 @@ export default function informer(options = {}) {
421
498
  var __streams = ${streamsClientSource()};
422
499
  window.__INFORMER__.upload = __streams.upload;
423
500
  window.__INFORMER__.downloadUrl = __streams.downloadUrl;
501
+ window.__INFORMER__.streams = __streams.streams;
424
502
  })();
425
503
  </script>
426
504
  ${renderDevChannelScript({ hub: Boolean(serverOrigin) })}`;