@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.
@@ -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, and the `channels:` manifest relay that mirrors
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
- // Everything after `@user/` is one username, verbatim the server compares the
18
- // whole remainder to the socket's own.
19
- export const CHANNEL_NAME = /^(@user\/\S+|[\w.-]+(\/[\w.-]+)*)$/;
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?, on? }'];
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) continue;
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 optional "description" and "on"`);
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
- if (def.on !== undefined) {
93
- const events = Array.isArray(def.on) ? def.on : [def.on];
94
- for (const event of events) {
95
- if (!isEventName(event)) {
96
- errors.push(`channels.${name}.on: invalid event name ${JSON.stringify(event)} (letters, digits, _ . -, max ${EVENT_NAME_MAX_LENGTH} chars)`);
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
- * @returns {{ emitter: EventEmitter, broadcast: Function, relay: Function, tenant: string, appId: string }}
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
- function publish(channel, event, payload) {
119
- if (!isChannelName(channel)) throw channelError('app_channel_invalid_name', 400);
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 frame = { tenant, appId, channel, event, payload: message, at: Date.now() };
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
- async function broadcast(channel, event, payload) {
142
- const frame = publish(channel, event, payload);
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
- return { emitter, broadcast, relay, tenant, appId };
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
  /**
@@ -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
- export function buildDevContext({ deps, apiFetch, devBindings = {}, appFetch = null }) {
391
+ // The integration request route carries a base64 body of at most 50 MB
392
+ // (integration/routes/request.js REQUEST_MAX_BYTES), so a staged stream the dev
393
+ // proxy forwards through it can be at most this many raw bytes — a little less
394
+ // in practice, since the envelope also carries url, headers and params. Pinned
395
+ // to the server constant by test/streams-parity.test.js.
396
+ export const DEV_FORWARD_MAX_BYTES = Math.floor(52428800 * 3 / 4);
397
+
398
+ export function buildDevContext({ deps, apiFetch, devBindings = {}, appFetch = null, forwarding = null }) {
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
- return await devCall(apiFetch, 'POST', `integrations/${targetId}/request`, payload || {}, name, 'integration');
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;
@@ -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: the
7
- * pump and query-time `embed()` need a real Informer, so an app that
8
- * feature-detects on it sees locally exactly what it sees on an install
9
- * without the feature. `version` is `'dev'` (not semver) so a floor check
10
- * treats the dev mirror as "unknown" rather than as any particular release.
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
+ }