@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.
- package/README.md +7 -3
- package/bin/init.js +13 -2
- package/bin/workspace.js +15 -7
- package/index.d.ts +14 -0
- package/package.json +1 -1
- package/src/agent-dev.js +8 -11
- package/src/dev-bag.js +241 -0
- package/src/dev-channel-handlers.js +335 -0
- package/src/dev-channel-shim.js +276 -39
- package/src/dev-channels.js +138 -28
- package/src/dev-dependencies.js +140 -4
- package/src/dev-platform.js +81 -5
- package/src/dev-streams.js +176 -27
- package/src/env.js +17 -1
- package/src/index.js +87 -9
- package/src/server-routes.js +79 -211
- package/src/streams-client.js +121 -8
package/src/dev-channels.js
CHANGED
|
@@ -2,8 +2,9 @@ import { EventEmitter } from 'node:events';
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* App Channels in dev: the `broadcast(channel, event, payload)` verb for
|
|
5
|
-
* in-process handlers,
|
|
6
|
-
* `emitAppEvent` on the server
|
|
5
|
+
* in-process handlers, the `channels:` manifest relay that mirrors
|
|
6
|
+
* `emitAppEvent` on the server, and the per-channel replay buffer the page
|
|
7
|
+
* catches up from after a dropped dev websocket.
|
|
7
8
|
*
|
|
8
9
|
* A broadcast validates exactly like the server's `broadcastAppMessage`
|
|
9
10
|
* (app-channel-broadcast.js) — same regexes, limits and error messages — and
|
|
@@ -13,21 +14,36 @@ import { EventEmitter } from 'node:events';
|
|
|
13
14
|
* server-cluster concerns and have no dev counterpart.
|
|
14
15
|
*/
|
|
15
16
|
|
|
16
|
-
// Mirrors of app-channel-broadcast.js: `orders`, `orders/east`, `@user/brad
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
|
|
17
|
+
// Mirrors of app-channel-broadcast.js: `orders`, `orders/east`, `@user/brad`,
|
|
18
|
+
// plus the subscribe-side wildcard `rooms/*` (an ordinary name whose LAST
|
|
19
|
+
// segment is `*`; `*` alone is not a name). Everything after `@user/` is one
|
|
20
|
+
// username, verbatim — the server compares the whole remainder to the
|
|
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.
|
|
23
|
+
export const USER_CHANNEL_PREFIX = '@user/';
|
|
24
|
+
export const CHANNEL_NAME = /^(@user\/\S+|[\w.-]+(\/[\w.-]+)*(\/\*)?)$/;
|
|
20
25
|
export const CHANNEL_NAME_MAX_LENGTH = 128;
|
|
21
26
|
// `created`, `order_created`
|
|
22
27
|
export const EVENT_NAME = /^[\w.-]+$/;
|
|
23
28
|
export const EVENT_NAME_MAX_LENGTH = 64;
|
|
29
|
+
// Event names the client owns: `connected` is dispatched after a subscribe is
|
|
30
|
+
// admitted, `error` carries refusals and drops. A handler cannot broadcast,
|
|
31
|
+
// send or export either.
|
|
32
|
+
export const RESERVED_EVENTS = Object.freeze(['error', 'connected']);
|
|
24
33
|
// config-factory.js app.channels.maxFrameBytes default
|
|
25
34
|
export const MAX_FRAME_BYTES = 65536;
|
|
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).
|
|
37
|
+
export const REPLAY_FRAMES = 50;
|
|
38
|
+
export const REPLAY_TTL_MS = 60000;
|
|
26
39
|
// deploy.js CHANNELS_SCHEMA description cap
|
|
27
40
|
const DESCRIPTION_MAX_LENGTH = 500;
|
|
28
41
|
|
|
29
42
|
// The Vite custom event a frame rides to the page (`hot.on(DEV_CHANNEL_EVENT, ...)`).
|
|
30
43
|
export const DEV_CHANNEL_EVENT = 'informer:channel';
|
|
44
|
+
// Where the page's subscribe / unsubscribe / send / replay calls land on the
|
|
45
|
+
// dev server (see dev-channel-handlers.js).
|
|
46
|
+
export const DEV_CHANNEL_API = '/_dev/channels';
|
|
31
47
|
// The plugin has no tenant identity; frames carry this until it does.
|
|
32
48
|
export const DEV_TENANT = 'dev';
|
|
33
49
|
|
|
@@ -46,23 +62,53 @@ export function isChannelName(name) {
|
|
|
46
62
|
return typeof name === 'string' && name.length <= CHANNEL_NAME_MAX_LENGTH && CHANNEL_NAME.test(name);
|
|
47
63
|
}
|
|
48
64
|
|
|
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). */
|
|
66
|
+
export function isWildcardName(name) {
|
|
67
|
+
return typeof name === 'string' && name.endsWith('/*');
|
|
68
|
+
}
|
|
69
|
+
|
|
49
70
|
export function isEventName(name) {
|
|
50
71
|
return typeof name === 'string' && name.length <= EVENT_NAME_MAX_LENGTH && EVENT_NAME.test(name);
|
|
51
72
|
}
|
|
52
73
|
|
|
74
|
+
export function isReservedEvent(name) {
|
|
75
|
+
return RESERVED_EVENTS.includes(name);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The wildcard names a frame on `channel` is also delivered to, nearest first:
|
|
80
|
+
* `a/b/c` → `['a/b/*', 'a/*']`. A one-segment name has none, and a `@user/`
|
|
81
|
+
* channel never matches a wildcard.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} channel
|
|
84
|
+
* @returns {string[]}
|
|
85
|
+
*/
|
|
86
|
+
export function wildcardParents(channel) {
|
|
87
|
+
if (typeof channel !== 'string' || channel.startsWith(USER_CHANNEL_PREFIX)) return [];
|
|
88
|
+
const segments = channel.split('/');
|
|
89
|
+
const parents = [];
|
|
90
|
+
for (let i = segments.length - 1; i >= 1; i--) {
|
|
91
|
+
parents.push(`${segments.slice(0, i).join('/')}/*`);
|
|
92
|
+
}
|
|
93
|
+
return parents;
|
|
94
|
+
}
|
|
95
|
+
|
|
53
96
|
// Shaped like the boom error the server throws: message === code, plus the
|
|
54
97
|
// HTTP status it would carry, so a handler can branch on either in dev.
|
|
55
|
-
function channelError(code, statusCode) {
|
|
98
|
+
export function channelError(code, statusCode) {
|
|
56
99
|
const err = new Error(code);
|
|
57
100
|
err.code = code;
|
|
58
101
|
err.statusCode = statusCode;
|
|
59
102
|
return err;
|
|
60
103
|
}
|
|
61
104
|
|
|
105
|
+
const onRequired = (name) => `channels.${name}: "on" is required — list the events to relay; a channel with nothing to relay needs no declaration`;
|
|
106
|
+
|
|
62
107
|
/**
|
|
63
108
|
* Validate the shape of a parsed `channels:` block. Returns human-readable
|
|
64
109
|
* error strings (empty when valid). Mirrors deploy.js CHANNELS_SCHEMA so an
|
|
65
|
-
* author sees at boot what the deploy would 400 on
|
|
110
|
+
* author sees at boot what the deploy would 400 on: the block declares
|
|
111
|
+
* relays only, so every entry needs a non-empty `on`.
|
|
66
112
|
*
|
|
67
113
|
* @param {*} block - The raw `channels:` value
|
|
68
114
|
* @returns {string[]} Error messages, one per problem
|
|
@@ -71,16 +117,19 @@ export function validateChannels(block) {
|
|
|
71
117
|
const errors = [];
|
|
72
118
|
if (block === undefined || block === null) return errors;
|
|
73
119
|
if (typeof block !== 'object' || Array.isArray(block)) {
|
|
74
|
-
return ['channels: must be a map of channel name → { description
|
|
120
|
+
return ['channels: must be a map of channel name → { on, description? }'];
|
|
75
121
|
}
|
|
76
122
|
for (const [name, def] of Object.entries(block)) {
|
|
77
|
-
if (!isChannelName(name)) {
|
|
123
|
+
if (!isChannelName(name) || isWildcardName(name)) {
|
|
78
124
|
errors.push(`channels: invalid channel name "${name}" (use segments of letters, digits, _ . -, joined by /, max ${CHANNEL_NAME_MAX_LENGTH} chars)`);
|
|
79
125
|
continue;
|
|
80
126
|
}
|
|
81
|
-
if (def === null || def === undefined)
|
|
127
|
+
if (def === null || def === undefined) {
|
|
128
|
+
errors.push(onRequired(name));
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
82
131
|
if (typeof def !== 'object' || Array.isArray(def)) {
|
|
83
|
-
errors.push(`channels.${name}: must be a map with
|
|
132
|
+
errors.push(`channels.${name}: must be a map with "on" and an optional "description"`);
|
|
84
133
|
continue;
|
|
85
134
|
}
|
|
86
135
|
for (const key of Object.keys(def)) {
|
|
@@ -89,12 +138,14 @@ export function validateChannels(block) {
|
|
|
89
138
|
if (def.description !== undefined && (typeof def.description !== 'string' || def.description.length > DESCRIPTION_MAX_LENGTH)) {
|
|
90
139
|
errors.push(`channels.${name}.description: must be a string of at most ${DESCRIPTION_MAX_LENGTH} chars`);
|
|
91
140
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
141
|
+
const events = def.on === undefined || def.on === null ? [] : [].concat(def.on);
|
|
142
|
+
if (events.length === 0) {
|
|
143
|
+
errors.push(onRequired(name));
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
for (const event of events) {
|
|
147
|
+
if (!isEventName(event)) {
|
|
148
|
+
errors.push(`channels.${name}.on: invalid event name ${JSON.stringify(event)} (letters, digits, _ . -, max ${EVENT_NAME_MAX_LENGTH} chars)`);
|
|
98
149
|
}
|
|
99
150
|
}
|
|
100
151
|
}
|
|
@@ -108,16 +159,40 @@ export function validateChannels(block) {
|
|
|
108
159
|
* @param {string} [opts.tenant] - frame tenant (the plugin knows none; defaults to 'dev')
|
|
109
160
|
* @param {string} [opts.appId] - the dev app id (the mocked `report.id`)
|
|
110
161
|
* @param {string} [opts.logPrefix] - console prefix
|
|
111
|
-
* @
|
|
162
|
+
* @param {Function} [opts.now] - clock in ms (tests)
|
|
163
|
+
* @returns {{ emitter: EventEmitter, broadcast: Function, relay: Function, replay: Function, tenant: string, appId: string }}
|
|
112
164
|
*/
|
|
113
|
-
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 } = {}) {
|
|
114
166
|
const emitter = new EventEmitter();
|
|
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.
|
|
169
|
+
const buffers = new Map();
|
|
170
|
+
|
|
171
|
+
function bufferFor(channel) {
|
|
172
|
+
let buffer = buffers.get(channel);
|
|
173
|
+
if (!buffer) {
|
|
174
|
+
buffer = { seq: 0, frames: [], writtenAt: 0 };
|
|
175
|
+
buffers.set(channel, buffer);
|
|
176
|
+
}
|
|
177
|
+
return buffer;
|
|
178
|
+
}
|
|
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
|
+
}
|
|
115
187
|
|
|
116
188
|
// Validate and publish one §1.1 frame. Synchronous so the manifest relay
|
|
117
189
|
// can run inside a synchronous emit(); throws the server's error codes.
|
|
118
|
-
|
|
119
|
-
|
|
190
|
+
// `replay: false` keeps the frame out of the channel's replay buffer (it
|
|
191
|
+
// still takes a seq), as broadcastAppMessage does in production.
|
|
192
|
+
function publish(channel, event, payload, { replay } = {}) {
|
|
193
|
+
if (!isChannelName(channel) || isWildcardName(channel)) throw channelError('app_channel_invalid_name', 400);
|
|
120
194
|
if (!isEventName(event)) throw channelError('app_channel_invalid_event', 400);
|
|
195
|
+
if (isReservedEvent(event)) throw channelError('app_channel_reserved_event', 400);
|
|
121
196
|
|
|
122
197
|
const message = payload === undefined ? null : payload;
|
|
123
198
|
let serialized;
|
|
@@ -131,17 +206,27 @@ export function createDevChannels({ tenant = DEV_TENANT, appId = 'dev-local', lo
|
|
|
131
206
|
if (serialized === undefined) throw channelError('app_channel_invalid_payload', 400);
|
|
132
207
|
if (Buffer.byteLength(serialized) > MAX_FRAME_BYTES) throw channelError('app_channel_frame_too_large', 413);
|
|
133
208
|
|
|
134
|
-
const
|
|
209
|
+
const buffer = bufferFor(channel);
|
|
210
|
+
buffer.seq += 1;
|
|
211
|
+
const at = now();
|
|
212
|
+
const frame = { tenant, appId, channel, event, payload: message, seq: buffer.seq, at };
|
|
213
|
+
if (replay !== false) {
|
|
214
|
+
const frames = liveFrames(buffer);
|
|
215
|
+
frames.push(frame);
|
|
216
|
+
if (frames.length > REPLAY_FRAMES) frames.shift();
|
|
217
|
+
buffer.writtenAt = at;
|
|
218
|
+
}
|
|
135
219
|
emitter.emit(BROADCAST_EVENT, frame);
|
|
136
220
|
return frame;
|
|
137
221
|
}
|
|
138
222
|
|
|
139
223
|
// The bag member. Async like the sandbox's, so a bad name/event/payload
|
|
140
|
-
// rejects rather than throws, exactly as it does in production
|
|
141
|
-
|
|
142
|
-
|
|
224
|
+
// rejects rather than throws, exactly as it does in production; resolves
|
|
225
|
+
// `{ ok, seq }` like the server, and honors `options.replay`.
|
|
226
|
+
async function broadcast(channel, event, payload, options) {
|
|
227
|
+
const frame = publish(channel, event, payload, options && typeof options === 'object' ? options : {});
|
|
143
228
|
console.log(`${logPrefix} broadcast("${channel}", "${event}", ${previewPayload(frame.payload)})`);
|
|
144
|
-
return { ok: true };
|
|
229
|
+
return { ok: true, seq: frame.seq };
|
|
145
230
|
}
|
|
146
231
|
|
|
147
232
|
/**
|
|
@@ -166,7 +251,32 @@ export function createDevChannels({ tenant = DEV_TENANT, appId = 'dev-local', lo
|
|
|
166
251
|
}
|
|
167
252
|
}
|
|
168
253
|
|
|
169
|
-
|
|
254
|
+
/**
|
|
255
|
+
* The buffered frames of one channel published after `since`, oldest
|
|
256
|
+
* first, with the smallest seq still buffered (null when nothing is) and
|
|
257
|
+
* the channel's current seq (0 before its first frame), so the page can
|
|
258
|
+
* tell a gap from a quiet channel. Dev counters never restart, so
|
|
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.
|
|
262
|
+
*
|
|
263
|
+
* @param {string} channel
|
|
264
|
+
* @param {number} [since] - the last seq the page has seen
|
|
265
|
+
* @returns {{ frames: Object[], oldest: number|null, current: number }}
|
|
266
|
+
*/
|
|
267
|
+
function replay(channel, since = 0) {
|
|
268
|
+
const buffer = buffers.get(channel);
|
|
269
|
+
if (!buffer) return { frames: [], oldest: null, current: 0 };
|
|
270
|
+
const frames = liveFrames(buffer);
|
|
271
|
+
const from = Number(since) || 0;
|
|
272
|
+
return {
|
|
273
|
+
frames: frames.filter(f => f.seq > from),
|
|
274
|
+
oldest: frames.length ? frames[0].seq : null,
|
|
275
|
+
current: buffer.seq
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return { emitter, broadcast, relay, replay, tenant, appId };
|
|
170
280
|
}
|
|
171
281
|
|
|
172
282
|
/**
|
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,11 +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
|
-
*
|
|
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.
|
|
11
14
|
*
|
|
12
15
|
* Override any of it per project with `informer({ mock: { platform: {…} } })`.
|
|
13
16
|
*/
|
|
@@ -35,7 +38,80 @@ export function devPlatform(overrides = {}) {
|
|
|
35
38
|
const { capabilities = {}, ...rest } = overrides || {};
|
|
36
39
|
return {
|
|
37
40
|
version: 'dev',
|
|
41
|
+
originMode: true,
|
|
38
42
|
...rest,
|
|
39
43
|
capabilities: { ...DEV_CAPABILITIES, ...capabilities }
|
|
40
44
|
};
|
|
41
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
|
+
}
|