@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.
- package/README.md +8 -4
- package/bin/deploy.js +3 -0
- package/bin/init.js +13 -2
- package/index.d.ts +21 -5
- package/package.json +1 -1
- package/src/agent-dev.js +9 -12
- package/src/client.js +29 -3
- package/src/deploy.js +198 -45
- package/src/dev-bag.js +75 -19
- package/src/dev-channel-actors.js +352 -0
- package/src/dev-channel-handlers.js +99 -31
- package/src/dev-channel-shim.js +65 -11
- package/src/dev-channels.js +33 -15
- package/src/dev-dependencies.js +197 -19
- package/src/dev-platform.js +82 -7
- package/src/dev-streams.js +176 -27
- package/src/index.js +61 -8
- package/src/openapi-to-dts.js +4 -0
- package/src/server-routes.js +26 -15
- package/src/streams-client.js +121 -8
package/src/dev-dependencies.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
399
|
-
// back to the manifest `defaultBinding` UUID; pack slots
|
|
400
|
-
// fallback — the pin resolves via marketplace installs, which
|
|
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
|
-
*
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
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
|
-
|
|
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;
|
package/src/dev-platform.js
CHANGED
|
@@ -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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* `
|
|
12
|
-
*
|
|
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
|
+
}
|