@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,3 +1,4 @@
1
+ import { isStreamRef } from './dev-streams.js';
1
2
  import { readFile, access } from 'node:fs/promises';
2
3
  import { join } from 'node:path';
3
4
  import crypto from 'node:crypto';
@@ -164,8 +165,8 @@ const METHOD_SURFACE = {
164
165
  query: ['execute'],
165
166
  datasource: ['query'],
166
167
  integration: ['request'],
167
- app: ['request'],
168
- pack: ['request']
168
+ app: ['request', 'url'],
169
+ pack: ['request', 'url']
169
170
  };
170
171
 
171
172
  // Cross-app request() method allow-list — mirrors REQUEST_METHODS in
@@ -387,7 +388,14 @@ export function resolveAppBinding(binding) {
387
388
  * @returns {Object} An object keyed by dependency name, values are typed
388
389
  * proxies with methods matching the target's production method surface.
389
390
  */
390
- export function buildDevContext({ deps, apiFetch, devBindings = {}, appFetch = null }) {
391
+ // The integration request route carries a base64 body of at most 50 MB
392
+ // (integration/routes/request.js REQUEST_MAX_BYTES), so a staged stream the dev
393
+ // proxy forwards through it can be at most this many raw bytes — a little less
394
+ // in practice, since the envelope also carries url, headers and params. Pinned
395
+ // to the server constant by test/streams-parity.test.js.
396
+ export const DEV_FORWARD_MAX_BYTES = Math.floor(52428800 * 3 / 4);
397
+
398
+ export function buildDevContext({ deps, apiFetch, devBindings = {}, appFetch = null, forwarding = null, serverOrigin = null }) {
391
399
  const context = {};
392
400
  for (const [name, decl] of Object.entries(deps || {})) {
393
401
  if (!decl || typeof decl !== 'object') continue;
@@ -395,11 +403,10 @@ export function buildDevContext({ deps, apiFetch, devBindings = {}, appFetch = n
395
403
  if (!VALID_TARGETS.has(target)) continue;
396
404
 
397
405
  // App and pack slots bind from the plugin's `devBindings` (a human
398
- // `owner:slug` pointing request() at the target app). App slots fall
399
- // back to the manifest `defaultBinding` UUID; pack slots have no
400
- // fallback — the pin resolves via marketplace installs, which dev
401
- // doesn't have, so the devBinding names the locally-installed app.
402
- // request() is the only surface either way.
406
+ // `owner:slug` pointing request() and url() at the target app). App
407
+ // slots fall back to the manifest `defaultBinding` UUID; pack slots
408
+ // have no fallback — the pin resolves via marketplace installs, which
409
+ // dev doesn't have, so the devBinding names the locally-installed app.
403
410
  if (target === 'app' || target === 'pack') {
404
411
  const fallback = (target === 'app'
405
412
  && typeof decl.defaultBinding === 'string' && UUID_PATTERN.test(decl.defaultBinding))
@@ -407,7 +414,7 @@ export function buildDevContext({ deps, apiFetch, devBindings = {}, appFetch = n
407
414
  : null;
408
415
  const binding = devBindings[name] != null ? devBindings[name] : fallback;
409
416
  context[name] = binding
410
- ? makeAppDevProxy({ name, binding, appFetch, kind: target })
417
+ ? makeAppDevProxy({ name, binding, appFetch, serverOrigin, kind: target })
411
418
  : makeUnboundDevProxy({ name, target });
412
419
  continue;
413
420
  }
@@ -417,19 +424,38 @@ export function buildDevContext({ deps, apiFetch, devBindings = {}, appFetch = n
417
424
  : null;
418
425
 
419
426
  context[name] = targetId
420
- ? makeDevProxy({ name, target, targetId, apiFetch })
427
+ ? makeDevProxy({ name, target, targetId, apiFetch, forwarding })
421
428
  : makeUnboundDevProxy({ name, target });
422
429
  }
423
430
  return context;
424
431
  }
425
432
 
426
433
  /**
427
- * Dev proxy for a `target: app` or `target: pack` slot. `request()` is the
428
- * only surface. Pack slots reuse this wholesale — in production the pack
429
- * driver resolves its marketplace pin and then delegates the runtime to the
430
- * app driver, and the devBinding IS that resolution done by hand. The prod
431
- * version gate (pack_dependency_out_of_range) is not emulated: dev has no
432
- * pack_install to read a version from.
434
+ * Append an app-relative path, query string, or fragment to a view URL, or
435
+ * null when the result would leave the view. Mirrors joinViewPath in
436
+ * informer-server's modules/app/lib/app-launch.js so dev refuses the same
437
+ * paths prod 400s; exported so test/streams-parity.test.js can hold the two
438
+ * copies to the same input table.
439
+ *
440
+ * Unguarded, as the server copy is: no `path` makes the parse throw, so the
441
+ * one thing that can is a malformed INFORMER_URL, and that deserves to read
442
+ * as the config error it is rather than as a claim about '..' segments.
443
+ */
444
+ export function joinViewPath(viewUrl, path) {
445
+ if (!path) return viewUrl;
446
+ const joined = /^[?#]/.test(path) ? `${viewUrl}${path}` : `${viewUrl}/${path.replace(/^\/+/, '')}`;
447
+ const base = new URL(viewUrl, 'http://localhost').pathname;
448
+ const resolved = new URL(joined, 'http://localhost').pathname;
449
+ return resolved === base || resolved.startsWith(`${base}/`) ? joined : null;
450
+ }
451
+
452
+ /**
453
+ * Dev proxy for a `target: app` or `target: pack` slot, with prod's
454
+ * `request()` and `url()` surface. Pack slots reuse this wholesale — in
455
+ * production the pack driver resolves its marketplace pin and then delegates
456
+ * the runtime to the app driver, and the devBinding IS that resolution done
457
+ * by hand. The prod version gate (pack_dependency_out_of_range) is not
458
+ * emulated: dev has no pack_install to read a version from.
433
459
  *
434
460
  * Production runs `request()` through the target's own /view/_/ dispatch, and
435
461
  * dev injects into the same route:
@@ -444,16 +470,21 @@ export function buildDevContext({ deps, apiFetch, devBindings = {}, appFetch = n
444
470
  * Non-JSON success responses follow the prod envelope contract: text/HTML
445
471
  * returns a text envelope; binary is rejected (dev can't emulate it yet).
446
472
  *
473
+ * url(path) -> <INFORMER_URL>/api/apps/<app>/view[/path | ?query | #fragment]
474
+ * The target's view URL on the configured server, for a page to open.
475
+ *
447
476
  * @param {Object} args
448
477
  * @param {string} args.name - dependency slot name
449
478
  * @param {string|{app?: string}} args.binding - devBindings entry (or the
450
479
  * manifest defaultBinding UUID). A bare string is shorthand for `{ app }`.
451
480
  * @param {Function|null} args.appFetch - token-authed fetch, or null when
452
481
  * INFORMER_APP_TOKEN is unset.
482
+ * @param {string|null} [args.serverOrigin] - INFORMER_URL, the server url()
483
+ * links point at.
453
484
  * @param {'app'|'pack'} [args.kind] - slot flavor, for error labels and the
454
485
  * structured resourceType guest code branches on.
455
486
  */
456
- function makeAppDevProxy({ name, binding, appFetch, kind = 'app' }) {
487
+ function makeAppDevProxy({ name, binding, appFetch, serverOrigin = null, kind = 'app' }) {
457
488
  const { app } = resolveAppBinding(binding);
458
489
 
459
490
  return {
@@ -514,11 +545,30 @@ function makeAppDevProxy({ name, binding, appFetch, kind = 'app' }) {
514
545
  return { status, body, contentType: contentType || '', headers: { 'content-type': contentType || '' } };
515
546
  }
516
547
  return body;
548
+ },
549
+
550
+ async url(path) {
551
+ if (!app) {
552
+ throw new Error(
553
+ `Dependency "${name}" (${kind}): dev url() needs the target app — set devBindings.${name}.app (e.g. 'admin:kanban')`
554
+ );
555
+ }
556
+ if (!serverOrigin) {
557
+ throw new Error(`Dependency "${name}" (${kind}): dev url() needs INFORMER_URL, the server its links point at`);
558
+ }
559
+ if (path !== undefined && path !== null && typeof path !== 'string') {
560
+ throw new Error(`Dependency "${name}" (${kind}): url(path) takes a string`);
561
+ }
562
+ const href = joinViewPath(`${serverOrigin}/api/apps/${encodeURIComponent(app)}/view`, path);
563
+ if (!href) {
564
+ throw new Error(`Dependency "${name}" (${kind}): url(path) must not escape the target app with ".." path segments`);
565
+ }
566
+ return href;
517
567
  }
518
568
  };
519
569
  }
520
570
 
521
- function makeDevProxy({ name, target, targetId, apiFetch }) {
571
+ function makeDevProxy({ name, target, targetId, apiFetch, forwarding = null }) {
522
572
  switch (target) {
523
573
  case 'dataset':
524
574
  return {
@@ -544,7 +594,75 @@ function makeDevProxy({ name, target, targetId, apiFetch }) {
544
594
  case 'integration':
545
595
  return {
546
596
  async request(payload) {
547
- return await devCall(apiFetch, 'POST', `integrations/${targetId}/request`, payload || {}, name, 'integration');
597
+ const { data, form, into, ...rest } = payload || {};
598
+ const wantsBody = isStreamRef(data, 'upload');
599
+ const wantsForm = Boolean(form) && typeof form === 'object' && !Array.isArray(form);
600
+ const wantsInto = into !== undefined && into !== null;
601
+ // Ahead of the plain-call shortcut below: on its own a
602
+ // download handle leaves every flag false, and falling
603
+ // through would JSON-POST the handle to the third party as
604
+ // the request body. Prod refuses it (app-stream-forwarding.js).
605
+ if (isStreamRef(data, 'download')) {
606
+ throw dependencyError('request(): only an upload handle can be sent as the body; a download is what a route produces, not what it forwards', 400, { dependencyName: name, resourceType: 'integration' });
607
+ }
608
+ if (!wantsBody && !wantsForm && !wantsInto) {
609
+ return await devCall(apiFetch, 'POST', `integrations/${targetId}/request`, payload || {}, name, 'integration');
610
+ }
611
+ // Staged streams as bodies (I5-13030). Prod streams them
612
+ // through the proxy; dev rides the route's base64 envelope
613
+ // with the same request shape, so anything an author sees
614
+ // here, deployed code sees too — except the envelope's cap.
615
+ if (!forwarding) {
616
+ throw dependencyError(`Dependency "${name}" (integration): staged streams are unavailable in this context`, 400, { dependencyName: name, resourceType: 'integration' });
617
+ }
618
+ if (wantsBody && wantsForm) {
619
+ throw dependencyError('request(): use either `data` or `form` for the body, not both', 400, { dependencyName: name, resourceType: 'integration' });
620
+ }
621
+ // A `data` that is not a stream handle is the caller's own
622
+ // body: keep it. Mirrors app-stream-forwarding.js — without
623
+ // it, request({ data: { q }, into: dl }) sends nothing.
624
+ let body = (!wantsBody && !wantsForm && data !== undefined) ? { ...rest, data } : rest;
625
+ if (wantsForm && data !== undefined) {
626
+ throw dependencyError('request(): use either `data` or `form` for the body, not both', 400, { dependencyName: name, resourceType: 'integration' });
627
+ }
628
+ if (wantsBody) {
629
+ const upload = forwarding.body(data);
630
+ assertForwardable(name, upload.size);
631
+ // content-length describes bytes only this side counted;
632
+ // content-type stays the caller's to relabel, as in prod.
633
+ assertStreamOwnedHeaders(rest.headers, { 'content-length': upload.size }, name);
634
+ body = { ...rest, data: upload.bytes.toString('base64'), encoding: 'base64', headers: withDefaultHeader(rest.headers, 'content-type', upload.contentType) };
635
+ } else if (wantsForm) {
636
+ const { bytes, contentType } = multipart(form, forwarding, name);
637
+ assertForwardable(name, bytes.length);
638
+ // The boundary is generated in multipart() and nowhere
639
+ // else, so content-type is the stream's here; a caller's
640
+ // `multipart/form-data` would drop it.
641
+ assertStreamOwnedHeaders(rest.headers, { 'content-type': contentType, 'content-length': bytes.length }, name);
642
+ body = { ...rest, data: bytes.toString('base64'), encoding: 'base64', headers: withDefaultHeader(rest.headers, 'content-type', contentType) };
643
+ }
644
+ if (!wantsInto) {
645
+ return await devCall(apiFetch, 'POST', `integrations/${targetId}/request`, body, name, 'integration');
646
+ }
647
+ // `into` is claimed before the call, as prod does, so a bad
648
+ // target fails without an upstream round trip — and is given
649
+ // back on every path that does not deliver a body.
650
+ const receiver = forwarding.receiver(into, rest.url);
651
+ let answer;
652
+ try {
653
+ answer = await apiFetch(`integrations/${targetId}/request`, { method: 'POST', body, raw: true });
654
+ } catch (err) {
655
+ receiver.release();
656
+ throw err;
657
+ }
658
+ const { status, bytes, headers, body: parsed } = answer;
659
+ // Prod gates the fill on < 300 (request.js); a 3xx that axios
660
+ // did not follow must not be sealed as the file here either.
661
+ if (status >= 300) {
662
+ receiver.release();
663
+ throw dependencyCallError(name, 'integration', status, parsed);
664
+ }
665
+ return receiver.fill(bytes, headers);
548
666
  }
549
667
  };
550
668
  default:
@@ -634,6 +752,66 @@ function isBinaryContentType(contentType) {
634
752
  return Boolean(ct) && !ct.startsWith('text/') && !ct.includes('json') && !ct.includes('event-stream');
635
753
  }
636
754
 
755
+ function assertForwardable(name, size) {
756
+ if (size > DEV_FORWARD_MAX_BYTES) {
757
+ throw new Error(
758
+ `Dependency "${name}" (integration): the dev proxy forwards a staged stream through the request route's base64 envelope, which holds at most ${DEV_FORWARD_MAX_BYTES} bytes (this one is ${size}). A deployed app streams it without the cap — test files this size against a deployment.`
759
+ );
760
+ }
761
+ }
762
+
763
+ /**
764
+ * Refuse a caller-declared header that describes the staged stream, the way
765
+ * prod's applyStreamHeaders does (modules/integration/routes/request.js).
766
+ *
767
+ * Dev sends the body as a base64 envelope and never ships these names, so
768
+ * without the refusal an author learns a contract that 400s on deploy — or
769
+ * worse, ships a boundary-less multipart body no upstream can parse.
770
+ */
771
+ function assertStreamOwnedHeaders(headers, owned, name) {
772
+ for (const [ownedName, value] of Object.entries(owned)) {
773
+ const declared = Object.keys(headers || {}).find(k => k.toLowerCase() === ownedName);
774
+ if (declared && String(headers[declared]) !== String(value)) {
775
+ throw dependencyError(
776
+ `request(): \`${declared}\` describes the staged stream and cannot be set by the caller (you sent "${headers[declared]}", the stream is "${value}")`,
777
+ 400, { dependencyName: name, resourceType: 'integration' }
778
+ );
779
+ }
780
+ }
781
+ }
782
+
783
+ function withDefaultHeader(headers, name, value) {
784
+ const out = { ...(headers || {}) };
785
+ if (!Object.keys(out).some(k => k.toLowerCase() === name)) out[name] = value;
786
+ return out;
787
+ }
788
+
789
+ /** A multipart/form-data body built here — the plugin ships without form-data. */
790
+ function multipart(form, forwarding, name) {
791
+ const boundary = `----InformerDevForm${Math.random().toString(16).slice(2)}${Date.now().toString(16)}`;
792
+ const parts = [];
793
+ for (const [field, value] of Object.entries(form)) {
794
+ if (value === undefined || value === null) continue;
795
+ if (isStreamRef(value, 'download')) {
796
+ throw dependencyError(`request(): form field "${field}" is a download handle; only uploads can be sent`, 400, { dependencyName: name, resourceType: 'integration' });
797
+ }
798
+ if (isStreamRef(value, 'upload')) {
799
+ const upload = forwarding.body(value);
800
+ // filenameSchema permits a double quote, which would close the
801
+ // disposition early and let a filename inject its own part headers.
802
+ // form-data escapes this for us in prod; here it is hand-rolled.
803
+ const filename = String(upload.filename || 'upload').replace(/["\r\n]/g, '_');
804
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${field}"; filename="${filename}"\r\nContent-Type: ${upload.contentType}\r\n\r\n`), upload.bytes, Buffer.from('\r\n'));
805
+ } else if (typeof value === 'object') {
806
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${field}"\r\nContent-Type: application/json\r\n\r\n${JSON.stringify(value)}\r\n`));
807
+ } else {
808
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${field}"\r\n\r\n${String(value)}\r\n`));
809
+ }
810
+ }
811
+ parts.push(Buffer.from(`--${boundary}--\r\n`));
812
+ return { bytes: Buffer.concat(parts), contentType: `multipart/form-data; boundary=${boundary}` };
813
+ }
814
+
637
815
  async function devCall(apiFetch, method, path, body, depName, resourceType) {
638
816
  const opts = { method };
639
817
  if (body !== null && body !== undefined) opts.body = body;
@@ -3,13 +3,14 @@
3
3
  * the server injects on `window.__INFORMER__.platform` and on the server
4
4
  * handler / tool bag: the Informer build version and the capability flags.
5
5
  *
6
- * Every capability the dev server mirrors is on. `embeddings` is off: the
7
- * pump and query-time `embed()` need a real Informer, so an app that
8
- * feature-detects on it sees locally exactly what it sees on an install
9
- * without the feature. `version` is `'dev'` (not semver) so a floor check
10
- * treats the dev mirror as "unknown" rather than as any particular release.
11
- * `originMode` is on: the dev server behaves like an app served from its own
12
- * origin, where live channels work.
6
+ * Every capability the dev server mirrors is on. `embeddings` is off by
7
+ * default: the pump needs a real Informer, so an app that feature-detects on
8
+ * it sees locally exactly what it sees on an install without the feature.
9
+ * Turning it on binds query-time `embed()` to a deployed app's `_embed` route
10
+ * (see createDevEmbed); the pump itself is still not mirrored. `version` is
11
+ * `'dev'` (not semver) so a floor check treats the dev mirror as "unknown"
12
+ * rather than as any particular release. `originMode` is on: the dev server
13
+ * behaves like an app served from its own origin, where live channels work.
13
14
  *
14
15
  * Override any of it per project with `informer({ mock: { platform: {…} } })`.
15
16
  */
@@ -30,6 +31,8 @@ export const DEV_CAPABILITIES = Object.freeze({
30
31
  // live channels: broadcast() in the sandbox, the `channels:` relay block,
31
32
  // and channels/ join/leave handlers — the dev server mirrors all three.
32
33
  channels: true,
34
+ // channels/ files with config.actor: one live instance per channel (dev-channel-actors.js)
35
+ channelActors: true,
33
36
  embeddings: false
34
37
  });
35
38
 
@@ -42,3 +45,75 @@ export function devPlatform(overrides = {}) {
42
45
  capabilities: { ...DEV_CAPABILITIES, ...capabilities }
43
46
  };
44
47
  }
48
+
49
+ /**
50
+ * Whatever the response carries by way of an explanation.
51
+ *
52
+ * fetchAs returns a parsed JSON body when it can and the raw text when it
53
+ * cannot (an auth-bounce HTML page, a proxy error page). Boom answers put the
54
+ * sentence on `message`; fetchAs's own invalid-path synthetic uses `error`.
55
+ * Reading only `message` drops both of the others and leaves the tautology
56
+ * `failed (502): HTTP 502` with the text that explained the failure thrown
57
+ * away.
58
+ */
59
+ function failureReason(body, status) {
60
+ if (typeof body === 'string') return body.trim().slice(0, 200) || `HTTP ${status}`;
61
+ return (body && (body.message || body.error)) || `HTTP ${status}`;
62
+ }
63
+
64
+ /**
65
+ * The dev bag's `embed(name, text)`, shared by the server-routes and agent
66
+ * mirrors so the two cannot drift.
67
+ *
68
+ * Off by default: the same written explanation a real install gives an app
69
+ * type without the capability, so the dev failure reads as the deployed
70
+ * lesson instead of a bare "embed is not a function".
71
+ *
72
+ * Opted in — `informer({ mock: { platform: { capabilities: { embeddings: true } } } })`
73
+ * — it posts to the deployed app's `_embed` route on the configured server.
74
+ * That route runs the same host function a deployed handler's `embed()`
75
+ * runs, so the vector, the revision and the billing are the deployed ones.
76
+ * The corpus is not: `query()` still goes to the dev workspace datasource,
77
+ * which the pump never writes to, so a search route compares a deployed
78
+ * vector against local rows.
79
+ *
80
+ * It addresses the app by package.json `informer.id`. `informer-init`
81
+ * generates that id locally and the first deploy creates the app under it, so
82
+ * for a scaffolded project it is always set and the question is whether THIS
83
+ * server has ever had a deploy of it — a 404, answered below with what to do
84
+ * about it.
85
+ */
86
+ export function createDevEmbed({ platform, apiFetch, appId }) {
87
+ if (!(platform && platform.capabilities && platform.capabilities.embeddings)) {
88
+ return async () => {
89
+ throw new Error('embed() is not available in the dev mirror: the embeddings capability needs a real Informer (platform.capabilities.embeddings is false)');
90
+ };
91
+ }
92
+ return async (name, text) => {
93
+ if (!appId) {
94
+ throw new Error('embed() in dev needs an app id to address: set informer.id in package.json (informer-init writes it) and deploy once');
95
+ }
96
+ const { status, body } = await apiFetch(`apps/${appId}/embeddings/${encodeURIComponent(String(name))}/_embed`, {
97
+ method: 'POST',
98
+ body: { text }
99
+ });
100
+
101
+ if (status === 404) {
102
+ throw new Error(`embed('${name}') failed (404): the configured server has no app ${appId} with an embeddings use case "${name}". Deploy this project there (npm run deploy) and declare the use case in embeddings/${name}.js.`);
103
+ }
104
+ if (status < 200 || status >= 300) {
105
+ throw new Error(`embed('${name}') failed (${status}): ${failureReason(body, status)}`);
106
+ }
107
+ // A 2xx is not yet an answer. globalThis.fetch FOLLOWS redirects, so
108
+ // an expired INFORMER_API_KEY or an SSO proxy in front of the server
109
+ // answers 200 with a sign-in page, and a 204 answers nothing at all.
110
+ // Returned as-is, `result.embedding` is undefined and the failure
111
+ // surfaces much later as a Postgres parameter-type error inside the
112
+ // app's own SQL — sending the developer to debug their query for what
113
+ // is an auth failure.
114
+ if (!body || !Array.isArray(body.embedding)) {
115
+ throw new Error(`embed('${name}') got ${status} but no embedding vector — the response did not come from the _embed route. An expired INFORMER_API_KEY or a sign-in proxy in front of the server both answer 200 with a page. Response: ${failureReason(body, status)}`);
116
+ }
117
+ return body;
118
+ };
119
+ }