@entrinsik/vite-plugin-informer 2.11.0 → 2.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -4
- package/bin/deploy.js +3 -0
- package/bin/init.js +13 -2
- package/index.d.ts +21 -5
- package/package.json +1 -1
- package/src/agent-dev.js +9 -12
- package/src/client.js +29 -3
- package/src/deploy.js +198 -45
- package/src/dev-bag.js +75 -19
- package/src/dev-channel-actors.js +352 -0
- package/src/dev-channel-handlers.js +99 -31
- package/src/dev-channel-shim.js +65 -11
- package/src/dev-channels.js +33 -15
- package/src/dev-dependencies.js +197 -19
- package/src/dev-platform.js +82 -7
- package/src/dev-streams.js +176 -27
- package/src/index.js +61 -8
- package/src/openapi-to-dts.js +4 -0
- package/src/server-routes.js +26 -15
- package/src/streams-client.js +121 -8
package/src/dev-streams.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { extname } from 'node:path';
|
|
2
|
+
import { extname, basename } from 'node:path';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Dev-mode app streams (I5-12979) — the local stand-in for the production
|
|
@@ -151,6 +151,47 @@ export function createStreamStore(limits = LIMITS) {
|
|
|
151
151
|
return { create, get, bytes, remove, reserve, seal, sweep, touch, items, limits };
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
/**
|
|
155
|
+
* The staged streams of one type, oldest first (I5-13030). Dev has a single
|
|
156
|
+
* user, so the store IS their ledger — no per-user filter to apply.
|
|
157
|
+
*/
|
|
158
|
+
export function listStreams(store, type) {
|
|
159
|
+
store.sweep();
|
|
160
|
+
return [...store.items.values()].filter(item => item.type === type).sort((a, b) => a.createdAt - b.createdAt);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Where the dev origin serves a download: the same shape the bag's download.url takes. */
|
|
164
|
+
export function downloadUrl(item, base = '') {
|
|
165
|
+
return `${base}/_downloads/${encodeURIComponent(item.id)}${item.filename ? `/${encodeURIComponent(item.filename)}` : ''}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* MIRRORED from modules/app/lib/app-stream-forwarding.js (I5-13030) and pinned
|
|
170
|
+
* by the parity test: how a filled stream is named when the guest did not
|
|
171
|
+
* name it — the upstream's Content-Disposition, else the request path.
|
|
172
|
+
*/
|
|
173
|
+
export function filenameFromDisposition(header) {
|
|
174
|
+
if (typeof header !== 'string') return null;
|
|
175
|
+
const utf8 = /filename\*\s*=\s*(?:UTF-8|utf-8)''([^;]+)/.exec(header);
|
|
176
|
+
if (utf8) {
|
|
177
|
+
try { return decodeURIComponent(utf8[1].trim()); } catch { /* fall through to the plain form */ }
|
|
178
|
+
}
|
|
179
|
+
const plain = /filename\s*=\s*"?([^";]+)"?/.exec(header);
|
|
180
|
+
return plain ? plain[1].trim() : null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function filenameFor(headers, url) {
|
|
184
|
+
const named = filenameFromDisposition(headers && headers['content-disposition']);
|
|
185
|
+
if (named) return basename(named);
|
|
186
|
+
if (typeof url === 'string') {
|
|
187
|
+
try {
|
|
188
|
+
const last = basename(new URL(url, 'http://upstream.invalid').pathname);
|
|
189
|
+
if (last && last !== '/') return last;
|
|
190
|
+
} catch { /* not a URL we can name a file after */ }
|
|
191
|
+
}
|
|
192
|
+
return 'download';
|
|
193
|
+
}
|
|
194
|
+
|
|
154
195
|
/** Guest-facing handle: never the store item itself. */
|
|
155
196
|
export function toHandle(item, extra = {}) {
|
|
156
197
|
return {
|
|
@@ -402,35 +443,38 @@ export function createStreamServices({ store, query, base = '' }) {
|
|
|
402
443
|
return { rowCount };
|
|
403
444
|
}
|
|
404
445
|
|
|
446
|
+
/** A live upload handle — what uploads.get() and a filled `into: 'upload'` both hand out. */
|
|
447
|
+
function uploadHandle(item) {
|
|
448
|
+
return {
|
|
449
|
+
...toHandle(item),
|
|
450
|
+
text: async (encoding) => {
|
|
451
|
+
if (encoding !== undefined && encoding !== null && !Buffer.isEncoding(encoding)) throw httpError(422, `Unknown text encoding "${encoding}"`);
|
|
452
|
+
return inline(item).toString(encoding || 'utf8');
|
|
453
|
+
},
|
|
454
|
+
json: async () => {
|
|
455
|
+
const text = inline(item).toString('utf8');
|
|
456
|
+
try {
|
|
457
|
+
return JSON.parse(text);
|
|
458
|
+
} catch (err) {
|
|
459
|
+
throw httpError(422, `Upload is not valid JSON: ${err.message}`);
|
|
460
|
+
}
|
|
461
|
+
},
|
|
462
|
+
base64: async () => inline(item).toString('base64'),
|
|
463
|
+
extractText: async () => { throw httpError(501, 'upload.extractText() is not available in dev — the platform text extractor runs on the Informer server; test it against a deployed app'); },
|
|
464
|
+
copyInto: async (table, opts) => await copyInto(item, table, opts || {}),
|
|
465
|
+
discard: async () => { store.remove(item.id); return true; }
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
405
469
|
const uploads = {
|
|
406
470
|
async get(id) {
|
|
407
|
-
|
|
408
|
-
return {
|
|
409
|
-
...toHandle(item),
|
|
410
|
-
text: async (encoding) => {
|
|
411
|
-
if (encoding !== undefined && encoding !== null && !Buffer.isEncoding(encoding)) throw httpError(422, `Unknown text encoding "${encoding}"`);
|
|
412
|
-
return inline(item).toString(encoding || 'utf8');
|
|
413
|
-
},
|
|
414
|
-
json: async () => {
|
|
415
|
-
const text = inline(item).toString('utf8');
|
|
416
|
-
try {
|
|
417
|
-
return JSON.parse(text);
|
|
418
|
-
} catch (err) {
|
|
419
|
-
throw httpError(422, `Upload is not valid JSON: ${err.message}`);
|
|
420
|
-
}
|
|
421
|
-
},
|
|
422
|
-
base64: async () => inline(item).toString('base64'),
|
|
423
|
-
extractText: async () => { throw httpError(501, 'upload.extractText() is not available in dev — the platform text extractor runs on the Informer server; test it against a deployed app'); },
|
|
424
|
-
copyInto: async (table, opts) => await copyInto(item, table, opts || {}),
|
|
425
|
-
discard: async () => { store.remove(item.id); return true; }
|
|
426
|
-
};
|
|
471
|
+
return uploadHandle(ownedUpload(id));
|
|
427
472
|
}
|
|
428
473
|
};
|
|
429
474
|
|
|
430
475
|
function downloadHandle(item) {
|
|
431
|
-
const url = `${base}/_downloads/${encodeURIComponent(item.id)}${item.filename ? `/${encodeURIComponent(item.filename)}` : ''}`;
|
|
432
476
|
const handle = {
|
|
433
|
-
...toHandle(item, { url }),
|
|
477
|
+
...toHandle(item, { url: downloadUrl(item, base) }),
|
|
434
478
|
async write(chunk) {
|
|
435
479
|
writableDownload(item.id);
|
|
436
480
|
const buf = toBytes(chunk);
|
|
@@ -504,7 +548,93 @@ export function createStreamServices({ store, query, base = '' }) {
|
|
|
504
548
|
}
|
|
505
549
|
}
|
|
506
550
|
|
|
507
|
-
|
|
551
|
+
/**
|
|
552
|
+
* Forwarding to an integration (I5-13030). Prod streams the bytes between
|
|
553
|
+
* the staging store and the upstream; the dev proxy reaches the real
|
|
554
|
+
* request route over HTTP with a JSON body, so here the bytes ride that
|
|
555
|
+
* route's base64 envelope instead — the same semantics, under the
|
|
556
|
+
* envelope's own cap (dev-dependencies.js names the gap when a file is
|
|
557
|
+
* over it). What the store does is identical: an outbound handle is read,
|
|
558
|
+
* an `into` target is claimed before the call and filled after it.
|
|
559
|
+
*/
|
|
560
|
+
const forwarding = {
|
|
561
|
+
/** The bytes behind an owned, sealed upload reference, with what describes them. */
|
|
562
|
+
body(ref) {
|
|
563
|
+
const item = ownedUpload(ref.id);
|
|
564
|
+
return { bytes: store.bytes(item), contentType: item.contentType || 'application/octet-stream', filename: item.filename, size: item.size };
|
|
565
|
+
},
|
|
566
|
+
/**
|
|
567
|
+
* Claim an `into` target now — a download the guest created (unsealed,
|
|
568
|
+
* unwritten) or 'upload' for a fresh one — and return fill(), which
|
|
569
|
+
* lands the upstream body and seals it. Over the byte cap, fill() fails
|
|
570
|
+
* 413 and leaves a download empty and unsealed, a fresh upload gone.
|
|
571
|
+
*/
|
|
572
|
+
receiver(target, url) {
|
|
573
|
+
if (target === 'upload') {
|
|
574
|
+
store.reserve(limits.maxUploadBytes);
|
|
575
|
+
const item = store.create('upload', { filename: null, contentType: null, size: 0, chunks: 0, reserved: limits.maxUploadBytes });
|
|
576
|
+
let settled = false;
|
|
577
|
+
return {
|
|
578
|
+
// Prod's appStream.release: the upstream delivered no body,
|
|
579
|
+
// so give the claim back rather than leaving an upload
|
|
580
|
+
// nobody holds a handle to until the TTL.
|
|
581
|
+
release() {
|
|
582
|
+
if (settled) return;
|
|
583
|
+
settled = true;
|
|
584
|
+
store.remove(item.id);
|
|
585
|
+
},
|
|
586
|
+
fill(bytes, headers = {}) {
|
|
587
|
+
settled = true;
|
|
588
|
+
if (bytes.length > limits.maxUploadBytes) {
|
|
589
|
+
store.remove(item.id);
|
|
590
|
+
throw httpError(413, `The upstream body exceeds the ${limits.maxUploadBytes} byte limit`, { maxUploadBytes: limits.maxUploadBytes });
|
|
591
|
+
}
|
|
592
|
+
item.contentType = (headers['content-type'] || 'application/octet-stream').split(';')[0].trim();
|
|
593
|
+
item.filename = filenameFor(headers, url);
|
|
594
|
+
// An `into` fill is the append producer, which never sets
|
|
595
|
+
// a chunk size — prod reports null, and so must the
|
|
596
|
+
// listing below, not just the handle returned here.
|
|
597
|
+
item.chunkSize = null;
|
|
598
|
+
item.chunks = 1;
|
|
599
|
+
item.chunkData.set(1, Buffer.from(bytes));
|
|
600
|
+
item.size = bytes.length;
|
|
601
|
+
item.complete = true;
|
|
602
|
+
store.seal(item);
|
|
603
|
+
// The same extra KEYS prod's handle carries for a filled
|
|
604
|
+
// upload. Dev is one in-memory buffer, so chunks is always
|
|
605
|
+
// 1 where prod reports the real append-producer count.
|
|
606
|
+
return { ...uploadHandle(item), complete: true, chunks: 1, chunkSize: null };
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
if (isStreamRef(target, 'download')) {
|
|
611
|
+
const item = writableDownload(target.id);
|
|
612
|
+
if (item.parts.length) throw httpError(409, 'Download already has bytes; `into` fills a download from empty and seals it');
|
|
613
|
+
return {
|
|
614
|
+
// The guest still owns this one, so there is nothing to
|
|
615
|
+
// reclaim — it stays empty and unsealed, as prod leaves it.
|
|
616
|
+
release() {},
|
|
617
|
+
fill(bytes, headers = {}) {
|
|
618
|
+
if (bytes.length > limits.maxUploadBytes) {
|
|
619
|
+
item.parts = [];
|
|
620
|
+
item.size = 0;
|
|
621
|
+
throw httpError(413, `The upstream body exceeds the ${limits.maxUploadBytes} byte limit`, { maxUploadBytes: limits.maxUploadBytes });
|
|
622
|
+
}
|
|
623
|
+
if (!item.contentType) item.contentType = (headers['content-type'] || 'application/octet-stream').split(';')[0].trim();
|
|
624
|
+
if (!item.filename) item.filename = filenameFor(headers, url);
|
|
625
|
+
item.parts = [Buffer.from(bytes)];
|
|
626
|
+
item.size = bytes.length;
|
|
627
|
+
pending.delete(item.id);
|
|
628
|
+
store.seal(item);
|
|
629
|
+
return { ...downloadHandle(item), complete: true };
|
|
630
|
+
}
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
throw httpError(400, `request(): \`into\` must be a download handle or 'upload'`);
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
return { uploads, downloads, resolveQueryParams, endDownload, finalize, forwarding };
|
|
508
638
|
}
|
|
509
639
|
|
|
510
640
|
// What a guest hands write(): a string or bytes. Anything else is a 422 in
|
|
@@ -591,7 +721,7 @@ async function readBody(req) {
|
|
|
591
721
|
|
|
592
722
|
function uploadSummary(item) {
|
|
593
723
|
const { received, missing } = missingChunks(item);
|
|
594
|
-
return toHandle(item, { chunkSize: item.chunkSize, chunks: item.chunks, complete: item.complete, received, missing });
|
|
724
|
+
return toHandle(item, { chunkSize: Number(item.chunkSize) || null, chunks: item.chunks, complete: item.complete, received, missing });
|
|
595
725
|
}
|
|
596
726
|
|
|
597
727
|
/**
|
|
@@ -612,6 +742,11 @@ export function createUploadsMiddleware(store) {
|
|
|
612
742
|
res.setHeader('Location', `/_uploads/${item.id}`);
|
|
613
743
|
return sendJson(res, 201, uploadSummary(item));
|
|
614
744
|
}
|
|
745
|
+
// GET /_uploads — what is staged (I5-13030). Dev has one user, so
|
|
746
|
+
// the store's uploads are all theirs, oldest first like prod.
|
|
747
|
+
if (parts.length === 0 && method === 'GET') {
|
|
748
|
+
return sendJson(res, 200, listStreams(store, 'upload').map(item => toHandle(item, { chunkSize: Number(item.chunkSize) || null, chunks: item.chunks, complete: item.complete })));
|
|
749
|
+
}
|
|
615
750
|
if (parts.length === 0) return next();
|
|
616
751
|
|
|
617
752
|
const item = store.get('upload', parts[0]);
|
|
@@ -639,14 +774,28 @@ export function createUploadsMiddleware(store) {
|
|
|
639
774
|
};
|
|
640
775
|
}
|
|
641
776
|
|
|
642
|
-
/**
|
|
777
|
+
/**
|
|
778
|
+
* Connect middleware for GET /_downloads/{id}/{filename?}?keep&inline, plus
|
|
779
|
+
* (I5-13030) GET /_downloads to list what is staged and DELETE /_downloads/{id}
|
|
780
|
+
* to drop one unserved.
|
|
781
|
+
*/
|
|
643
782
|
export function createDownloadsMiddleware(store) {
|
|
644
783
|
return function downloadsMiddleware(req, res, next) {
|
|
645
784
|
try {
|
|
646
|
-
|
|
785
|
+
const method = req.method.toUpperCase();
|
|
647
786
|
const url = new URL(req.url, 'http://dev.local');
|
|
648
787
|
const [id] = url.pathname.split('/').filter(Boolean);
|
|
788
|
+
if (!id && method === 'GET') {
|
|
789
|
+
return sendJson(res, 200, listStreams(store, 'download').map(item => toHandle(item, { complete: item.complete, url: downloadUrl(item) })));
|
|
790
|
+
}
|
|
649
791
|
if (!id) return next();
|
|
792
|
+
if (method === 'DELETE') {
|
|
793
|
+
if (!store.get('download', id)) throw httpError(404, 'Not Found');
|
|
794
|
+
store.remove(id);
|
|
795
|
+
res.statusCode = 204;
|
|
796
|
+
return res.end();
|
|
797
|
+
}
|
|
798
|
+
if (method !== 'GET') return next();
|
|
650
799
|
const item = store.get('download', id);
|
|
651
800
|
if (!item || !item.complete) throw httpError(404, 'Not Found');
|
|
652
801
|
return serveDownload(store, item, res, {
|
package/src/index.js
CHANGED
|
@@ -39,6 +39,36 @@ function buildMock(options) {
|
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* The app id dev surfaces address on the server (the opt-in dev `embed()`
|
|
44
|
+
* posts to its `_embed` route). `informer-init` generates it locally into
|
|
45
|
+
* package.json `informer.id` and the first deploy creates the app under it.
|
|
46
|
+
* Absent, the surface says so at call time rather than the dev server
|
|
47
|
+
* refusing to start.
|
|
48
|
+
*/
|
|
49
|
+
function readAppId(projectRoot) {
|
|
50
|
+
const pkgPath = resolve(projectRoot, 'package.json');
|
|
51
|
+
let raw;
|
|
52
|
+
try {
|
|
53
|
+
raw = readFileSync(pkgPath, 'utf8');
|
|
54
|
+
} catch (err) {
|
|
55
|
+
// Unreadable is not the same as "no id set", and the two want
|
|
56
|
+
// different fixes. Saying which one it is beats telling someone to
|
|
57
|
+
// set a field in a file they are already looking at — or that the
|
|
58
|
+
// dev server never found.
|
|
59
|
+
if (err.code !== 'ENOENT') console.warn(`[informer] Could not read ${pkgPath}: ${err.message}`);
|
|
60
|
+
else console.warn(`[informer] No package.json at ${pkgPath}; dev surfaces that address the deployed app are unavailable.`);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const pkg = JSON.parse(raw);
|
|
65
|
+
return (pkg.informer && pkg.informer.id) || null;
|
|
66
|
+
} catch (err) {
|
|
67
|
+
console.warn(`[informer] Could not parse ${pkgPath}: ${err.message}`);
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
42
72
|
/**
|
|
43
73
|
* Occupied-spot check for the dev /api mount: does informer.yaml's
|
|
44
74
|
* access.apis declare this method+path? Mirrors the server's matchWhitelist
|
|
@@ -103,8 +133,10 @@ async function writeAppDepTypes (projectRoot, dts) {
|
|
|
103
133
|
* - Sets base to './' so built assets use relative paths
|
|
104
134
|
*
|
|
105
135
|
* @param {Object} [options]
|
|
106
|
-
* @param {{ report?: object, theme?: 'light'|'dark', roles?: string[] }} [options.mock]
|
|
107
|
-
* window.__INFORMER__ mock injected in dev.
|
|
136
|
+
* @param {{ report?: object, theme?: 'light'|'dark', roles?: string[], user?: object, platform?: object }} [options.mock]
|
|
137
|
+
* window.__INFORMER__ mock injected in dev. `platform` merges into the dev
|
|
138
|
+
* descriptor, so `{ capabilities: { embeddings: true } }` opts one capability
|
|
139
|
+
* in and leaves the rest. See InformerPluginOptions in index.d.ts.
|
|
108
140
|
* @param {Object} [options.devBindings] - dev bindings for `target: app` and
|
|
109
141
|
* `target: pack` deps. App slots can't be defaultBound in the manifest; pack
|
|
110
142
|
* slots resolve their marketplace pin via installs dev doesn't have, so the
|
|
@@ -181,6 +213,15 @@ export default function informer(options = {}) {
|
|
|
181
213
|
const projectRoot = process.cwd();
|
|
182
214
|
const migrationsDir = resolve(projectRoot, 'migrations');
|
|
183
215
|
|
|
216
|
+
// One merged descriptor for the whole dev server. Built once
|
|
217
|
+
// rather than per consumer so "the channel hub, the handler bags
|
|
218
|
+
// and the browser all see the same mock" is structural — which is
|
|
219
|
+
// the property the embeddings opt-in depends on, since the flag
|
|
220
|
+
// the app reads and the flag that binds embed() must be the same
|
|
221
|
+
// flag. (transformIndexHtml is a separate hook and builds its own.)
|
|
222
|
+
const mock = buildMock(options);
|
|
223
|
+
const appId = readAppId(projectRoot);
|
|
224
|
+
|
|
184
225
|
// One API client for the whole dev-server setup — createClient just
|
|
185
226
|
// builds an auth header (no I/O), so it's hoisted out of the two
|
|
186
227
|
// branches that each used to rebuild an identical one.
|
|
@@ -279,7 +320,7 @@ export default function informer(options = {}) {
|
|
|
279
320
|
// Every page receives every frame; the subscribe loop mounted below
|
|
280
321
|
// (join / joined / leave / send against channels/ files) is what
|
|
281
322
|
// tells a page which channels it may dispatch.
|
|
282
|
-
const channels = createDevChannels({ appId:
|
|
323
|
+
const channels = createDevChannels({ appId: mock.report.id });
|
|
283
324
|
channels.emitter.on(BROADCAST_EVENT, (frame) => {
|
|
284
325
|
server.ws.send({ type: 'custom', event: DEV_CHANNEL_EVENT, data: frame });
|
|
285
326
|
});
|
|
@@ -291,8 +332,10 @@ export default function informer(options = {}) {
|
|
|
291
332
|
devBindings: options.devBindings || {},
|
|
292
333
|
appToken,
|
|
293
334
|
channels,
|
|
294
|
-
user:
|
|
295
|
-
roles: (options.mock && options.mock.roles) || []
|
|
335
|
+
user: mock.user,
|
|
336
|
+
roles: (options.mock && options.mock.roles) || [],
|
|
337
|
+
platform: mock.platform,
|
|
338
|
+
appId
|
|
296
339
|
}));
|
|
297
340
|
|
|
298
341
|
// Generate .d.ts types for bound `target: app` / `target: pack`
|
|
@@ -368,6 +411,11 @@ export default function informer(options = {}) {
|
|
|
368
411
|
server.middlewares.use('/_uploads', createUploadsMiddleware(streamStore));
|
|
369
412
|
server.middlewares.use('/_downloads', createDownloadsMiddleware(streamStore));
|
|
370
413
|
|
|
414
|
+
// The merged platform descriptor (dev defaults + mock.platform) is
|
|
415
|
+
// what the handler bags see too, not only the browser: an app that
|
|
416
|
+
// opts into `embeddings` there must find embed() bound, not throwing.
|
|
417
|
+
const platform = mock.platform;
|
|
418
|
+
|
|
371
419
|
// Mount server-side route handlers if a server/ directory exists
|
|
372
420
|
const serverDir = resolve(projectRoot, 'server');
|
|
373
421
|
|
|
@@ -378,7 +426,7 @@ export default function informer(options = {}) {
|
|
|
378
426
|
devWorkspaceId,
|
|
379
427
|
projectRoot,
|
|
380
428
|
roles: (options.mock && options.mock.roles) || [],
|
|
381
|
-
user:
|
|
429
|
+
user: mock.user,
|
|
382
430
|
// Dev-only bindings for `target: app` / `target: pack`
|
|
383
431
|
// slots (app: overrides the manifest defaultBinding; pack:
|
|
384
432
|
// names the locally-installed pack app). Shape:
|
|
@@ -386,7 +434,9 @@ export default function informer(options = {}) {
|
|
|
386
434
|
devBindings: options.devBindings || {},
|
|
387
435
|
appToken,
|
|
388
436
|
channels,
|
|
389
|
-
streamStore
|
|
437
|
+
streamStore,
|
|
438
|
+
platform,
|
|
439
|
+
appId
|
|
390
440
|
});
|
|
391
441
|
server.middlewares.use('/api/_server', serverRoutes);
|
|
392
442
|
// Occupied-spot precedence (App API v2, matches production
|
|
@@ -418,7 +468,9 @@ export default function informer(options = {}) {
|
|
|
418
468
|
projectRoot,
|
|
419
469
|
devBindings: options.devBindings || {},
|
|
420
470
|
appToken,
|
|
421
|
-
channels
|
|
471
|
+
channels,
|
|
472
|
+
platform,
|
|
473
|
+
appId
|
|
422
474
|
});
|
|
423
475
|
server.middlewares.use('/api/_agent', agentDev);
|
|
424
476
|
}
|
|
@@ -446,6 +498,7 @@ export default function informer(options = {}) {
|
|
|
446
498
|
var __streams = ${streamsClientSource()};
|
|
447
499
|
window.__INFORMER__.upload = __streams.upload;
|
|
448
500
|
window.__INFORMER__.downloadUrl = __streams.downloadUrl;
|
|
501
|
+
window.__INFORMER__.streams = __streams.streams;
|
|
449
502
|
})();
|
|
450
503
|
</script>
|
|
451
504
|
${renderDevChannelScript({ hub: Boolean(serverOrigin) })}`;
|
package/src/openapi-to-dts.js
CHANGED
|
@@ -109,6 +109,10 @@ function slotInterface (iface, spec) {
|
|
|
109
109
|
return [
|
|
110
110
|
`export interface ${iface} {`,
|
|
111
111
|
overloads.join('\n'),
|
|
112
|
+
` /** Link into the target app's UI: its view root, or a path, query or`,
|
|
113
|
+
` * fragment under it. Absolute wherever the deployment names an`,
|
|
114
|
+
` * external address — always, when apps serve from their own origins. */`,
|
|
115
|
+
' url(path?: string): Promise<string>;',
|
|
112
116
|
'}'
|
|
113
117
|
].join('\n');
|
|
114
118
|
}
|
package/src/server-routes.js
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { parse as parseUrl } from 'node:url';
|
|
4
4
|
import { createDevBagBuilder, buildDevUser } from './dev-bag.js';
|
|
5
5
|
import { createDevChannels } from './dev-channels.js';
|
|
6
|
+
import { devPlatform } from './dev-platform.js';
|
|
6
7
|
import { createStreamStore, createStreamServices, isStreamRef, serveDownload as serveDevDownload } from './dev-streams.js';
|
|
7
8
|
|
|
8
9
|
const VALID_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
@@ -163,8 +164,8 @@ async function walkJsFiles(dir, basePath) {
|
|
|
163
164
|
* descriptor (mirrors app-sandbox.js buildInvokeScript / respondCallback).
|
|
164
165
|
* The encoding allow-list and contract checks are enforced inside the ivm in
|
|
165
166
|
* production; replicated here so handlers see the same errors in dev (where
|
|
166
|
-
* there's no isolate to attribute the throw to).
|
|
167
|
-
*
|
|
167
|
+
* there's no isolate to attribute the throw to). The header rule is shared
|
|
168
|
+
* with prod's modules/app/lib/handler-headers.js — change both.
|
|
168
169
|
*/
|
|
169
170
|
function sendDescriptor(res, result) {
|
|
170
171
|
const encoding = typeof result.encoding === 'string' ? result.encoding : null;
|
|
@@ -176,28 +177,38 @@ function sendDescriptor(res, result) {
|
|
|
176
177
|
}
|
|
177
178
|
const responseBody = result.body !== undefined ? result.body : null;
|
|
178
179
|
|
|
180
|
+
// Validate before writing anything: prod rejects a malformed body while
|
|
181
|
+
// building the response, so nothing of the handler's reaches the client. Left
|
|
182
|
+
// below the header loop, a 500 here still carried the handler's
|
|
183
|
+
// Content-Disposition and the browser saved a .pdf full of JSON.
|
|
184
|
+
if (encoding === 'base64' && !isBase64(responseBody)) {
|
|
185
|
+
throw new Error('Handler returned malformed base64 body');
|
|
186
|
+
}
|
|
187
|
+
|
|
179
188
|
res.statusCode = result.status || 200;
|
|
189
|
+
// Lowercased for wire parity with Hapi. Node's header lookup is
|
|
190
|
+
// case-insensitive either way, which is why dev never reproduced I5-12972.
|
|
180
191
|
for (const [key, value] of Object.entries(result.headers || {})) {
|
|
181
|
-
res.setHeader(key, value);
|
|
192
|
+
res.setHeader(key.toLowerCase(), value);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Same rule as prod's applyHandlerHeaders, and applied before the body is
|
|
196
|
+
// branched on so it covers base64 too. Dev used to leave a binary response
|
|
197
|
+
// unlabelled, which the browser sniffed happily while prod shipped it as
|
|
198
|
+
// application/json — the I5-12972 failure, arrived at from the other side.
|
|
199
|
+
const declaredType = res.getHeader('content-type');
|
|
200
|
+
if (typeof declaredType !== 'string' || !declaredType.trim()) {
|
|
201
|
+
res.setHeader('content-type', 'application/json');
|
|
182
202
|
}
|
|
183
203
|
|
|
184
204
|
if (responseBody === null) {
|
|
185
205
|
res.end();
|
|
186
206
|
} else if (encoding === 'base64') {
|
|
187
|
-
if (!isBase64(responseBody)) {
|
|
188
|
-
throw new Error('Handler returned malformed base64 body');
|
|
189
|
-
}
|
|
190
207
|
res.end(Buffer.from(responseBody, 'base64'));
|
|
191
208
|
} else if (typeof responseBody === 'string') {
|
|
192
209
|
// Strings go out verbatim, as prod passes them.
|
|
193
|
-
if (!res.getHeader('content-type')) {
|
|
194
|
-
res.setHeader('Content-Type', 'application/json');
|
|
195
|
-
}
|
|
196
210
|
res.end(responseBody);
|
|
197
211
|
} else {
|
|
198
|
-
if (!res.getHeader('content-type')) {
|
|
199
|
-
res.setHeader('Content-Type', 'application/json');
|
|
200
|
-
}
|
|
201
212
|
res.end(JSON.stringify(responseBody));
|
|
202
213
|
}
|
|
203
214
|
}
|
|
@@ -224,13 +235,13 @@ function isDescriptor(value) {
|
|
|
224
235
|
* and /_downloads middleware (createStreamStore()); a private one when omitted
|
|
225
236
|
* @returns {Function} Connect middleware
|
|
226
237
|
*/
|
|
227
|
-
export function createMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, roles, user, devBindings, appToken, channels = createDevChannels(), streamStore }) {
|
|
238
|
+
export function createMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, roles, user, devBindings, appToken, channels = createDevChannels(), streamStore, platform = devPlatform(), appId = null }) {
|
|
228
239
|
// `@user/${request.user.username}` on the server names the channel the
|
|
229
240
|
// page subscribes to.
|
|
230
241
|
const devUser = buildDevUser(user);
|
|
231
242
|
const serverDir = join(projectRoot, 'server');
|
|
232
243
|
const streams = streamStore || createStreamStore();
|
|
233
|
-
const bagBuilder = createDevBagBuilder({ serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings, appToken, channels, logPrefix: '[app]' });
|
|
244
|
+
const bagBuilder = createDevBagBuilder({ serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings, appToken, channels, logPrefix: '[app]', platform, appId });
|
|
234
245
|
const { query } = bagBuilder;
|
|
235
246
|
|
|
236
247
|
return async function serverRoutesMiddleware(req, res, next) {
|
|
@@ -333,7 +344,7 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
|
|
|
333
344
|
res.end(JSON.stringify(earlyBody));
|
|
334
345
|
}
|
|
335
346
|
|
|
336
|
-
const { bag } = await bagBuilder.build();
|
|
347
|
+
const { bag } = await bagBuilder.build({ forwarding: streamServices.forwarding });
|
|
337
348
|
|
|
338
349
|
// Call handler — bag mirrors the prod sandbox (see app-sandbox.js).
|
|
339
350
|
const result = await handler({ ...bag, request, query: streamQuery, respond, uploads: streamServices.uploads, downloads: streamServices.downloads });
|