@entrinsik/vite-plugin-informer 2.11.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.
- package/README.md +5 -3
- package/bin/init.js +13 -2
- package/index.d.ts +14 -0
- package/package.json +1 -1
- package/src/agent-dev.js +8 -11
- package/src/dev-bag.js +75 -19
- package/src/dev-channel-handlers.js +23 -13
- package/src/dev-channel-shim.js +37 -11
- package/src/dev-channels.js +33 -15
- package/src/dev-dependencies.js +140 -4
- package/src/dev-platform.js +80 -7
- package/src/dev-streams.js +176 -27
- package/src/index.js +61 -8
- package/src/server-routes.js +4 -3
- package/src/streams-client.js +121 -8
package/src/dev-channels.js
CHANGED
|
@@ -18,9 +18,10 @@ import { EventEmitter } from 'node:events';
|
|
|
18
18
|
// plus the subscribe-side wildcard `rooms/*` (an ordinary name whose LAST
|
|
19
19
|
// segment is `*`; `*` alone is not a name). Everything after `@user/` is one
|
|
20
20
|
// username, verbatim — the server compares the whole remainder to the
|
|
21
|
-
// socket's own — so `@user/*` is
|
|
21
|
+
// socket's own — so `@user/*` is a valid name for a user called `*`, refused
|
|
22
|
+
// on subscribe as someone else's channel rather than read as a wildcard.
|
|
22
23
|
export const USER_CHANNEL_PREFIX = '@user/';
|
|
23
|
-
export const CHANNEL_NAME = /^(@user
|
|
24
|
+
export const CHANNEL_NAME = /^(@user\/\S+|[\w.-]+(\/[\w.-]+)*(\/\*)?)$/;
|
|
24
25
|
export const CHANNEL_NAME_MAX_LENGTH = 128;
|
|
25
26
|
// `created`, `order_created`
|
|
26
27
|
export const EVENT_NAME = /^[\w.-]+$/;
|
|
@@ -31,8 +32,10 @@ export const EVENT_NAME_MAX_LENGTH = 64;
|
|
|
31
32
|
export const RESERVED_EVENTS = Object.freeze(['error', 'connected']);
|
|
32
33
|
// config-factory.js app.channels.maxFrameBytes default
|
|
33
34
|
export const MAX_FRAME_BYTES = 65536;
|
|
34
|
-
// Frames kept per channel for `replay(channel, since)
|
|
35
|
+
// Frames kept per channel for `replay(channel, since)`, and for how long after
|
|
36
|
+
// the channel's last write (config-factory.js app.channels.replay defaults).
|
|
35
37
|
export const REPLAY_FRAMES = 50;
|
|
38
|
+
export const REPLAY_TTL_MS = 60000;
|
|
36
39
|
// deploy.js CHANNELS_SCHEMA description cap
|
|
37
40
|
const DESCRIPTION_MAX_LENGTH = 500;
|
|
38
41
|
|
|
@@ -59,9 +62,9 @@ export function isChannelName(name) {
|
|
|
59
62
|
return typeof name === 'string' && name.length <= CHANNEL_NAME_MAX_LENGTH && CHANNEL_NAME.test(name);
|
|
60
63
|
}
|
|
61
64
|
|
|
62
|
-
/** `rooms/*`: a subscribe-only name covering every channel
|
|
65
|
+
/** `rooms/*`: a subscribe-only name covering every channel under `rooms/`. Any trailing `/*` counts, as on the server (`@user/*` too, so nothing can broadcast to it). */
|
|
63
66
|
export function isWildcardName(name) {
|
|
64
|
-
return
|
|
67
|
+
return typeof name === 'string' && name.endsWith('/*');
|
|
65
68
|
}
|
|
66
69
|
|
|
67
70
|
export function isEventName(name) {
|
|
@@ -156,23 +159,32 @@ export function validateChannels(block) {
|
|
|
156
159
|
* @param {string} [opts.tenant] - frame tenant (the plugin knows none; defaults to 'dev')
|
|
157
160
|
* @param {string} [opts.appId] - the dev app id (the mocked `report.id`)
|
|
158
161
|
* @param {string} [opts.logPrefix] - console prefix
|
|
162
|
+
* @param {Function} [opts.now] - clock in ms (tests)
|
|
159
163
|
* @returns {{ emitter: EventEmitter, broadcast: Function, relay: Function, replay: Function, tenant: string, appId: string }}
|
|
160
164
|
*/
|
|
161
|
-
export function createDevChannels({ tenant = DEV_TENANT, appId = 'dev-local', logPrefix = '[app-channel]' } = {}) {
|
|
165
|
+
export function createDevChannels({ tenant = DEV_TENANT, appId = 'dev-local', logPrefix = '[app-channel]', now = Date.now } = {}) {
|
|
162
166
|
const emitter = new EventEmitter();
|
|
163
|
-
// channel name → { seq, frames }: the monotonic counter and the
|
|
164
|
-
// REPLAY_FRAMES frames, oldest first.
|
|
167
|
+
// channel name → { seq, frames, writtenAt }: the monotonic counter and the
|
|
168
|
+
// last REPLAY_FRAMES frames, oldest first, with the time of the last write.
|
|
165
169
|
const buffers = new Map();
|
|
166
170
|
|
|
167
171
|
function bufferFor(channel) {
|
|
168
172
|
let buffer = buffers.get(channel);
|
|
169
173
|
if (!buffer) {
|
|
170
|
-
buffer = { seq: 0, frames: [] };
|
|
174
|
+
buffer = { seq: 0, frames: [], writtenAt: 0 };
|
|
171
175
|
buffers.set(channel, buffer);
|
|
172
176
|
}
|
|
173
177
|
return buffer;
|
|
174
178
|
}
|
|
175
179
|
|
|
180
|
+
// The server's PEXPIRE on the buffer key: the whole buffer goes
|
|
181
|
+
// REPLAY_TTL_MS after its last write, while the seq counter stays (it has
|
|
182
|
+
// its own, far longer life).
|
|
183
|
+
function liveFrames(buffer) {
|
|
184
|
+
if (buffer.frames.length && now() - buffer.writtenAt >= REPLAY_TTL_MS) buffer.frames = [];
|
|
185
|
+
return buffer.frames;
|
|
186
|
+
}
|
|
187
|
+
|
|
176
188
|
// Validate and publish one §1.1 frame. Synchronous so the manifest relay
|
|
177
189
|
// can run inside a synchronous emit(); throws the server's error codes.
|
|
178
190
|
// `replay: false` keeps the frame out of the channel's replay buffer (it
|
|
@@ -196,10 +208,13 @@ export function createDevChannels({ tenant = DEV_TENANT, appId = 'dev-local', lo
|
|
|
196
208
|
|
|
197
209
|
const buffer = bufferFor(channel);
|
|
198
210
|
buffer.seq += 1;
|
|
199
|
-
const
|
|
211
|
+
const at = now();
|
|
212
|
+
const frame = { tenant, appId, channel, event, payload: message, seq: buffer.seq, at };
|
|
200
213
|
if (replay !== false) {
|
|
201
|
-
|
|
202
|
-
|
|
214
|
+
const frames = liveFrames(buffer);
|
|
215
|
+
frames.push(frame);
|
|
216
|
+
if (frames.length > REPLAY_FRAMES) frames.shift();
|
|
217
|
+
buffer.writtenAt = at;
|
|
203
218
|
}
|
|
204
219
|
emitter.emit(BROADCAST_EVENT, frame);
|
|
205
220
|
return frame;
|
|
@@ -241,7 +256,9 @@ export function createDevChannels({ tenant = DEV_TENANT, appId = 'dev-local', lo
|
|
|
241
256
|
* first, with the smallest seq still buffered (null when nothing is) and
|
|
242
257
|
* the channel's current seq (0 before its first frame), so the page can
|
|
243
258
|
* tell a gap from a quiet channel. Dev counters never restart, so
|
|
244
|
-
* `current >= since` for any seq the page has seen.
|
|
259
|
+
* `current >= since` for any seq the page has seen. An expired buffer
|
|
260
|
+
* answers no frames and no oldest with the counter intact, which a page
|
|
261
|
+
* that saw an earlier seq reads as a gap.
|
|
245
262
|
*
|
|
246
263
|
* @param {string} channel
|
|
247
264
|
* @param {number} [since] - the last seq the page has seen
|
|
@@ -250,10 +267,11 @@ export function createDevChannels({ tenant = DEV_TENANT, appId = 'dev-local', lo
|
|
|
250
267
|
function replay(channel, since = 0) {
|
|
251
268
|
const buffer = buffers.get(channel);
|
|
252
269
|
if (!buffer) return { frames: [], oldest: null, current: 0 };
|
|
270
|
+
const frames = liveFrames(buffer);
|
|
253
271
|
const from = Number(since) || 0;
|
|
254
272
|
return {
|
|
255
|
-
frames:
|
|
256
|
-
oldest:
|
|
273
|
+
frames: frames.filter(f => f.seq > from),
|
|
274
|
+
oldest: frames.length ? frames[0].seq : null,
|
|
257
275
|
current: buffer.seq
|
|
258
276
|
};
|
|
259
277
|
}
|
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';
|
|
@@ -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 }) {
|
|
391
399
|
const context = {};
|
|
392
400
|
for (const [name, decl] of Object.entries(deps || {})) {
|
|
393
401
|
if (!decl || typeof decl !== 'object') continue;
|
|
@@ -417,7 +425,7 @@ export function buildDevContext({ deps, apiFetch, devBindings = {}, appFetch = n
|
|
|
417
425
|
: null;
|
|
418
426
|
|
|
419
427
|
context[name] = targetId
|
|
420
|
-
? makeDevProxy({ name, target, targetId, apiFetch })
|
|
428
|
+
? makeDevProxy({ name, target, targetId, apiFetch, forwarding })
|
|
421
429
|
: makeUnboundDevProxy({ name, target });
|
|
422
430
|
}
|
|
423
431
|
return context;
|
|
@@ -518,7 +526,7 @@ function makeAppDevProxy({ name, binding, appFetch, kind = 'app' }) {
|
|
|
518
526
|
};
|
|
519
527
|
}
|
|
520
528
|
|
|
521
|
-
function makeDevProxy({ name, target, targetId, apiFetch }) {
|
|
529
|
+
function makeDevProxy({ name, target, targetId, apiFetch, forwarding = null }) {
|
|
522
530
|
switch (target) {
|
|
523
531
|
case 'dataset':
|
|
524
532
|
return {
|
|
@@ -544,7 +552,75 @@ function makeDevProxy({ name, target, targetId, apiFetch }) {
|
|
|
544
552
|
case 'integration':
|
|
545
553
|
return {
|
|
546
554
|
async request(payload) {
|
|
547
|
-
|
|
555
|
+
const { data, form, into, ...rest } = payload || {};
|
|
556
|
+
const wantsBody = isStreamRef(data, 'upload');
|
|
557
|
+
const wantsForm = Boolean(form) && typeof form === 'object' && !Array.isArray(form);
|
|
558
|
+
const wantsInto = into !== undefined && into !== null;
|
|
559
|
+
// Ahead of the plain-call shortcut below: on its own a
|
|
560
|
+
// download handle leaves every flag false, and falling
|
|
561
|
+
// through would JSON-POST the handle to the third party as
|
|
562
|
+
// the request body. Prod refuses it (app-stream-forwarding.js).
|
|
563
|
+
if (isStreamRef(data, 'download')) {
|
|
564
|
+
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' });
|
|
565
|
+
}
|
|
566
|
+
if (!wantsBody && !wantsForm && !wantsInto) {
|
|
567
|
+
return await devCall(apiFetch, 'POST', `integrations/${targetId}/request`, payload || {}, name, 'integration');
|
|
568
|
+
}
|
|
569
|
+
// Staged streams as bodies (I5-13030). Prod streams them
|
|
570
|
+
// through the proxy; dev rides the route's base64 envelope
|
|
571
|
+
// with the same request shape, so anything an author sees
|
|
572
|
+
// here, deployed code sees too — except the envelope's cap.
|
|
573
|
+
if (!forwarding) {
|
|
574
|
+
throw dependencyError(`Dependency "${name}" (integration): staged streams are unavailable in this context`, 400, { dependencyName: name, resourceType: 'integration' });
|
|
575
|
+
}
|
|
576
|
+
if (wantsBody && wantsForm) {
|
|
577
|
+
throw dependencyError('request(): use either `data` or `form` for the body, not both', 400, { dependencyName: name, resourceType: 'integration' });
|
|
578
|
+
}
|
|
579
|
+
// A `data` that is not a stream handle is the caller's own
|
|
580
|
+
// body: keep it. Mirrors app-stream-forwarding.js — without
|
|
581
|
+
// it, request({ data: { q }, into: dl }) sends nothing.
|
|
582
|
+
let body = (!wantsBody && !wantsForm && data !== undefined) ? { ...rest, data } : rest;
|
|
583
|
+
if (wantsForm && data !== undefined) {
|
|
584
|
+
throw dependencyError('request(): use either `data` or `form` for the body, not both', 400, { dependencyName: name, resourceType: 'integration' });
|
|
585
|
+
}
|
|
586
|
+
if (wantsBody) {
|
|
587
|
+
const upload = forwarding.body(data);
|
|
588
|
+
assertForwardable(name, upload.size);
|
|
589
|
+
// content-length describes bytes only this side counted;
|
|
590
|
+
// content-type stays the caller's to relabel, as in prod.
|
|
591
|
+
assertStreamOwnedHeaders(rest.headers, { 'content-length': upload.size }, name);
|
|
592
|
+
body = { ...rest, data: upload.bytes.toString('base64'), encoding: 'base64', headers: withDefaultHeader(rest.headers, 'content-type', upload.contentType) };
|
|
593
|
+
} else if (wantsForm) {
|
|
594
|
+
const { bytes, contentType } = multipart(form, forwarding, name);
|
|
595
|
+
assertForwardable(name, bytes.length);
|
|
596
|
+
// The boundary is generated in multipart() and nowhere
|
|
597
|
+
// else, so content-type is the stream's here; a caller's
|
|
598
|
+
// `multipart/form-data` would drop it.
|
|
599
|
+
assertStreamOwnedHeaders(rest.headers, { 'content-type': contentType, 'content-length': bytes.length }, name);
|
|
600
|
+
body = { ...rest, data: bytes.toString('base64'), encoding: 'base64', headers: withDefaultHeader(rest.headers, 'content-type', contentType) };
|
|
601
|
+
}
|
|
602
|
+
if (!wantsInto) {
|
|
603
|
+
return await devCall(apiFetch, 'POST', `integrations/${targetId}/request`, body, name, 'integration');
|
|
604
|
+
}
|
|
605
|
+
// `into` is claimed before the call, as prod does, so a bad
|
|
606
|
+
// target fails without an upstream round trip — and is given
|
|
607
|
+
// back on every path that does not deliver a body.
|
|
608
|
+
const receiver = forwarding.receiver(into, rest.url);
|
|
609
|
+
let answer;
|
|
610
|
+
try {
|
|
611
|
+
answer = await apiFetch(`integrations/${targetId}/request`, { method: 'POST', body, raw: true });
|
|
612
|
+
} catch (err) {
|
|
613
|
+
receiver.release();
|
|
614
|
+
throw err;
|
|
615
|
+
}
|
|
616
|
+
const { status, bytes, headers, body: parsed } = answer;
|
|
617
|
+
// Prod gates the fill on < 300 (request.js); a 3xx that axios
|
|
618
|
+
// did not follow must not be sealed as the file here either.
|
|
619
|
+
if (status >= 300) {
|
|
620
|
+
receiver.release();
|
|
621
|
+
throw dependencyCallError(name, 'integration', status, parsed);
|
|
622
|
+
}
|
|
623
|
+
return receiver.fill(bytes, headers);
|
|
548
624
|
}
|
|
549
625
|
};
|
|
550
626
|
default:
|
|
@@ -634,6 +710,66 @@ function isBinaryContentType(contentType) {
|
|
|
634
710
|
return Boolean(ct) && !ct.startsWith('text/') && !ct.includes('json') && !ct.includes('event-stream');
|
|
635
711
|
}
|
|
636
712
|
|
|
713
|
+
function assertForwardable(name, size) {
|
|
714
|
+
if (size > DEV_FORWARD_MAX_BYTES) {
|
|
715
|
+
throw new Error(
|
|
716
|
+
`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.`
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Refuse a caller-declared header that describes the staged stream, the way
|
|
723
|
+
* prod's applyStreamHeaders does (modules/integration/routes/request.js).
|
|
724
|
+
*
|
|
725
|
+
* Dev sends the body as a base64 envelope and never ships these names, so
|
|
726
|
+
* without the refusal an author learns a contract that 400s on deploy — or
|
|
727
|
+
* worse, ships a boundary-less multipart body no upstream can parse.
|
|
728
|
+
*/
|
|
729
|
+
function assertStreamOwnedHeaders(headers, owned, name) {
|
|
730
|
+
for (const [ownedName, value] of Object.entries(owned)) {
|
|
731
|
+
const declared = Object.keys(headers || {}).find(k => k.toLowerCase() === ownedName);
|
|
732
|
+
if (declared && String(headers[declared]) !== String(value)) {
|
|
733
|
+
throw dependencyError(
|
|
734
|
+
`request(): \`${declared}\` describes the staged stream and cannot be set by the caller (you sent "${headers[declared]}", the stream is "${value}")`,
|
|
735
|
+
400, { dependencyName: name, resourceType: 'integration' }
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function withDefaultHeader(headers, name, value) {
|
|
742
|
+
const out = { ...(headers || {}) };
|
|
743
|
+
if (!Object.keys(out).some(k => k.toLowerCase() === name)) out[name] = value;
|
|
744
|
+
return out;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/** A multipart/form-data body built here — the plugin ships without form-data. */
|
|
748
|
+
function multipart(form, forwarding, name) {
|
|
749
|
+
const boundary = `----InformerDevForm${Math.random().toString(16).slice(2)}${Date.now().toString(16)}`;
|
|
750
|
+
const parts = [];
|
|
751
|
+
for (const [field, value] of Object.entries(form)) {
|
|
752
|
+
if (value === undefined || value === null) continue;
|
|
753
|
+
if (isStreamRef(value, 'download')) {
|
|
754
|
+
throw dependencyError(`request(): form field "${field}" is a download handle; only uploads can be sent`, 400, { dependencyName: name, resourceType: 'integration' });
|
|
755
|
+
}
|
|
756
|
+
if (isStreamRef(value, 'upload')) {
|
|
757
|
+
const upload = forwarding.body(value);
|
|
758
|
+
// filenameSchema permits a double quote, which would close the
|
|
759
|
+
// disposition early and let a filename inject its own part headers.
|
|
760
|
+
// form-data escapes this for us in prod; here it is hand-rolled.
|
|
761
|
+
const filename = String(upload.filename || 'upload').replace(/["\r\n]/g, '_');
|
|
762
|
+
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'));
|
|
763
|
+
} else if (typeof value === 'object') {
|
|
764
|
+
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`));
|
|
765
|
+
} else {
|
|
766
|
+
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${field}"\r\n\r\n${String(value)}\r\n`));
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
parts.push(Buffer.from(`--${boundary}--\r\n`));
|
|
770
|
+
return { bytes: Buffer.concat(parts), contentType: `multipart/form-data; boundary=${boundary}` };
|
|
771
|
+
}
|
|
772
|
+
|
|
637
773
|
async function devCall(apiFetch, method, path, body, depName, resourceType) {
|
|
638
774
|
const opts = { method };
|
|
639
775
|
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
|
*/
|
|
@@ -42,3 +43,75 @@ export function devPlatform(overrides = {}) {
|
|
|
42
43
|
capabilities: { ...DEV_CAPABILITIES, ...capabilities }
|
|
43
44
|
};
|
|
44
45
|
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whatever the response carries by way of an explanation.
|
|
49
|
+
*
|
|
50
|
+
* fetchAs returns a parsed JSON body when it can and the raw text when it
|
|
51
|
+
* cannot (an auth-bounce HTML page, a proxy error page). Boom answers put the
|
|
52
|
+
* sentence on `message`; fetchAs's own invalid-path synthetic uses `error`.
|
|
53
|
+
* Reading only `message` drops both of the others and leaves the tautology
|
|
54
|
+
* `failed (502): HTTP 502` with the text that explained the failure thrown
|
|
55
|
+
* away.
|
|
56
|
+
*/
|
|
57
|
+
function failureReason(body, status) {
|
|
58
|
+
if (typeof body === 'string') return body.trim().slice(0, 200) || `HTTP ${status}`;
|
|
59
|
+
return (body && (body.message || body.error)) || `HTTP ${status}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The dev bag's `embed(name, text)`, shared by the server-routes and agent
|
|
64
|
+
* mirrors so the two cannot drift.
|
|
65
|
+
*
|
|
66
|
+
* Off by default: the same written explanation a real install gives an app
|
|
67
|
+
* type without the capability, so the dev failure reads as the deployed
|
|
68
|
+
* lesson instead of a bare "embed is not a function".
|
|
69
|
+
*
|
|
70
|
+
* Opted in — `informer({ mock: { platform: { capabilities: { embeddings: true } } } })`
|
|
71
|
+
* — it posts to the deployed app's `_embed` route on the configured server.
|
|
72
|
+
* That route runs the same host function a deployed handler's `embed()`
|
|
73
|
+
* runs, so the vector, the revision and the billing are the deployed ones.
|
|
74
|
+
* The corpus is not: `query()` still goes to the dev workspace datasource,
|
|
75
|
+
* which the pump never writes to, so a search route compares a deployed
|
|
76
|
+
* vector against local rows.
|
|
77
|
+
*
|
|
78
|
+
* It addresses the app by package.json `informer.id`. `informer-init`
|
|
79
|
+
* generates that id locally and the first deploy creates the app under it, so
|
|
80
|
+
* for a scaffolded project it is always set and the question is whether THIS
|
|
81
|
+
* server has ever had a deploy of it — a 404, answered below with what to do
|
|
82
|
+
* about it.
|
|
83
|
+
*/
|
|
84
|
+
export function createDevEmbed({ platform, apiFetch, appId }) {
|
|
85
|
+
if (!(platform && platform.capabilities && platform.capabilities.embeddings)) {
|
|
86
|
+
return async () => {
|
|
87
|
+
throw new Error('embed() is not available in the dev mirror: the embeddings capability needs a real Informer (platform.capabilities.embeddings is false)');
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return async (name, text) => {
|
|
91
|
+
if (!appId) {
|
|
92
|
+
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');
|
|
93
|
+
}
|
|
94
|
+
const { status, body } = await apiFetch(`apps/${appId}/embeddings/${encodeURIComponent(String(name))}/_embed`, {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
body: { text }
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (status === 404) {
|
|
100
|
+
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.`);
|
|
101
|
+
}
|
|
102
|
+
if (status < 200 || status >= 300) {
|
|
103
|
+
throw new Error(`embed('${name}') failed (${status}): ${failureReason(body, status)}`);
|
|
104
|
+
}
|
|
105
|
+
// A 2xx is not yet an answer. globalThis.fetch FOLLOWS redirects, so
|
|
106
|
+
// an expired INFORMER_API_KEY or an SSO proxy in front of the server
|
|
107
|
+
// answers 200 with a sign-in page, and a 204 answers nothing at all.
|
|
108
|
+
// Returned as-is, `result.embedding` is undefined and the failure
|
|
109
|
+
// surfaces much later as a Postgres parameter-type error inside the
|
|
110
|
+
// app's own SQL — sending the developer to debug their query for what
|
|
111
|
+
// is an auth failure.
|
|
112
|
+
if (!body || !Array.isArray(body.embedding)) {
|
|
113
|
+
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)}`);
|
|
114
|
+
}
|
|
115
|
+
return body;
|
|
116
|
+
};
|
|
117
|
+
}
|
package/src/dev-streams.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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, {
|