@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.
@@ -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 refused rather than read as a wildcard.
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\/(?!\*$)\S+|[\w.-]+(\/[\w.-]+)*(\/\*)?)$/;
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 one level under `rooms/`. */
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 isChannelName(name) && !name.startsWith(USER_CHANNEL_PREFIX) && name.endsWith('/*');
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 last
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 frame = { tenant, appId, channel, event, payload: message, seq: buffer.seq, at: Date.now() };
211
+ const at = now();
212
+ const frame = { tenant, appId, channel, event, payload: message, seq: buffer.seq, at };
200
213
  if (replay !== false) {
201
- buffer.frames.push(frame);
202
- if (buffer.frames.length > REPLAY_FRAMES) buffer.frames.shift();
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: buffer.frames.filter(f => f.seq > from),
256
- oldest: buffer.frames.length ? buffer.frames[0].seq : null,
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
  }
@@ -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,13 +3,14 @@
3
3
  * the server injects on `window.__INFORMER__.platform` and on the server
4
4
  * handler / tool bag: the Informer build version and the capability flags.
5
5
  *
6
- * Every capability the dev server mirrors is on. `embeddings` is off: the
7
- * pump and query-time `embed()` need a real Informer, so an app that
8
- * feature-detects on it sees locally exactly what it sees on an install
9
- * without the feature. `version` is `'dev'` (not semver) so a floor check
10
- * treats the dev mirror as "unknown" rather than as any particular release.
11
- * `originMode` is on: the dev server behaves like an app served from its own
12
- * origin, where live channels work.
6
+ * Every capability the dev server mirrors is on. `embeddings` is off by
7
+ * default: the pump needs a real Informer, so an app that feature-detects on
8
+ * it sees locally exactly what it sees on an install without the feature.
9
+ * Turning it on binds query-time `embed()` to a deployed app's `_embed` route
10
+ * (see createDevEmbed); the pump itself is still not mirrored. `version` is
11
+ * `'dev'` (not semver) so a floor check treats the dev mirror as "unknown"
12
+ * rather than as any particular release. `originMode` is on: the dev server
13
+ * behaves like an app served from its own origin, where live channels work.
13
14
  *
14
15
  * Override any of it per project with `informer({ mock: { platform: {…} } })`.
15
16
  */
@@ -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
+ }
@@ -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
- const item = ownedUpload(id);
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
- return { uploads, downloads, resolveQueryParams, endDownload, finalize };
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
- /** Connect middleware for GET /_downloads/{id}/{filename?}?keep&inline. */
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
- if (req.method.toUpperCase() !== 'GET') return next();
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, {