@entrinsik/vite-plugin-informer 2.10.0 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -3
- package/bin/init.js +13 -2
- package/bin/workspace.js +15 -7
- package/index.d.ts +14 -0
- package/package.json +1 -1
- package/src/agent-dev.js +8 -11
- package/src/dev-bag.js +241 -0
- package/src/dev-channel-handlers.js +335 -0
- package/src/dev-channel-shim.js +276 -39
- package/src/dev-channels.js +138 -28
- package/src/dev-dependencies.js +140 -4
- package/src/dev-platform.js +81 -5
- package/src/dev-streams.js +176 -27
- package/src/env.js +17 -1
- package/src/index.js +87 -9
- package/src/server-routes.js +79 -211
- package/src/streams-client.js +121 -8
package/README.md
CHANGED
|
@@ -13,6 +13,8 @@ npm run dev # local dev against the Informer in .
|
|
|
13
13
|
npm run deploy # assemble + upload + deploy
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
+
`informer-init` leaves an existing `@entrinsik/vite-plugin-informer` entry alone, and writes one only when the dependency is missing — in which case it uses its own version (2.12.0 and later; before that it wrote `^1.0.0`). A project scaffolded earlier still carries `^1.0.0`, which resolves to 1.0.5 and predates everything below: bump it by hand.
|
|
17
|
+
|
|
16
18
|
## What a deploy ships
|
|
17
19
|
|
|
18
20
|
The assembler uploads your built frontend plus these source trees, matching how an App's library is structured in Informer:
|
|
@@ -23,7 +25,7 @@ The assembler uploads your built frontend plus these source trees, matching how
|
|
|
23
25
|
| `webhooks/` | Publicly callable webhook routes |
|
|
24
26
|
| `tools/` | AI tools, private to the App's agents |
|
|
25
27
|
| `mcp/` | Tools exposed over the App's MCP endpoint |
|
|
26
|
-
| `channels/` | Live channel join/leave handlers |
|
|
28
|
+
| `channels/` | Live channel handlers: `join` / `joined` / `leave` and event-named handlers for `channel.send()`; `npm run dev` runs them locally, and the `channels:` block in `informer.yaml` declares relays only (each entry needs `on`) |
|
|
27
29
|
| `migrations/` | Workspace database migrations, run in order on deploy |
|
|
28
30
|
| `embeddings/` | Declarative embedding use cases (see below) |
|
|
29
31
|
| `lib/`, `shared/` | Modules importable by the trees above |
|
|
@@ -44,6 +46,8 @@ Against an older server the CLI:
|
|
|
44
46
|
|
|
45
47
|
A server whose version isn't comparable (a `dev` build, or an `/about` behind a proxy) is treated as current and warned about — gating it would break deploys to builds made from source.
|
|
46
48
|
|
|
49
|
+
App Channels phase 2 (plugin ≥ 2.11.0: `channel.send()`, the `joined` export and event-named exports in `channels/` files, wildcard subscriptions, frame `seq` and replay, `connected`) needs a 2026.1.3 server that carries I5-13027. The version probe cannot tell such a server from an earlier 2026.1.3 build, so the deploy is not gated: an earlier build refuses a `channels/` file that exports `joined` or an event name, and accepts a `channels:` entry without `on` that a current server rejects. The dev server runs the phase 2 contract regardless; plugin 2.12.0 aligns its channel semantics with the server's (wildcard gating, replay expiry, and the per-user send budget), so on 2.11.0 the dev mirror has the phase 2 surface but not all of its behavior.
|
|
50
|
+
|
|
47
51
|
Feature-detect at runtime with optional chaining, since `platform` is absent entirely before 2026.1.3:
|
|
48
52
|
|
|
49
53
|
```javascript
|
|
@@ -116,11 +120,11 @@ const hits = await query(
|
|
|
116
120
|
|
|
117
121
|
### Dev loop
|
|
118
122
|
|
|
119
|
-
`npm run deploy` uploads `embeddings/` like any other source tree — there is no manifest block to add (plugin ≥ 2.10.0; earlier versions never upload the folder, so the use case silently does not exist on the server). `npm run dev` never runs the pump, and its `embed()` throws an explanation rather than embedding (the dev mirror reports `platform.capabilities.embeddings: false`), so a search route is exercised against a deployed App. Feature-detect with `platform?.capabilities?.embeddings` — `typeof embed === 'function'` is true on both, since the binding exists either way. Then, in the App admin panel, the **Embeddings** tab lists each use case with its pump status (queued, running, up to date, last run, last error, skipped docs) and a **Run now** action; the same surface exists as API routes (`GET /apps/{id}/embeddings`, `POST /apps/{id}/embeddings/{name}/_run`). Deploy, run, read the error, fix, repeat.
|
|
123
|
+
`npm run deploy` uploads `embeddings/` like any other source tree — there is no manifest block to add (plugin ≥ 2.10.0; earlier versions never upload the folder, so the use case silently does not exist on the server). `npm run dev` never runs the pump, and its `embed()` throws an explanation rather than embedding (the dev mirror reports `platform.capabilities.embeddings: false`), so a search route is exercised against a deployed App. Plugin ≥ 2.12.0 can opt in: `informer({ mock: { platform: { capabilities: { embeddings: true } } } })` binds the dev `embed()` to the deployed App's `_embed` route on the configured server (addressed by package.json `informer.id`; the dev credentials need write access to that App, and each call is billed to it), so the query vector under `npm run dev` is the deployed one — same model, same `revision`. The corpus is not: `query()` still reads the dev workspace datasource, which the pump never writes to, so a search route compares a deployed vector against local rows. Feature-detect with `platform?.capabilities?.embeddings` — `typeof embed === 'function'` is true on both, since the binding exists either way. Then, in the App admin panel, the **Embeddings** tab lists each use case with its pump status (queued, running, up to date, last run, last error, skipped docs) and a **Run now** action; the same surface exists as API routes (`GET /apps/{id}/embeddings`, `POST /apps/{id}/embeddings/{name}/_run`). Deploy, run, read the error, fix, repeat.
|
|
120
124
|
|
|
121
125
|
### Upgrading an App that already has an `embeddings/` folder
|
|
122
126
|
|
|
123
|
-
`embeddings/` is a server-side folder from
|
|
127
|
+
`embeddings/` is a server-side folder from Informer 2026.1.3 on, like `server/`: its files are uploaded with the library, never served to browsers, and scanned as pump handlers. An App that kept anything else there (assets, data files) loses those files in the browser after redeploying with plugin 2.10.0 or later. A `.js` file there that exports `GET` or `POST` but not both fails the deploy; one that exports neither is never scanned as a use case at all and lands as a warning on an otherwise successful deploy. Move such content elsewhere before upgrading; the deploy names every stray entry as a warning.
|
|
124
128
|
|
|
125
129
|
### Older Informer releases
|
|
126
130
|
|
package/bin/init.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { readFile, writeFile,
|
|
3
|
+
import { readFile, writeFile, access } from 'node:fs/promises';
|
|
4
4
|
import { resolve, basename } from 'node:path';
|
|
5
5
|
import { createInterface } from 'node:readline';
|
|
6
6
|
import { randomUUID } from 'node:crypto';
|
|
@@ -118,6 +118,16 @@ function prompt(question, defaultValue) {
|
|
|
118
118
|
});
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* The devDependency range a fresh scaffold gets. Read inside init() rather than
|
|
123
|
+
* at module scope so a failure reaches init()'s error handler.
|
|
124
|
+
*/
|
|
125
|
+
async function pluginVersionRange() {
|
|
126
|
+
const { version } = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
127
|
+
if (!version) throw new Error('could not read the version of @entrinsik/vite-plugin-informer; reinstall the plugin');
|
|
128
|
+
return `^${version}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
121
131
|
/**
|
|
122
132
|
* Convert package name to friendly display name.
|
|
123
133
|
* "magic-quickbooks-report" -> "Magic Quickbooks Report"
|
|
@@ -174,7 +184,8 @@ async function init() {
|
|
|
174
184
|
// 5. Add plugin to dependencies if not present
|
|
175
185
|
if (!pkg.devDependencies) pkg.devDependencies = {};
|
|
176
186
|
if (!pkg.devDependencies['@entrinsik/vite-plugin-informer']) {
|
|
177
|
-
|
|
187
|
+
// Float the scaffold on the version that scaffolded it, not a fixed range.
|
|
188
|
+
pkg.devDependencies['@entrinsik/vite-plugin-informer'] = await pluginVersionRange();
|
|
178
189
|
console.log('Added @entrinsik/vite-plugin-informer to devDependencies');
|
|
179
190
|
}
|
|
180
191
|
|
package/bin/workspace.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { readFile } from 'node:fs/promises';
|
|
4
|
-
import { resolve } from 'node:path';
|
|
4
|
+
import { resolve, basename } from 'node:path';
|
|
5
5
|
import { createClient } from '../src/client.js';
|
|
6
|
-
import { loadEnv, envWritePath, parseModeArg } from '../src/env.js';
|
|
6
|
+
import { loadEnv, envWritePath, localEnvValue, parseModeArg } from '../src/env.js';
|
|
7
7
|
import { init, migrate, reset } from '../src/workspace.js';
|
|
8
8
|
|
|
9
9
|
const mode = parseModeArg(process.argv);
|
|
@@ -41,12 +41,22 @@ const api = createClient({ baseUrl, apiKey, user, pass });
|
|
|
41
41
|
const migrationsDir = resolve('migrations');
|
|
42
42
|
const envPath = envWritePath({ mode });
|
|
43
43
|
|
|
44
|
+
// loadEnv walks up to a parent .env (monorepo config), so process.env may
|
|
45
|
+
// carry ANOTHER app's workspace id. Only this app's own env file decides
|
|
46
|
+
// whether it is initialized; migrate/reset fall back to the inherited value.
|
|
47
|
+
const localWorkspaceId = localEnvValue('INFORMER_DEV_WORKSPACE', { mode });
|
|
48
|
+
const inheritedWorkspaceId = process.env.INFORMER_DEV_WORKSPACE || null;
|
|
49
|
+
if (!localWorkspaceId && inheritedWorkspaceId && command === 'init') {
|
|
50
|
+
console.log(`Ignoring INFORMER_DEV_WORKSPACE=${inheritedWorkspaceId} inherited from the environment: ${basename(envPath)} does not define it.`);
|
|
51
|
+
}
|
|
52
|
+
const workspaceId = localWorkspaceId || inheritedWorkspaceId;
|
|
53
|
+
|
|
44
54
|
try {
|
|
45
55
|
if (command === 'init') {
|
|
46
56
|
// Check if already initialized
|
|
47
|
-
if (
|
|
48
|
-
console.error(`Workspace already initialized: ${
|
|
49
|
-
console.error(
|
|
57
|
+
if (localWorkspaceId) {
|
|
58
|
+
console.error(`Workspace already initialized: ${localWorkspaceId}`);
|
|
59
|
+
console.error(`Run workspace:reset to start fresh, or remove INFORMER_DEV_WORKSPACE from ${basename(envPath)} to re-initialize.`);
|
|
50
60
|
process.exit(1);
|
|
51
61
|
}
|
|
52
62
|
|
|
@@ -72,7 +82,6 @@ try {
|
|
|
72
82
|
await init({ api, slug, migrationsDir, envPath });
|
|
73
83
|
|
|
74
84
|
} else if (command === 'migrate') {
|
|
75
|
-
const workspaceId = process.env.INFORMER_DEV_WORKSPACE;
|
|
76
85
|
if (!workspaceId) {
|
|
77
86
|
console.error('No dev workspace found. Run workspace:init first.');
|
|
78
87
|
process.exit(1);
|
|
@@ -80,7 +89,6 @@ try {
|
|
|
80
89
|
await migrate({ api, workspaceId, migrationsDir });
|
|
81
90
|
|
|
82
91
|
} else if (command === 'reset') {
|
|
83
|
-
const workspaceId = process.env.INFORMER_DEV_WORKSPACE;
|
|
84
92
|
if (!workspaceId) {
|
|
85
93
|
console.error('No dev workspace found. Run workspace:init first.');
|
|
86
94
|
process.exit(1);
|
package/index.d.ts
CHANGED
|
@@ -15,11 +15,25 @@ export interface AppDevBinding {
|
|
|
15
15
|
app?: string;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* What the platform offers the app, as the server injects it
|
|
20
|
+
* (`window.__INFORMER__.platform`). A mock override merges into the dev
|
|
21
|
+
* defaults, so naming one capability leaves the rest alone.
|
|
22
|
+
*/
|
|
23
|
+
export interface MockPlatform {
|
|
24
|
+
version?: string;
|
|
25
|
+
originMode?: boolean;
|
|
26
|
+
capabilities?: Record<string, boolean>;
|
|
27
|
+
}
|
|
28
|
+
|
|
18
29
|
export interface InformerPluginOptions {
|
|
19
30
|
mock?: {
|
|
20
31
|
report?: { id?: string; name?: string };
|
|
21
32
|
theme?: 'light' | 'dark';
|
|
22
33
|
roles?: string[];
|
|
34
|
+
/** The viewer identity, for testing `@user/<username>` channels as someone else. */
|
|
35
|
+
user?: { username?: string; displayName?: string };
|
|
36
|
+
platform?: MockPlatform;
|
|
23
37
|
};
|
|
24
38
|
devBindings?: Record<string, string | AppDevBinding>;
|
|
25
39
|
proxy?: Record<string, unknown>;
|
package/package.json
CHANGED
package/src/agent-dev.js
CHANGED
|
@@ -4,15 +4,7 @@ import { parse as parseUrl } from 'node:url';
|
|
|
4
4
|
import yaml from 'yaml';
|
|
5
5
|
import { manifestBlock, buildDevContext, buildDevCrypto, buildDevMessaging, normalizeFetchPath } from './dev-dependencies.js';
|
|
6
6
|
import { createDevChannels, createDevEmit } from './dev-channels.js';
|
|
7
|
-
import { devPlatform } from './dev-platform.js';
|
|
8
|
-
|
|
9
|
-
// embed() is present on a real install even without the embeddings
|
|
10
|
-
// capability, where it throws a written explanation. Mirroring that here
|
|
11
|
-
// keeps the dev failure the same lesson as the deployed one, instead of a
|
|
12
|
-
// bare "embed is not a function" that reads like a missing binding.
|
|
13
|
-
const embed = async () => {
|
|
14
|
-
throw new Error('embed() is not available in the dev mirror: the embeddings capability needs a real Informer (platform.capabilities.embeddings is false)');
|
|
15
|
-
};
|
|
7
|
+
import { devPlatform, createDevEmbed } from './dev-platform.js';
|
|
16
8
|
|
|
17
9
|
const parseYaml = yaml.parse;
|
|
18
10
|
|
|
@@ -164,7 +156,7 @@ async function readSSE(response) {
|
|
|
164
156
|
* behind `broadcast()` and the `channels:` relay (a private one when omitted)
|
|
165
157
|
* @returns {Function} Connect middleware
|
|
166
158
|
*/
|
|
167
|
-
export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings, appToken, channels = createDevChannels() }) {
|
|
159
|
+
export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings, appToken, channels = createDevChannels(), platform = devPlatform(), appId = null }) {
|
|
168
160
|
|
|
169
161
|
// query() — proxies to the workspace _sql endpoint (same as server-routes.js)
|
|
170
162
|
async function query(sql, params) {
|
|
@@ -223,6 +215,11 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
|
|
|
223
215
|
return { status, body, contentType };
|
|
224
216
|
}
|
|
225
217
|
|
|
218
|
+
// Throws the written explanation unless the mock platform opts into
|
|
219
|
+
// embeddings; then posts to the deployed app's _embed route through
|
|
220
|
+
// apiFetch (hoisted below). See createDevEmbed.
|
|
221
|
+
const embed = createDevEmbed({ platform, apiFetch, appId });
|
|
222
|
+
|
|
226
223
|
async function apiFetch(path, opts = {}) {
|
|
227
224
|
return await fetchAs(authHeader, path, opts);
|
|
228
225
|
}
|
|
@@ -455,7 +452,7 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
|
|
|
455
452
|
|
|
456
453
|
if (tool) {
|
|
457
454
|
try {
|
|
458
|
-
result = await tool.handler({ args: tc.input, run: { agentName, trigger: triggerEvent }, context, query, fetch: apiFetch, emit, broadcast, notify, email, embed, crypto: cryptoHelper, markdown, log, env, platform
|
|
455
|
+
result = await tool.handler({ args: tc.input, run: { agentName, trigger: triggerEvent }, context, query, fetch: apiFetch, emit, broadcast, notify, email, embed, crypto: cryptoHelper, markdown, log, env, platform });
|
|
459
456
|
} catch (err) {
|
|
460
457
|
console.error(`[agent-dev] Tool "${tc.name}" failed:`, err.message);
|
|
461
458
|
result = { error: err.message };
|
package/src/dev-bag.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { loadManifest, manifestBlock, buildDevContext, buildDevCrypto, buildDevMessaging, normalizeFetchPath } from './dev-dependencies.js';
|
|
2
|
+
import { createDevChannels, createDevEmit } from './dev-channels.js';
|
|
3
|
+
import { LIMITS } from './dev-streams.js';
|
|
4
|
+
import { devPlatform, createDevEmbed } from './dev-platform.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The part of the dev handler bag every surface shares — `server/` routes and
|
|
8
|
+
* `channels/` handlers alike: the platform services (`query`, `fetch`, the
|
|
9
|
+
* typed dependency `context`, `emit`, `broadcast`, `notify`, `email`,
|
|
10
|
+
* `crypto`, `markdown`, `log`, `env`, `platform`). Each surface adds only its
|
|
11
|
+
* own inbound members (`request` + `respond` for HTTP, `channel` + `payload`
|
|
12
|
+
* for channels), the same split the prod sandbox's buildInvokeScript makes.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// Cap dev proxy calls at 30s so a hung upstream fails loudly instead of
|
|
16
|
+
// hanging the handler.
|
|
17
|
+
const FETCH_TIMEOUT_MS = 30000;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Read a response body into memory, refusing once it passes `max` and cancelling
|
|
21
|
+
* the reader so the rest never arrives. `arrayBuffer()` would buffer whatever the
|
|
22
|
+
* upstream sends before anything could measure it.
|
|
23
|
+
*/
|
|
24
|
+
async function readCapped(resp, max) {
|
|
25
|
+
if (!resp.body) return Buffer.from(await resp.arrayBuffer());
|
|
26
|
+
const reader = resp.body.getReader();
|
|
27
|
+
const chunks = [];
|
|
28
|
+
let total = 0;
|
|
29
|
+
for (;;) {
|
|
30
|
+
const { done, value } = await reader.read();
|
|
31
|
+
if (done) break;
|
|
32
|
+
total += value.byteLength;
|
|
33
|
+
if (total > max) {
|
|
34
|
+
await reader.cancel();
|
|
35
|
+
throw new RangeError(`The upstream body exceeds the ${max} bytes the dev server holds in memory`);
|
|
36
|
+
}
|
|
37
|
+
chunks.push(Buffer.from(value));
|
|
38
|
+
}
|
|
39
|
+
return Buffer.concat(chunks);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** log(message, data) + log.debug/info/warn/error — the sandbox log surface, to the terminal. */
|
|
43
|
+
export function buildDevLog(prefix = '[app-log]') {
|
|
44
|
+
const logCall = (level, message, data) => {
|
|
45
|
+
const msg = typeof message === 'string' ? message : JSON.stringify(message);
|
|
46
|
+
const args = [`${prefix} [${level}] ${msg}`];
|
|
47
|
+
if (data) args.push(data);
|
|
48
|
+
console.log(...args);
|
|
49
|
+
};
|
|
50
|
+
return Object.assign(
|
|
51
|
+
(message, data) => logCall('info', message, data),
|
|
52
|
+
{
|
|
53
|
+
debug: (message, data) => logCall('debug', message, data),
|
|
54
|
+
info: (message, data) => logCall('info', message, data),
|
|
55
|
+
warn: (message, data) => logCall('warn', message, data),
|
|
56
|
+
error: (message, data) => logCall('error', message, data)
|
|
57
|
+
}
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The viewer identity dev handlers see — the same object the page gets as window.__INFORMER__.user. */
|
|
62
|
+
export function buildDevUser(user) {
|
|
63
|
+
return {
|
|
64
|
+
username: (user && user.username) || 'dev',
|
|
65
|
+
displayName: (user && user.displayName) || 'Local Developer',
|
|
66
|
+
email: (user && user.email) || null,
|
|
67
|
+
timezone: (user && user.timezone) || null
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build the shared services once per dev server and the per-invocation bag
|
|
73
|
+
* on demand.
|
|
74
|
+
*
|
|
75
|
+
* @param {Object} opts
|
|
76
|
+
* @param {string} opts.serverOrigin - Informer server origin
|
|
77
|
+
* @param {string} opts.authHeader - Basic or Bearer auth header for /api calls
|
|
78
|
+
* @param {string|null} opts.devWorkspaceId - workspace datasource id for query()
|
|
79
|
+
* @param {string} opts.projectRoot - app project root
|
|
80
|
+
* @param {Object} [opts.devBindings] - dev bindings for `target: app` / `target: pack` deps
|
|
81
|
+
* @param {string|null} [opts.appToken] - INFORMER_APP_TOKEN for cross-app request()
|
|
82
|
+
* @param {ReturnType<typeof createDevChannels>} [opts.channels] - the dev channels hub
|
|
83
|
+
* @param {string} [opts.logPrefix] - prefix for notify/email/emit console lines
|
|
84
|
+
* @param {Object} [opts.platform] - the MERGED platform descriptor (dev defaults
|
|
85
|
+
* + mock.platform). The bag must see the same one the browser does, or an app
|
|
86
|
+
* that opts into `embeddings` reads the flag as true and still finds embed()
|
|
87
|
+
* throwing. Defaults to the bare dev descriptor.
|
|
88
|
+
* @param {string|null} [opts.appId] - the deployed app's id, for the opt-in
|
|
89
|
+
* dev embed()'s call to its `_embed` route
|
|
90
|
+
* @returns {{ query: Function, apiFetch: Function, build: (opts?: { forwarding?: Object|null }) => Promise<{ manifest: Object, bag: Object }> }}
|
|
91
|
+
*/
|
|
92
|
+
export function createDevBagBuilder({ serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings = {}, appToken = null, channels = createDevChannels(), logPrefix = '[app]', platform = devPlatform(), appId = null }) {
|
|
93
|
+
// query() implementation — proxies to the workspace _sql endpoint
|
|
94
|
+
async function query(sql, params) {
|
|
95
|
+
if (!devWorkspaceId) {
|
|
96
|
+
throw new Error('query() requires INFORMER_DEV_WORKSPACE. Run: npx informer-workspace init');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const resp = await globalThis.fetch(`${serverOrigin}/api/datasources/${devWorkspaceId}/_sql`, {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
|
|
102
|
+
body: JSON.stringify({ sql, params: params || [] })
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (!resp.ok) {
|
|
106
|
+
const err = await resp.json().catch(() => ({}));
|
|
107
|
+
const detail = err.message || resp.statusText;
|
|
108
|
+
throw new Error(`query() failed: ${resp.status} ${detail} (${serverOrigin}/api/datasources/${devWorkspaceId}/_sql)`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const data = await resp.json();
|
|
112
|
+
return data.rows;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// fetch() implementation — proxies API calls to the Informer server
|
|
116
|
+
async function fetchAs(auth, path, opts = {}) {
|
|
117
|
+
const method = (opts.method || 'GET').toUpperCase();
|
|
118
|
+
// Same strict canonicalization as prod (app-sandbox.js normalizeFetchPath);
|
|
119
|
+
// reject non-canonical shapes here instead of silently accepting them.
|
|
120
|
+
const apiPath = normalizeFetchPath(path);
|
|
121
|
+
if (!apiPath) {
|
|
122
|
+
// `message` is where every other failure from here puts its
|
|
123
|
+
// sentence (the synthetic 502 below, and boom's own answers), so
|
|
124
|
+
// one field read by a caller finds all of them. `error` stays
|
|
125
|
+
// beside it for anything already reading that.
|
|
126
|
+
const reason = `Invalid fetch path: ${String(path).slice(0, 80)}`;
|
|
127
|
+
return { status: 400, body: { message: reason, error: reason }, contentType: 'application/json' };
|
|
128
|
+
}
|
|
129
|
+
const url = `${serverOrigin}${apiPath}`;
|
|
130
|
+
const fetchOpts = {
|
|
131
|
+
method,
|
|
132
|
+
headers: { Authorization: auth, 'Content-Type': 'application/json', ...(opts.headers || {}) }
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
if (opts.body && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
|
136
|
+
fetchOpts.body = JSON.stringify(opts.body);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Read the stream exactly once — `.json()` consumes/locks the body, so a
|
|
140
|
+
// `.text()` fallback would throw "Body is unusable" on any non-JSON
|
|
141
|
+
// response (auth-bounce HTML, proxy error page). Parse in memory
|
|
142
|
+
// instead — same as prod's unwrapInject. `raw: true` (I5-13030, a
|
|
143
|
+
// stream being filled from an integration) keeps the bytes as bytes and
|
|
144
|
+
// hands the headers back too; the body is still parsed for an error.
|
|
145
|
+
let status, contentType, text, bytes, headers;
|
|
146
|
+
try {
|
|
147
|
+
const resp = await globalThis.fetch(url, { ...fetchOpts, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
148
|
+
status = resp.status;
|
|
149
|
+
contentType = resp.headers.get('content-type') || '';
|
|
150
|
+
if (opts.raw) {
|
|
151
|
+
// Bounded before it is buffered. Prod meters the body mid-stream
|
|
152
|
+
// and tears the socket down at the first byte past the cap; here
|
|
153
|
+
// the whole thing lands in memory, so an unbounded read turns a
|
|
154
|
+
// clean 413 into an OOM'd dev server with nothing naming the
|
|
155
|
+
// cause. The declared length is the cheap check; the running one
|
|
156
|
+
// covers a chunked response that declares nothing.
|
|
157
|
+
const declared = Number(resp.headers.get('content-length'));
|
|
158
|
+
if (Number.isFinite(declared) && declared > LIMITS.maxUploadBytes) {
|
|
159
|
+
throw new RangeError(`The upstream body is ${declared} bytes; the dev server holds at most ${LIMITS.maxUploadBytes} in memory`);
|
|
160
|
+
}
|
|
161
|
+
bytes = await readCapped(resp, LIMITS.maxUploadBytes);
|
|
162
|
+
headers = Object.fromEntries(resp.headers.entries());
|
|
163
|
+
text = status >= 400 ? bytes.toString('utf8') : '';
|
|
164
|
+
} else {
|
|
165
|
+
text = await resp.text();
|
|
166
|
+
}
|
|
167
|
+
} catch (err) {
|
|
168
|
+
// Transport failure or timeout — fetch throws (TypeError 'fetch failed'
|
|
169
|
+
// with the real reason on err.cause, or a TimeoutError). Return a
|
|
170
|
+
// synthetic 502 so the dependency layer names it (dep + url + cause)
|
|
171
|
+
// rather than a bare unhandled "fetch failed".
|
|
172
|
+
const reason = (err.cause && err.cause.message) || err.message;
|
|
173
|
+
return { status: 502, body: { message: `fetch to ${url} failed: ${reason}` }, contentType: 'application/json' };
|
|
174
|
+
}
|
|
175
|
+
let body;
|
|
176
|
+
try { body = JSON.parse(text); } catch { body = text; }
|
|
177
|
+
return opts.raw ? { status, body, contentType, bytes, headers } : { status, body, contentType };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function apiFetch(path, opts = {}) {
|
|
181
|
+
return await fetchAs(authHeader, path, opts);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Cross-app request() targets /api/apps/<id>/view/_/<path>, whose auth accepts
|
|
185
|
+
// only the token/session strategies — NOT basic auth. In API-key mode the
|
|
186
|
+
// INFORMER_API_KEY Bearer already satisfies that, so reuse it; under basic
|
|
187
|
+
// auth a separate API token (INFORMER_APP_TOKEN) is required, and without one
|
|
188
|
+
// the app proxy's request() throws a pointed error instead of a bare 401.
|
|
189
|
+
// appFetch also stamps x-informer-app-depth:1 so the target runs one hop deep
|
|
190
|
+
// and enforces the same one-hop guard it does in production.
|
|
191
|
+
const appAuth = appToken
|
|
192
|
+
? `Bearer ${appToken}`
|
|
193
|
+
: (authHeader && authHeader.startsWith('Bearer ') ? authHeader : null);
|
|
194
|
+
const appFetch = appAuth
|
|
195
|
+
? async (path, opts = {}) => await fetchAs(appAuth, path, { ...opts, headers: { ...(opts.headers || {}), 'x-informer-app-depth': '1' } })
|
|
196
|
+
: null;
|
|
197
|
+
|
|
198
|
+
// notify/email — delivery is a console-logged no-op in dev, but the
|
|
199
|
+
// required-field validation mirrors prod so an app that passes here
|
|
200
|
+
// won't 500 in production.
|
|
201
|
+
const { notify, email } = buildDevMessaging(logPrefix);
|
|
202
|
+
const log = buildDevLog();
|
|
203
|
+
// Throws the written explanation a real install gives an app type without
|
|
204
|
+
// the capability, unless the mock platform opts into embeddings; then it
|
|
205
|
+
// posts to the deployed app's _embed route through apiFetch. See
|
|
206
|
+
// createDevEmbed.
|
|
207
|
+
const embed = createDevEmbed({ platform, apiFetch, appId });
|
|
208
|
+
// markdown helper — passthrough in dev (production uses `marked`)
|
|
209
|
+
const markdown = (text) => text;
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The shared bag for one invocation. The manifest is parsed per call
|
|
213
|
+
* (deps, env and channels all come from that one read) so edits to
|
|
214
|
+
* informer.yaml take effect without a dev-server restart.
|
|
215
|
+
*
|
|
216
|
+
* @param {Object} [opts]
|
|
217
|
+
* @param {Object|null} [opts.forwarding] - the request's stream forwarding
|
|
218
|
+
* services (dev-streams.js createStreamServices), so an integration
|
|
219
|
+
* request() can send a staged upload or fill a download; only `server/`
|
|
220
|
+
* routes have streams, so channel handlers leave it null
|
|
221
|
+
*/
|
|
222
|
+
async function build({ forwarding = null } = {}) {
|
|
223
|
+
const manifest = await loadManifest(projectRoot);
|
|
224
|
+
const deps = manifestBlock(manifest, 'dependencies');
|
|
225
|
+
const context = buildDevContext({ deps, apiFetch, devBindings, appFetch, forwarding });
|
|
226
|
+
const env = manifestBlock(manifest, 'env');
|
|
227
|
+
|
|
228
|
+
// emit() writes no app_event row in dev, but still relays a listed
|
|
229
|
+
// event to its `channels:` channel; broadcast() publishes a live
|
|
230
|
+
// frame to the page (see dev-channels.js).
|
|
231
|
+
const emit = createDevEmit({ channels, manifestChannels: manifestBlock(manifest, 'channels'), logPrefix: '[app-event]' });
|
|
232
|
+
const { broadcast } = channels;
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
manifest,
|
|
236
|
+
bag: { context, query, fetch: apiFetch, emit, broadcast, notify, email, embed, crypto: buildDevCrypto(), markdown, log, env, platform }
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return { query, apiFetch, build };
|
|
241
|
+
}
|