@entrinsik/vite-plugin-informer 2.7.0 → 2.11.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 +131 -0
- package/bin/ci.js +0 -0
- package/bin/workspace.js +15 -7
- package/package.json +1 -1
- package/src/agent-dev.js +26 -12
- package/src/assemble.js +5 -1
- package/src/compat.js +252 -0
- package/src/deploy.js +116 -43
- package/src/dev-bag.js +185 -0
- package/src/dev-channel-handlers.js +325 -0
- package/src/dev-channel-shim.js +361 -0
- package/src/dev-channels.js +283 -0
- package/src/dev-dependencies.js +44 -17
- package/src/dev-platform.js +44 -0
- package/src/dev-streams.js +660 -0
- package/src/env.js +17 -1
- package/src/index.js +156 -16
- package/src/server-routes.js +121 -196
- package/src/streams-client.js +200 -0
package/src/index.js
CHANGED
|
@@ -1,13 +1,71 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { parse as parseYaml } from 'yaml';
|
|
2
3
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
3
4
|
import { resolve } from 'node:path';
|
|
4
5
|
import { createClient } from './client.js';
|
|
5
|
-
import { loadDependencies, validateDependencies, validateDevBindings, resolveAppBinding } from './dev-dependencies.js';
|
|
6
|
+
import { loadDependencies, loadChannels, validateDependencies, validateDevBindings, resolveAppBinding } from './dev-dependencies.js';
|
|
6
7
|
import { buildDeclarations } from './openapi-to-dts.js';
|
|
7
8
|
import { loadEnv, envWritePath } from './env.js';
|
|
8
9
|
import { createMiddleware as createServerRoutes } from './server-routes.js';
|
|
9
10
|
import { createAgentMiddleware } from './agent-dev.js';
|
|
11
|
+
import { createDevChannels, validateChannels, BROADCAST_EVENT, DEV_CHANNEL_EVENT, DEV_CHANNEL_API } from './dev-channels.js';
|
|
12
|
+
import { createDevChannelHandlers, validateChannelHandlers } from './dev-channel-handlers.js';
|
|
13
|
+
import { renderDevChannelScript } from './dev-channel-shim.js';
|
|
14
|
+
import { createStreamStore, createUploadsMiddleware, createDownloadsMiddleware } from './dev-streams.js';
|
|
15
|
+
import { streamsClientSource } from './streams-client.js';
|
|
10
16
|
import { init, migrate } from './workspace.js';
|
|
17
|
+
import { devPlatform } from './dev-platform.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The dev `window.__INFORMER__` context: defaults overlaid with the plugin's
|
|
21
|
+
* `mock` option. Shared by the page injection and the dev channels hub (whose
|
|
22
|
+
* frames carry `report.id` as the app id).
|
|
23
|
+
*/
|
|
24
|
+
function buildMock(options) {
|
|
25
|
+
return {
|
|
26
|
+
report: {
|
|
27
|
+
id: 'dev-local',
|
|
28
|
+
name: 'Local Development'
|
|
29
|
+
},
|
|
30
|
+
theme: 'light',
|
|
31
|
+
roles: [],
|
|
32
|
+
// The viewer identity the server injects (window.__INFORMER__.user);
|
|
33
|
+
// override with mock.user to test @user/<username> channels as someone else.
|
|
34
|
+
user: { username: 'dev', displayName: 'Local Developer' },
|
|
35
|
+
...options.mock,
|
|
36
|
+
// What the platform offers, as the server injects it; a mock
|
|
37
|
+
// override merges into the dev defaults (see dev-platform.js).
|
|
38
|
+
platform: devPlatform(options.mock && options.mock.platform)
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Occupied-spot check for the dev /api mount: does informer.yaml's
|
|
44
|
+
* access.apis declare this method+path? Mirrors the server's matchWhitelist
|
|
45
|
+
* semantics — segment-aware, `*` matches exactly one segment.
|
|
46
|
+
*/
|
|
47
|
+
function manifestOccupies (projectRoot, req) {
|
|
48
|
+
let entries;
|
|
49
|
+
try {
|
|
50
|
+
const raw = parseYaml(readFileSync(resolve(projectRoot, 'informer.yaml'), 'utf8')) || {};
|
|
51
|
+
entries = (raw.access && Array.isArray(raw.access.apis)) ? raw.access.apis : [];
|
|
52
|
+
} catch {
|
|
53
|
+
return false; // no/invalid manifest: nothing is occupied
|
|
54
|
+
}
|
|
55
|
+
// Connect strips the mount prefix, so re-add it for whole-path matching.
|
|
56
|
+
const path = `/api${(req.url || '').split('?')[0]}`;
|
|
57
|
+
const segments = path.split('/').filter(Boolean);
|
|
58
|
+
const method = (req.method || 'GET').toUpperCase();
|
|
59
|
+
return entries.some(entry => {
|
|
60
|
+
const spec = typeof entry === 'string'
|
|
61
|
+
? entry
|
|
62
|
+
: `${(entry && entry.method) || 'GET'} ${(entry && (entry.url || entry.path)) || ''}`;
|
|
63
|
+
const parsed = /^([A-Za-z]+)\s+(\S+)$/.exec(String(spec).trim());
|
|
64
|
+
if (!parsed || parsed[1].toUpperCase() !== method) return false;
|
|
65
|
+
const want = parsed[2].split('/').filter(Boolean);
|
|
66
|
+
return want.length === segments.length && want.every((s, i) => s === '*' || s === segments[i]);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
11
69
|
|
|
12
70
|
/**
|
|
13
71
|
* Write generated app-dependency type declarations under .informer/ and keep the
|
|
@@ -39,7 +97,9 @@ async function writeAppDepTypes (projectRoot, dts) {
|
|
|
39
97
|
*
|
|
40
98
|
* - Proxies /api requests to the Informer server with Basic auth
|
|
41
99
|
* - Runs server/ route handlers locally via ssrLoadModule (if server/ dir exists)
|
|
42
|
-
* -
|
|
100
|
+
* - Runs channels/ handlers locally (join / joined / leave / send) behind
|
|
101
|
+
* the `channel()` mock injected with the window.__INFORMER__ context in
|
|
102
|
+
* dev mode, whose frames arrive over Vite's dev websocket
|
|
43
103
|
* - Sets base to './' so built assets use relative paths
|
|
44
104
|
*
|
|
45
105
|
* @param {Object} [options]
|
|
@@ -101,7 +161,12 @@ export default function informer(options = {}) {
|
|
|
101
161
|
Authorization: authHeader
|
|
102
162
|
},
|
|
103
163
|
...options.proxy
|
|
104
|
-
}
|
|
164
|
+
},
|
|
165
|
+
// NOTE: no /informer-api mount. The server
|
|
166
|
+
// retired that spelling — /api is ONE namespace
|
|
167
|
+
// with manifest-driven precedence (a whitelisted
|
|
168
|
+
// access.apis path OCCUPIES its spot; everything
|
|
169
|
+
// else is the app's own routes), mirrored below.
|
|
105
170
|
}
|
|
106
171
|
};
|
|
107
172
|
}
|
|
@@ -185,6 +250,50 @@ export default function informer(options = {}) {
|
|
|
185
250
|
for (const message of validateDevBindings(deps, options.devBindings || {})) {
|
|
186
251
|
console.error(`[informer] vite.config.js: ${message}`);
|
|
187
252
|
}
|
|
253
|
+
// Same for the `channels:` relay block — the deploy 400s on a bad
|
|
254
|
+
// channel or event name; say so now instead of dropping relays.
|
|
255
|
+
try {
|
|
256
|
+
for (const message of validateChannels(await loadChannels(projectRoot))) {
|
|
257
|
+
console.error(`[informer] informer.yaml: ${message}`);
|
|
258
|
+
}
|
|
259
|
+
} catch (err) {
|
|
260
|
+
console.warn(`[informer] Could not read informer.yaml channels: ${err.message}`);
|
|
261
|
+
}
|
|
262
|
+
// And for channels/ files — the deploy 400s on a stray export or a
|
|
263
|
+
// path no page can subscribe to.
|
|
264
|
+
try {
|
|
265
|
+
for (const message of await validateChannelHandlers(projectRoot)) {
|
|
266
|
+
console.error(`[informer] ${message}`);
|
|
267
|
+
}
|
|
268
|
+
} catch (err) {
|
|
269
|
+
console.warn(`[informer] Could not read channels/: ${err.message}`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// App Channels in dev. Handlers' broadcast() (and the `channels:`
|
|
273
|
+
// relay behind emit()) publish frames on this hub; each frame goes
|
|
274
|
+
// to the page as a custom event on Vite's own dev websocket, which
|
|
275
|
+
// the injected `__INFORMER__.channel` mock listens to via
|
|
276
|
+
// import.meta.hot. Riding the HMR socket means no second websocket,
|
|
277
|
+
// no `ws: true` on the /api proxy and no socket credential to mint
|
|
278
|
+
// — the dev server already owns a live connection to every page.
|
|
279
|
+
// Every page receives every frame; the subscribe loop mounted below
|
|
280
|
+
// (join / joined / leave / send against channels/ files) is what
|
|
281
|
+
// tells a page which channels it may dispatch.
|
|
282
|
+
const channels = createDevChannels({ appId: buildMock(options).report.id });
|
|
283
|
+
channels.emitter.on(BROADCAST_EVENT, (frame) => {
|
|
284
|
+
server.ws.send({ type: 'custom', event: DEV_CHANNEL_EVENT, data: frame });
|
|
285
|
+
});
|
|
286
|
+
server.middlewares.use(DEV_CHANNEL_API, createDevChannelHandlers(server, {
|
|
287
|
+
serverOrigin,
|
|
288
|
+
authHeader,
|
|
289
|
+
devWorkspaceId,
|
|
290
|
+
projectRoot,
|
|
291
|
+
devBindings: options.devBindings || {},
|
|
292
|
+
appToken,
|
|
293
|
+
channels,
|
|
294
|
+
user: buildMock(options).user,
|
|
295
|
+
roles: (options.mock && options.mock.roles) || []
|
|
296
|
+
}));
|
|
188
297
|
|
|
189
298
|
// Generate .d.ts types for bound `target: app` / `target: pack`
|
|
190
299
|
// deps from their published OpenAPI docs, so server/ handlers get
|
|
@@ -249,6 +358,16 @@ export default function informer(options = {}) {
|
|
|
249
358
|
console.warn(`[informer] app-dependency type generation skipped: ${err.message}`);
|
|
250
359
|
}
|
|
251
360
|
|
|
361
|
+
// App streams (I5-12979): the dev origin's /_uploads and /_downloads
|
|
362
|
+
// — the same protocol the deployed app speaks on its own origin —
|
|
363
|
+
// backed by one in-memory store the server routes' `uploads` /
|
|
364
|
+
// `downloads` services share. Mounted regardless of server/: the
|
|
365
|
+
// client helper is always on __INFORMER__, so a page may stage an
|
|
366
|
+
// upload before any handler exists to consume it.
|
|
367
|
+
const streamStore = createStreamStore();
|
|
368
|
+
server.middlewares.use('/_uploads', createUploadsMiddleware(streamStore));
|
|
369
|
+
server.middlewares.use('/_downloads', createDownloadsMiddleware(streamStore));
|
|
370
|
+
|
|
252
371
|
// Mount server-side route handlers if a server/ directory exists
|
|
253
372
|
const serverDir = resolve(projectRoot, 'server');
|
|
254
373
|
|
|
@@ -259,14 +378,31 @@ export default function informer(options = {}) {
|
|
|
259
378
|
devWorkspaceId,
|
|
260
379
|
projectRoot,
|
|
261
380
|
roles: (options.mock && options.mock.roles) || [],
|
|
381
|
+
user: buildMock(options).user,
|
|
262
382
|
// Dev-only bindings for `target: app` / `target: pack`
|
|
263
383
|
// slots (app: overrides the manifest defaultBinding; pack:
|
|
264
384
|
// names the locally-installed pack app). Shape:
|
|
265
385
|
// devBindings: { kanban: { app: 'admin:kanban' } }
|
|
266
386
|
devBindings: options.devBindings || {},
|
|
267
|
-
appToken
|
|
387
|
+
appToken,
|
|
388
|
+
channels,
|
|
389
|
+
streamStore
|
|
268
390
|
});
|
|
269
391
|
server.middlewares.use('/api/_server', serverRoutes);
|
|
392
|
+
// Occupied-spot precedence (App API v2, matches production
|
|
393
|
+
// dispatch in view-api.js): a platform API declared in
|
|
394
|
+
// informer.yaml's access.apis OCCUPIES its /api path — those
|
|
395
|
+
// requests skip the local handlers (next() falls through to
|
|
396
|
+
// the /api proxy above); every unclaimed path dispatches the
|
|
397
|
+
// app's own routes. The manifest is re-read per request so
|
|
398
|
+
// edits apply without a restart. Dev limitation: only raw
|
|
399
|
+
// access.apis entries are consulted (dependency-derived
|
|
400
|
+
// grants proxy through their typed context slots anyway).
|
|
401
|
+
// /api/_server stays as the legacy exclusive spelling.
|
|
402
|
+
server.middlewares.use('/api', (req, res, next) => {
|
|
403
|
+
if (manifestOccupies(projectRoot, req)) return next();
|
|
404
|
+
return serverRoutes(req, res, next);
|
|
405
|
+
});
|
|
270
406
|
}
|
|
271
407
|
|
|
272
408
|
// Mount agent dev middleware if tools/, mcp/, or informer.yaml agents exist
|
|
@@ -281,7 +417,8 @@ export default function informer(options = {}) {
|
|
|
281
417
|
devWorkspaceId,
|
|
282
418
|
projectRoot,
|
|
283
419
|
devBindings: options.devBindings || {},
|
|
284
|
-
appToken
|
|
420
|
+
appToken,
|
|
421
|
+
channels
|
|
285
422
|
});
|
|
286
423
|
server.middlewares.use('/api/_agent', agentDev);
|
|
287
424
|
}
|
|
@@ -293,22 +430,25 @@ export default function informer(options = {}) {
|
|
|
293
430
|
handler(html) {
|
|
294
431
|
if (!isDev) return html;
|
|
295
432
|
|
|
296
|
-
const mock =
|
|
297
|
-
report: {
|
|
298
|
-
id: 'dev-local',
|
|
299
|
-
name: 'Local Development'
|
|
300
|
-
},
|
|
301
|
-
theme: 'light',
|
|
302
|
-
roles: [],
|
|
303
|
-
...options.mock
|
|
304
|
-
};
|
|
433
|
+
const mock = buildMock(options);
|
|
305
434
|
|
|
435
|
+
// The context is a classic script (synchronous, so it exists
|
|
436
|
+
// before anything else runs). The streams client (upload /
|
|
437
|
+
// downloadUrl) rides inside it: the one piece of the mock that is
|
|
438
|
+
// code, not data, the same helper the deployed page gets,
|
|
439
|
+
// speaking to the /_uploads and /_downloads mounted in
|
|
440
|
+
// configureServer. The channel mock follows as a module script
|
|
441
|
+
// because only a Vite-processed module gets import.meta.hot.
|
|
306
442
|
const script = `<script>
|
|
307
443
|
(function() {
|
|
308
444
|
'use strict';
|
|
309
445
|
window.__INFORMER__ = ${JSON.stringify(mock)};
|
|
446
|
+
var __streams = ${streamsClientSource()};
|
|
447
|
+
window.__INFORMER__.upload = __streams.upload;
|
|
448
|
+
window.__INFORMER__.downloadUrl = __streams.downloadUrl;
|
|
310
449
|
})();
|
|
311
|
-
</script
|
|
450
|
+
</script>
|
|
451
|
+
${renderDevChannelScript({ hub: Boolean(serverOrigin) })}`;
|
|
312
452
|
|
|
313
453
|
// Insert after <head> tag, matching server behavior
|
|
314
454
|
const headIdx = html.indexOf('<head>');
|
package/src/server-routes.js
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import { readdir, stat } from 'node:fs/promises';
|
|
2
|
-
import { join
|
|
2
|
+
import { join } from 'node:path';
|
|
3
3
|
import { parse as parseUrl } from 'node:url';
|
|
4
|
-
import {
|
|
4
|
+
import { createDevBagBuilder, buildDevUser } from './dev-bag.js';
|
|
5
|
+
import { createDevChannels } from './dev-channels.js';
|
|
6
|
+
import { createStreamStore, createStreamServices, isStreamRef, serveDownload as serveDevDownload } from './dev-streams.js';
|
|
5
7
|
|
|
6
8
|
const VALID_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
7
9
|
|
|
8
|
-
// Cap dev proxy calls at 30s so a hung upstream fails loudly instead of
|
|
9
|
-
// hanging the handler.
|
|
10
|
-
const FETCH_TIMEOUT_MS = 30000;
|
|
11
|
-
|
|
12
10
|
// Strict base64 — see view-api.js for the rationale. Mirror kept identical to
|
|
13
11
|
// keep dev and prod behavior aligned, down to the scan: the quantified
|
|
14
12
|
// /^[A-Za-z0-9+/]+={0,2}$/ throws "RangeError: Maximum call stack size
|
|
@@ -26,17 +24,19 @@ function isBase64(s) {
|
|
|
26
24
|
}
|
|
27
25
|
|
|
28
26
|
/**
|
|
29
|
-
* Convert a file path under
|
|
27
|
+
* Convert a file path under a handler directory to a route path (mirrors the
|
|
28
|
+
* server's route-scanner.js filePathToRoute).
|
|
30
29
|
*
|
|
31
30
|
* Examples:
|
|
32
|
-
* server/orders/index.js
|
|
33
|
-
* server/orders/[id].js
|
|
31
|
+
* server/orders/index.js -> /orders
|
|
32
|
+
* server/orders/[id].js -> /orders/:id
|
|
34
33
|
* server/orders/[id]/approve.js -> /orders/:id/approve
|
|
35
|
-
* server/index.js
|
|
34
|
+
* server/index.js -> /
|
|
35
|
+
* channels/rooms/[room].js -> /rooms/:room (dirPrefix 'channels')
|
|
36
36
|
*/
|
|
37
|
-
function filePathToRoute(filePath) {
|
|
37
|
+
function filePathToRoute(filePath, dirPrefix = 'server') {
|
|
38
38
|
let route = filePath
|
|
39
|
-
.replace(
|
|
39
|
+
.replace(new RegExp(`^${dirPrefix}/`), '')
|
|
40
40
|
.replace(/\.js$/, '');
|
|
41
41
|
|
|
42
42
|
route = route.replace(/\[([^\]]+)\]/g, ':$1');
|
|
@@ -132,7 +132,7 @@ async function scanRoutes(serverDir) {
|
|
|
132
132
|
}));
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
export { filePathToRoute, walkJsFiles };
|
|
135
|
+
export { filePathToRoute, matchRoute, walkJsFiles };
|
|
136
136
|
|
|
137
137
|
async function walkJsFiles(dir, basePath) {
|
|
138
138
|
const results = [];
|
|
@@ -158,6 +158,54 @@ async function walkJsFiles(dir, basePath) {
|
|
|
158
158
|
return results;
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Write a handler's `{ status, headers?, body?, encoding? }` response
|
|
163
|
+
* descriptor (mirrors app-sandbox.js buildInvokeScript / respondCallback).
|
|
164
|
+
* The encoding allow-list and contract checks are enforced inside the ivm in
|
|
165
|
+
* production; replicated here so handlers see the same errors in dev (where
|
|
166
|
+
* there's no isolate to attribute the throw to). If you change this, mirror
|
|
167
|
+
* it in modules/app/routes/view-api.js.
|
|
168
|
+
*/
|
|
169
|
+
function sendDescriptor(res, result) {
|
|
170
|
+
const encoding = typeof result.encoding === 'string' ? result.encoding : null;
|
|
171
|
+
if (encoding !== null && encoding !== 'base64') {
|
|
172
|
+
throw new Error(`Unknown response encoding: ${JSON.stringify(encoding)} (expected 'base64' or omitted)`);
|
|
173
|
+
}
|
|
174
|
+
if (encoding === 'base64' && typeof result.body !== 'string') {
|
|
175
|
+
throw new Error(`encoding: 'base64' requires body to be a base64-encoded string, got ${typeof result.body}`);
|
|
176
|
+
}
|
|
177
|
+
const responseBody = result.body !== undefined ? result.body : null;
|
|
178
|
+
|
|
179
|
+
res.statusCode = result.status || 200;
|
|
180
|
+
for (const [key, value] of Object.entries(result.headers || {})) {
|
|
181
|
+
res.setHeader(key, value);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (responseBody === null) {
|
|
185
|
+
res.end();
|
|
186
|
+
} else if (encoding === 'base64') {
|
|
187
|
+
if (!isBase64(responseBody)) {
|
|
188
|
+
throw new Error('Handler returned malformed base64 body');
|
|
189
|
+
}
|
|
190
|
+
res.end(Buffer.from(responseBody, 'base64'));
|
|
191
|
+
} else if (typeof responseBody === 'string') {
|
|
192
|
+
// Strings go out verbatim, as prod passes them.
|
|
193
|
+
if (!res.getHeader('content-type')) {
|
|
194
|
+
res.setHeader('Content-Type', 'application/json');
|
|
195
|
+
}
|
|
196
|
+
res.end(responseBody);
|
|
197
|
+
} else {
|
|
198
|
+
if (!res.getHeader('content-type')) {
|
|
199
|
+
res.setHeader('Content-Type', 'application/json');
|
|
200
|
+
}
|
|
201
|
+
res.end(JSON.stringify(responseBody));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function isDescriptor(value) {
|
|
206
|
+
return value !== null && typeof value === 'object' && typeof value.status === 'number';
|
|
207
|
+
}
|
|
208
|
+
|
|
161
209
|
/**
|
|
162
210
|
* Create Connect middleware for dev-mode server route execution.
|
|
163
211
|
*
|
|
@@ -170,92 +218,20 @@ async function walkJsFiles(dir, basePath) {
|
|
|
170
218
|
* @param {string[]} [opts.roles] - dev user roles surfaced on request.roles
|
|
171
219
|
* @param {Object} [opts.devBindings] - dev bindings for `target: app` deps
|
|
172
220
|
* @param {string|null} [opts.appToken] - INFORMER_APP_TOKEN for cross-app request()
|
|
221
|
+
* @param {ReturnType<typeof createDevChannels>} [opts.channels] - the dev channels hub
|
|
222
|
+
* behind `broadcast()` and the `channels:` relay (a private one when omitted)
|
|
223
|
+
* @param {Object} [opts.streamStore] - dev stream store shared with the /_uploads
|
|
224
|
+
* and /_downloads middleware (createStreamStore()); a private one when omitted
|
|
173
225
|
* @returns {Function} Connect middleware
|
|
174
226
|
*/
|
|
175
|
-
export function createMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, roles, devBindings, appToken }) {
|
|
227
|
+
export function createMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, roles, user, devBindings, appToken, channels = createDevChannels(), streamStore }) {
|
|
228
|
+
// `@user/${request.user.username}` on the server names the channel the
|
|
229
|
+
// page subscribes to.
|
|
230
|
+
const devUser = buildDevUser(user);
|
|
176
231
|
const serverDir = join(projectRoot, 'server');
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if (!devWorkspaceId) {
|
|
181
|
-
throw new Error('query() requires INFORMER_DEV_WORKSPACE. Run: npx informer-workspace init');
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const resp = await globalThis.fetch(`${serverOrigin}/api/datasources/${devWorkspaceId}/_sql`, {
|
|
185
|
-
method: 'POST',
|
|
186
|
-
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
|
|
187
|
-
body: JSON.stringify({ sql, params: params || [] })
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
if (!resp.ok) {
|
|
191
|
-
const err = await resp.json().catch(() => ({}));
|
|
192
|
-
const detail = err.message || resp.statusText;
|
|
193
|
-
throw new Error(`query() failed: ${resp.status} ${detail} (${serverOrigin}/api/datasources/${devWorkspaceId}/_sql)`);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const data = await resp.json();
|
|
197
|
-
return data.rows;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// fetch() implementation — proxies API calls to the Informer server
|
|
201
|
-
async function fetchAs(auth, path, opts = {}) {
|
|
202
|
-
const method = (opts.method || 'GET').toUpperCase();
|
|
203
|
-
// Same strict canonicalization as prod (app-sandbox.js normalizeFetchPath);
|
|
204
|
-
// reject non-canonical shapes here instead of silently accepting them.
|
|
205
|
-
const apiPath = normalizeFetchPath(path);
|
|
206
|
-
if (!apiPath) {
|
|
207
|
-
return { status: 400, body: { error: `Invalid fetch path: ${String(path).slice(0, 80)}` }, contentType: 'application/json' };
|
|
208
|
-
}
|
|
209
|
-
const url = `${serverOrigin}${apiPath}`;
|
|
210
|
-
const fetchOpts = {
|
|
211
|
-
method,
|
|
212
|
-
headers: { Authorization: auth, 'Content-Type': 'application/json', ...(opts.headers || {}) }
|
|
213
|
-
};
|
|
214
|
-
|
|
215
|
-
if (opts.body && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
|
216
|
-
fetchOpts.body = JSON.stringify(opts.body);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// Read the stream exactly once — `.json()` consumes/locks the body, so a
|
|
220
|
-
// `.text()` fallback would throw "Body is unusable" on any non-JSON
|
|
221
|
-
// response (auth-bounce HTML, proxy error page). Parse in memory
|
|
222
|
-
// instead — same as prod's unwrapInject.
|
|
223
|
-
let status, contentType, text;
|
|
224
|
-
try {
|
|
225
|
-
const resp = await globalThis.fetch(url, { ...fetchOpts, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
226
|
-
status = resp.status;
|
|
227
|
-
contentType = resp.headers.get('content-type') || '';
|
|
228
|
-
text = await resp.text();
|
|
229
|
-
} catch (err) {
|
|
230
|
-
// Transport failure or timeout — fetch throws (TypeError 'fetch failed'
|
|
231
|
-
// with the real reason on err.cause, or a TimeoutError). Return a
|
|
232
|
-
// synthetic 502 so the dependency layer names it (dep + url + cause)
|
|
233
|
-
// rather than a bare unhandled "fetch failed".
|
|
234
|
-
const reason = (err.cause && err.cause.message) || err.message;
|
|
235
|
-
return { status: 502, body: { message: `fetch to ${url} failed: ${reason}` }, contentType: 'application/json' };
|
|
236
|
-
}
|
|
237
|
-
let body;
|
|
238
|
-
try { body = JSON.parse(text); } catch { body = text; }
|
|
239
|
-
return { status, body, contentType };
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
async function apiFetch(path, opts = {}) {
|
|
243
|
-
return await fetchAs(authHeader, path, opts);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// Cross-app request() targets /api/apps/<id>/view/_/<path>, whose auth accepts
|
|
247
|
-
// only the token/session strategies — NOT basic auth. In API-key mode the
|
|
248
|
-
// INFORMER_API_KEY Bearer already satisfies that, so reuse it; under basic
|
|
249
|
-
// auth a separate API token (INFORMER_APP_TOKEN) is required, and without one
|
|
250
|
-
// the app proxy's request() throws a pointed error instead of a bare 401.
|
|
251
|
-
// appFetch also stamps x-informer-app-depth:1 so the target runs one hop deep
|
|
252
|
-
// and enforces the same one-hop guard it does in production.
|
|
253
|
-
const appAuth = appToken
|
|
254
|
-
? `Bearer ${appToken}`
|
|
255
|
-
: (authHeader && authHeader.startsWith('Bearer ') ? authHeader : null);
|
|
256
|
-
const appFetch = appAuth
|
|
257
|
-
? async (path, opts = {}) => await fetchAs(appAuth, path, { ...opts, headers: { ...(opts.headers || {}), 'x-informer-app-depth': '1' } })
|
|
258
|
-
: null;
|
|
232
|
+
const streams = streamStore || createStreamStore();
|
|
233
|
+
const bagBuilder = createDevBagBuilder({ serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings, appToken, channels, logPrefix: '[app]' });
|
|
234
|
+
const { query } = bagBuilder;
|
|
259
235
|
|
|
260
236
|
return async function serverRoutesMiddleware(req, res, next) {
|
|
261
237
|
try {
|
|
@@ -300,40 +276,6 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
|
|
|
300
276
|
try { body = JSON.parse(rawBody); } catch { body = rawBody; }
|
|
301
277
|
}
|
|
302
278
|
|
|
303
|
-
// crypto helper — mirrors the prod sandbox crypto surface
|
|
304
|
-
const cryptoHelper = buildDevCrypto();
|
|
305
|
-
|
|
306
|
-
// log helper — mirrors sandbox log(message, data) + log.info/warn/error/debug
|
|
307
|
-
const logCall = (level, message, data) => {
|
|
308
|
-
const msg = typeof message === 'string' ? message : JSON.stringify(message);
|
|
309
|
-
const args = [`[app-log] [${level}] ${msg}`];
|
|
310
|
-
if (data) args.push(data);
|
|
311
|
-
console.log(...args);
|
|
312
|
-
};
|
|
313
|
-
const log = Object.assign(
|
|
314
|
-
(message, data) => logCall('info', message, data),
|
|
315
|
-
{
|
|
316
|
-
debug: (message, data) => logCall('debug', message, data),
|
|
317
|
-
info: (message, data) => logCall('info', message, data),
|
|
318
|
-
warn: (message, data) => logCall('warn', message, data),
|
|
319
|
-
error: (message, data) => logCall('error', message, data)
|
|
320
|
-
}
|
|
321
|
-
);
|
|
322
|
-
|
|
323
|
-
// markdown helper — passthrough in dev (production uses `marked`)
|
|
324
|
-
const markdown = (text) => text;
|
|
325
|
-
|
|
326
|
-
// emit helper — no-op in dev (logs to console)
|
|
327
|
-
const emit = (event, payload) => {
|
|
328
|
-
console.log(`[app-event] emit("${event}",`, JSON.stringify(payload), ')');
|
|
329
|
-
return { ok: true };
|
|
330
|
-
};
|
|
331
|
-
|
|
332
|
-
// notify/email — delivery is a console-logged no-op in dev, but the
|
|
333
|
-
// required-field validation mirrors prod so an app that passes here
|
|
334
|
-
// won't 500 in production.
|
|
335
|
-
const { notify, email } = buildDevMessaging('[app]');
|
|
336
|
-
|
|
337
279
|
// Build request context
|
|
338
280
|
const request = {
|
|
339
281
|
method: req.method.toUpperCase(),
|
|
@@ -344,92 +286,75 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
|
|
|
344
286
|
rawBody,
|
|
345
287
|
headers: req.headers,
|
|
346
288
|
roles: roles || [],
|
|
347
|
-
user: {
|
|
348
|
-
username: 'dev-user',
|
|
349
|
-
displayName: 'Dev User',
|
|
350
|
-
email: null,
|
|
351
|
-
timezone: null
|
|
352
|
-
}
|
|
289
|
+
user: { ...devUser }
|
|
353
290
|
};
|
|
354
291
|
|
|
355
|
-
//
|
|
292
|
+
// App streams (I5-12979): per-invocation uploads/downloads services.
|
|
293
|
+
// An upload handle passed as a query() parameter is swapped for a
|
|
294
|
+
// \x bytea literal on the way to the _sql proxy (prod passes the
|
|
295
|
+
// bytes as a Buffer parameter — same column-typed coercion).
|
|
296
|
+
const streamServices = createStreamServices({ store: streams, query: devWorkspaceId ? query : null });
|
|
297
|
+
const streamQuery = async (sql, params) => await query(sql, await streamServices.resolveQueryParams(params || []));
|
|
298
|
+
|
|
299
|
+
// Serve a download handle as the response, like view-api.js does
|
|
300
|
+
// when a handler returns one (single-use, prod headers).
|
|
301
|
+
function serveDownloadHandle(ref) {
|
|
302
|
+
const item = streams.get('download', ref.id);
|
|
303
|
+
if (!item) throw new Error(`Unknown download ${ref.id} — was it discarded?`);
|
|
304
|
+
// Do NOT force complete: production 404s an unsealed download
|
|
305
|
+
// (app-streams.js#serveDownload), so forcing it here would hide
|
|
306
|
+
// a half-written download that fails once deployed.
|
|
307
|
+
if (!item.complete) {
|
|
308
|
+
throw new Error(`Download ${ref.id} was never ended — call end() (or return it) before serving it.`);
|
|
309
|
+
}
|
|
310
|
+
serveDevDownload(streams, item, res);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// respond() — sends the early response, the handler continues in
|
|
314
|
+
// the background. A `{ status, headers?, body?, encoding? }`
|
|
315
|
+
// descriptor is honored as such (app-sandbox.js respondCallback);
|
|
316
|
+
// anything else is wrapped as 200 JSON; a download handle streams
|
|
317
|
+
// the staged bytes.
|
|
356
318
|
let responded = false;
|
|
357
|
-
function respond(earlyBody) {
|
|
319
|
+
async function respond(earlyBody) {
|
|
358
320
|
if (responded) return;
|
|
321
|
+
if (isStreamRef(earlyBody, 'download')) {
|
|
322
|
+
// Seal it first, as production does (app-sandbox.js
|
|
323
|
+
// respondCallback), then claim the response — not before,
|
|
324
|
+
// or a failure here would leave the request unanswered.
|
|
325
|
+
await streamServices.endDownload(earlyBody.id);
|
|
326
|
+
responded = true;
|
|
327
|
+
return serveDownloadHandle(earlyBody);
|
|
328
|
+
}
|
|
359
329
|
responded = true;
|
|
330
|
+
if (isDescriptor(earlyBody)) return sendDescriptor(res, earlyBody);
|
|
360
331
|
res.statusCode = 200;
|
|
361
332
|
res.setHeader('Content-Type', 'application/json');
|
|
362
333
|
res.end(JSON.stringify(earlyBody));
|
|
363
334
|
}
|
|
364
335
|
|
|
365
|
-
|
|
366
|
-
// sandbox does, so handlers using `await context.myDep.method(...)`
|
|
367
|
-
// work locally. Loaded per request so edits to informer.yaml take
|
|
368
|
-
// effect without a dev-server restart.
|
|
369
|
-
const deps = await loadDependencies(projectRoot);
|
|
370
|
-
const context = buildDevContext({ deps, apiFetch, devBindings, appFetch });
|
|
371
|
-
const env = await loadAppEnv(projectRoot);
|
|
336
|
+
const { bag } = await bagBuilder.build();
|
|
372
337
|
|
|
373
338
|
// Call handler — bag mirrors the prod sandbox (see app-sandbox.js).
|
|
374
|
-
const result = await handler({
|
|
339
|
+
const result = await handler({ ...bag, request, query: streamQuery, respond, uploads: streamServices.uploads, downloads: streamServices.downloads });
|
|
340
|
+
|
|
341
|
+
// Seal any download the handler left open (prod finalizes the same way).
|
|
342
|
+
await streamServices.finalize();
|
|
375
343
|
|
|
376
344
|
// If respond() was already called, the response is already sent
|
|
377
345
|
if (responded) return;
|
|
378
346
|
|
|
379
|
-
//
|
|
380
|
-
|
|
381
|
-
// ivm in production; replicate them here so handlers see the same
|
|
382
|
-
// errors in dev (where there's no isolate to attribute the throw to).
|
|
383
|
-
// If you change this, mirror it in modules/app/routes/view-api.js.
|
|
384
|
-
let status, responseBody, responseHeaders, encoding;
|
|
347
|
+
// A returned download handle streams as the response.
|
|
348
|
+
if (isStreamRef(result, 'download')) return serveDownloadHandle(result);
|
|
385
349
|
|
|
350
|
+
// Normalize the return value (mirrors app-sandbox.js buildInvokeScript).
|
|
386
351
|
if (result === undefined || result === null) {
|
|
387
|
-
|
|
388
|
-
responseBody = null;
|
|
389
|
-
responseHeaders = {};
|
|
390
|
-
} else if (typeof result === 'object' && typeof result.status === 'number') {
|
|
391
|
-
encoding = typeof result.encoding === 'string' ? result.encoding : null;
|
|
392
|
-
if (encoding !== null && encoding !== 'base64') {
|
|
393
|
-
throw new Error(`Unknown response encoding: ${JSON.stringify(encoding)} (expected 'base64' or omitted)`);
|
|
394
|
-
}
|
|
395
|
-
if (encoding === 'base64' && typeof result.body !== 'string') {
|
|
396
|
-
throw new Error(`encoding: 'base64' requires body to be a base64-encoded string, got ${typeof result.body}`);
|
|
397
|
-
}
|
|
398
|
-
status = result.status || 200;
|
|
399
|
-
responseBody = result.body !== undefined ? result.body : null;
|
|
400
|
-
responseHeaders = result.headers || {};
|
|
401
|
-
} else {
|
|
402
|
-
status = 200;
|
|
403
|
-
responseBody = result;
|
|
404
|
-
responseHeaders = { 'content-type': 'application/json' };
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
res.statusCode = status;
|
|
408
|
-
for (const [key, value] of Object.entries(responseHeaders)) {
|
|
409
|
-
res.setHeader(key, value);
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
if (responseBody === null) {
|
|
352
|
+
res.statusCode = 204;
|
|
413
353
|
res.end();
|
|
414
|
-
} else if (
|
|
415
|
-
|
|
416
|
-
throw new Error('Handler returned malformed base64 body');
|
|
417
|
-
}
|
|
418
|
-
res.end(Buffer.from(responseBody, 'base64'));
|
|
419
|
-
} else if (typeof responseBody === 'string') {
|
|
420
|
-
// Pre-PR the dev middleware always JSON.stringify'd the body, so
|
|
421
|
-
// a handler returning { body: 'hello' } emitted "hello" (with
|
|
422
|
-
// quotes) — diverging from prod which passed strings verbatim.
|
|
423
|
-
// This branch fixes that parity.
|
|
424
|
-
if (!res.getHeader('content-type')) {
|
|
425
|
-
res.setHeader('Content-Type', 'application/json');
|
|
426
|
-
}
|
|
427
|
-
res.end(responseBody);
|
|
354
|
+
} else if (isDescriptor(result)) {
|
|
355
|
+
sendDescriptor(res, result);
|
|
428
356
|
} else {
|
|
429
|
-
|
|
430
|
-
res.setHeader('Content-Type', 'application/json');
|
|
431
|
-
}
|
|
432
|
-
res.end(JSON.stringify(responseBody));
|
|
357
|
+
sendDescriptor(res, { status: 200, headers: { 'content-type': 'application/json' }, body: result });
|
|
433
358
|
}
|
|
434
359
|
} catch (err) {
|
|
435
360
|
viteServer.ssrFixStacktrace(err);
|