@entrinsik/vite-plugin-informer 2.6.0-beta.0 → 2.6.0-beta.2
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/bin/publish.js +0 -0
- package/index.d.ts +27 -1
- package/package.json +4 -1
- package/src/agent-dev.js +53 -19
- package/src/assemble.js +14 -5
- package/src/deploy.js +4 -1
- package/src/dev-dependencies.js +268 -13
- package/src/index.js +144 -19
- package/src/openapi-to-dts.js +168 -0
- package/src/server-routes.js +56 -10
package/bin/publish.js
CHANGED
|
File without changes
|
package/index.d.ts
CHANGED
|
@@ -1,2 +1,28 @@
|
|
|
1
1
|
import type { Plugin } from 'vite';
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Dev-only binding for a `target: app` or `target: pack` dependency slot.
|
|
5
|
+
* Points `request()` at the target app in dev. For app slots it overrides the
|
|
6
|
+
* manifest `defaultBinding` when both are present; for pack slots it is the
|
|
7
|
+
* only way to bind — the marketplace pin has no install to resolve against in
|
|
8
|
+
* dev, so point it at your locally-installed copy of the pack's app.
|
|
9
|
+
*
|
|
10
|
+
* - `app` — the target app (`owner:slug` or UUID). Powers `request()`.
|
|
11
|
+
*
|
|
12
|
+
* A bare string is shorthand for `{ app }`.
|
|
13
|
+
*/
|
|
14
|
+
export interface AppDevBinding {
|
|
15
|
+
app?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface InformerPluginOptions {
|
|
19
|
+
mock?: {
|
|
20
|
+
report?: { id?: string; name?: string };
|
|
21
|
+
theme?: 'light' | 'dark';
|
|
22
|
+
roles?: string[];
|
|
23
|
+
};
|
|
24
|
+
devBindings?: Record<string, string | AppDevBinding>;
|
|
25
|
+
proxy?: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default function informer(options?: InformerPluginOptions): Plugin;
|
package/package.json
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entrinsik/vite-plugin-informer",
|
|
3
|
-
"version": "2.6.0-beta.
|
|
3
|
+
"version": "2.6.0-beta.2",
|
|
4
4
|
"description": "Vite plugin and deploy tool for Informer App development",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"test": "node --test"
|
|
7
|
+
},
|
|
5
8
|
"repository": {
|
|
6
9
|
"type": "git",
|
|
7
10
|
"url": "https://github.com/entrinsik-org/i5.git",
|
package/src/agent-dev.js
CHANGED
|
@@ -7,6 +7,10 @@ const parseYaml = yaml.parse;
|
|
|
7
7
|
|
|
8
8
|
const MAX_STEPS = 20;
|
|
9
9
|
|
|
10
|
+
// Cap dev proxy calls at 30s so a hung upstream fails loudly. (The AI _chat
|
|
11
|
+
// stream below uses its own fetch.)
|
|
12
|
+
const FETCH_TIMEOUT_MS = 30000;
|
|
13
|
+
|
|
10
14
|
/**
|
|
11
15
|
* Read and parse informer.yaml from the project root.
|
|
12
16
|
*
|
|
@@ -25,21 +29,23 @@ async function loadInformerYaml(projectRoot) {
|
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
/**
|
|
28
|
-
* Scan the tools/
|
|
32
|
+
* Scan the tools/ and mcp/ directories for .js files that export a handler.
|
|
33
|
+
* Both share one tool namespace; agents may reference tools from either.
|
|
29
34
|
*
|
|
30
35
|
* @param {string} projectRoot
|
|
31
36
|
* @returns {Promise<Array<{ name: string, filePath: string }>>}
|
|
32
37
|
*/
|
|
33
38
|
async function scanLocalTools(projectRoot) {
|
|
34
|
-
const toolsDir = join(projectRoot, 'tools');
|
|
35
|
-
try {
|
|
36
|
-
await access(toolsDir);
|
|
37
|
-
} catch {
|
|
38
|
-
return [];
|
|
39
|
-
}
|
|
40
|
-
|
|
41
39
|
const tools = [];
|
|
42
|
-
|
|
40
|
+
for (const dirName of ['tools', 'mcp']) {
|
|
41
|
+
const toolsDir = join(projectRoot, dirName);
|
|
42
|
+
try {
|
|
43
|
+
await access(toolsDir);
|
|
44
|
+
} catch {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
await walkToolFiles(toolsDir, dirName, tools);
|
|
48
|
+
}
|
|
43
49
|
return tools;
|
|
44
50
|
}
|
|
45
51
|
|
|
@@ -60,7 +66,7 @@ async function walkToolFiles(dir, basePath, results) {
|
|
|
60
66
|
await walkToolFiles(full, childPath, results);
|
|
61
67
|
} else if (item.endsWith('.js')) {
|
|
62
68
|
const name = childPath
|
|
63
|
-
.replace(/^tools\//, '')
|
|
69
|
+
.replace(/^(tools|mcp)\//, '')
|
|
64
70
|
.replace(/\.js$/, '')
|
|
65
71
|
.replace(/\//g, '_');
|
|
66
72
|
results.push({ name, filePath: full });
|
|
@@ -145,7 +151,7 @@ async function readSSE(response) {
|
|
|
145
151
|
* @param {Object} opts - Configuration
|
|
146
152
|
* @returns {Function} Connect middleware
|
|
147
153
|
*/
|
|
148
|
-
export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot }) {
|
|
154
|
+
export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings, appToken }) {
|
|
149
155
|
|
|
150
156
|
// query() — proxies to the workspace _sql endpoint (same as server-routes.js)
|
|
151
157
|
async function query(sql, params) {
|
|
@@ -169,29 +175,57 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
|
|
|
169
175
|
}
|
|
170
176
|
|
|
171
177
|
// fetch() — proxies API calls to the Informer server (same as server-routes.js)
|
|
172
|
-
async function
|
|
178
|
+
async function fetchAs(auth, path, opts = {}) {
|
|
173
179
|
const method = (opts.method || 'GET').toUpperCase();
|
|
174
180
|
// Same strict canonicalization as prod (app-sandbox.js normalizeFetchPath).
|
|
175
181
|
const apiPath = normalizeFetchPath(path);
|
|
176
182
|
if (!apiPath) {
|
|
177
|
-
return { status: 400, body: { error: `Invalid fetch path: ${String(path).slice(0, 80)}` } };
|
|
183
|
+
return { status: 400, body: { error: `Invalid fetch path: ${String(path).slice(0, 80)}` }, contentType: 'application/json' };
|
|
178
184
|
}
|
|
179
185
|
const url = `${serverOrigin}${apiPath}`;
|
|
180
186
|
const fetchOpts = {
|
|
181
187
|
method,
|
|
182
|
-
headers: { Authorization:
|
|
188
|
+
headers: { Authorization: auth, 'Content-Type': 'application/json', ...(opts.headers || {}) }
|
|
183
189
|
};
|
|
184
190
|
|
|
185
|
-
if (opts.body && ['POST', 'PUT', 'PATCH'].includes(method)) {
|
|
191
|
+
if (opts.body && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
|
186
192
|
fetchOpts.body = JSON.stringify(opts.body);
|
|
187
193
|
}
|
|
188
194
|
|
|
189
|
-
|
|
195
|
+
// Read the stream once — `.json()` consumes it, so a `.text()` fallback
|
|
196
|
+
// would throw "Body is unusable" on any non-JSON response. Transport
|
|
197
|
+
// failure/timeout → synthetic 502 so the dep layer names it. See server-routes.js.
|
|
198
|
+
let status, contentType, text;
|
|
199
|
+
try {
|
|
200
|
+
const resp = await globalThis.fetch(url, { ...fetchOpts, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
201
|
+
status = resp.status;
|
|
202
|
+
contentType = resp.headers.get('content-type') || '';
|
|
203
|
+
text = await resp.text();
|
|
204
|
+
} catch (err) {
|
|
205
|
+
const reason = (err.cause && err.cause.message) || err.message;
|
|
206
|
+
return { status: 502, body: { message: `fetch to ${url} failed: ${reason}` }, contentType: 'application/json' };
|
|
207
|
+
}
|
|
190
208
|
let body;
|
|
191
|
-
try { body =
|
|
192
|
-
return { status
|
|
209
|
+
try { body = JSON.parse(text); } catch { body = text; }
|
|
210
|
+
return { status, body, contentType };
|
|
193
211
|
}
|
|
194
212
|
|
|
213
|
+
async function apiFetch(path, opts = {}) {
|
|
214
|
+
return await fetchAs(authHeader, path, opts);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Cross-app request() hits the target's /view/_/ dispatch, which accepts only
|
|
218
|
+
// the token/session strategies — not basic auth. In API-key mode the
|
|
219
|
+
// INFORMER_API_KEY Bearer is reused; under basic auth INFORMER_APP_TOKEN is
|
|
220
|
+
// required. appFetch stamps x-informer-app-depth:1 for the one-hop guard.
|
|
221
|
+
// See server-routes.js.
|
|
222
|
+
const appAuth = appToken
|
|
223
|
+
? `Bearer ${appToken}`
|
|
224
|
+
: (authHeader && authHeader.startsWith('Bearer ') ? authHeader : null);
|
|
225
|
+
const appFetch = appAuth
|
|
226
|
+
? async (path, opts = {}) => await fetchAs(appAuth, path, { ...opts, headers: { ...(opts.headers || {}), 'x-informer-app-depth': '1' } })
|
|
227
|
+
: null;
|
|
228
|
+
|
|
195
229
|
// emit() — no-op in dev mode (logs to console)
|
|
196
230
|
function emit(event, payload) {
|
|
197
231
|
console.log(`[agent-dev] emit("${event}",`, JSON.stringify(payload), ')');
|
|
@@ -287,7 +321,7 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
|
|
|
287
321
|
// `context.<slot>.<method>(...)` and `env` work locally and match
|
|
288
322
|
// the prod sandbox bag.
|
|
289
323
|
const deps = await loadDependencies(projectRoot);
|
|
290
|
-
const context = buildDevContext({ deps, apiFetch });
|
|
324
|
+
const context = buildDevContext({ deps, apiFetch, devBindings, appFetch });
|
|
291
325
|
const env = await loadAppEnv(projectRoot);
|
|
292
326
|
|
|
293
327
|
// Load tool handlers via ssrLoadModule
|
package/src/assemble.js
CHANGED
|
@@ -15,7 +15,15 @@ import { join, relative, posix } from 'node:path';
|
|
|
15
15
|
* (server/…, tools/…) — matching how an app's library is structured in Informer.
|
|
16
16
|
*/
|
|
17
17
|
const ROOT_CONFIG_FILES = ['informer.yaml', 'data-access.yaml'];
|
|
18
|
-
const SOURCE_DIRS = ['migrations', 'tools', 'server', 'webhooks'];
|
|
18
|
+
const SOURCE_DIRS = ['migrations', 'tools', 'mcp', 'server', 'webhooks', 'lib', 'shared'];
|
|
19
|
+
|
|
20
|
+
// Entries never worth shipping in an app's server-side library: OS/editor
|
|
21
|
+
// dotfiles (.DS_Store, .env), nested dependency trees, and test files. Applied
|
|
22
|
+
// only to the source-tree dirs (lib/, shared/, etc.) — which use generic names
|
|
23
|
+
// and are walked raw from the project root — not to the Vite dist/ output,
|
|
24
|
+
// where a dot-directory (e.g. .well-known/) can be a real asset.
|
|
25
|
+
const isExcludedSourceEntry = (name) =>
|
|
26
|
+
name === 'node_modules' || name.startsWith('.') || name.endsWith('.test.js');
|
|
19
27
|
|
|
20
28
|
async function exists(path) {
|
|
21
29
|
try {
|
|
@@ -27,14 +35,15 @@ async function exists(path) {
|
|
|
27
35
|
}
|
|
28
36
|
|
|
29
37
|
/** Recursively walk a directory, returning absolute file paths (not dirs). */
|
|
30
|
-
async function walkDir(dir) {
|
|
38
|
+
async function walkDir(dir, exclude) {
|
|
31
39
|
const results = [];
|
|
32
40
|
const items = await readdir(dir);
|
|
33
41
|
for (const item of items) {
|
|
42
|
+
if (exclude && exclude(item)) continue;
|
|
34
43
|
const full = join(dir, item);
|
|
35
44
|
const s = await stat(full);
|
|
36
45
|
if (s.isDirectory()) {
|
|
37
|
-
results.push(...await walkDir(full));
|
|
46
|
+
results.push(...await walkDir(full, exclude));
|
|
38
47
|
} else {
|
|
39
48
|
results.push(full);
|
|
40
49
|
}
|
|
@@ -72,11 +81,11 @@ export async function collectAppFiles({ distDir, projectRoot }) {
|
|
|
72
81
|
if (await exists(abs)) files.push({ abs, rel: name });
|
|
73
82
|
}
|
|
74
83
|
|
|
75
|
-
// source trees -> keep their prefix (server/…, migrations/…,
|
|
84
|
+
// source trees -> keep their prefix (server/…, migrations/…, lib/…, shared/…)
|
|
76
85
|
for (const dir of SOURCE_DIRS) {
|
|
77
86
|
const root = join(projectRoot, dir);
|
|
78
87
|
if (!await exists(root)) continue;
|
|
79
|
-
for (const abs of await walkDir(root)) {
|
|
88
|
+
for (const abs of await walkDir(root, isExcludedSourceEntry)) {
|
|
80
89
|
files.push({ abs, rel: toLibraryPath(relative(projectRoot, abs)) });
|
|
81
90
|
}
|
|
82
91
|
}
|
package/src/deploy.js
CHANGED
|
@@ -105,7 +105,7 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
|
|
|
105
105
|
await api.post(`${entityPath}/files/_clear`);
|
|
106
106
|
|
|
107
107
|
// 6. Upload the app-library file set: dist output at the library root, plus
|
|
108
|
-
// informer.yaml / data-access.yaml and the server/tools/migrations/webhooks
|
|
108
|
+
// informer.yaml / data-access.yaml and the server/tools/mcp/migrations/webhooks
|
|
109
109
|
// source trees. Sourced from the shared collectAppFiles() so a deploy and a
|
|
110
110
|
// marketplace publish package byte-identical contents.
|
|
111
111
|
console.log('Uploading files...');
|
|
@@ -160,6 +160,9 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
|
|
|
160
160
|
if (result.tools && result.tools.length > 0) {
|
|
161
161
|
console.log(` Registered ${result.tools.length} tool(s): ${result.tools.join(', ')}`);
|
|
162
162
|
}
|
|
163
|
+
if (result.mcpTools && result.mcpTools.length > 0) {
|
|
164
|
+
console.log(` Registered ${result.mcpTools.length} MCP tool(s): ${result.mcpTools.join(', ')}`);
|
|
165
|
+
}
|
|
163
166
|
if (result.agents && result.agents.length > 0) {
|
|
164
167
|
console.log(` Deployed ${result.agents.length} agent(s): ${result.agents.join(', ')}`);
|
|
165
168
|
}
|
package/src/dev-dependencies.js
CHANGED
|
@@ -150,16 +150,28 @@ export function buildDevMessaging(logPrefix = '[app]') {
|
|
|
150
150
|
// reject the same shapes with the same wording. Keep these in lockstep.
|
|
151
151
|
const DEPENDENCY_NAME_PATTERN = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$/;
|
|
152
152
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
153
|
-
const VALID_TARGETS = new Set(['dataset', 'query', 'datasource', 'integration']);
|
|
153
|
+
const VALID_TARGETS = new Set(['dataset', 'query', 'datasource', 'integration', 'app', 'pack']);
|
|
154
154
|
const VALID_RUN_AS = new Set(['user', 'owner']);
|
|
155
155
|
|
|
156
|
+
// Targets that accept `defaultBinding:` in the manifest — mirrors
|
|
157
|
+
// DEFAULT_BINDING_LOOKUP in deploy.js. All resolve the target under read_access.
|
|
158
|
+
// `pack` is deliberately absent: its identity is the marketplace pin
|
|
159
|
+
// (`pack:` + `requires:`), and the installer consents per instance.
|
|
160
|
+
const DEFAULT_BINDABLE_TARGETS = new Set(['dataset', 'query', 'datasource', 'integration', 'app']);
|
|
161
|
+
|
|
156
162
|
const METHOD_SURFACE = {
|
|
157
163
|
dataset: ['search', 'fields'],
|
|
158
164
|
query: ['execute'],
|
|
159
165
|
datasource: ['query'],
|
|
160
|
-
integration: ['request']
|
|
166
|
+
integration: ['request'],
|
|
167
|
+
app: ['request'],
|
|
168
|
+
pack: ['request']
|
|
161
169
|
};
|
|
162
170
|
|
|
171
|
+
// Cross-app request() method allow-list — mirrors REQUEST_METHODS in
|
|
172
|
+
// entity-type/app.js so dev rejects the same shapes prod 400s.
|
|
173
|
+
const REQUEST_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
174
|
+
|
|
163
175
|
/**
|
|
164
176
|
* Load the `dependencies:` map from informer.yaml. Returns `{}` if the file
|
|
165
177
|
* is missing or the section is absent — both are valid (an app may have no
|
|
@@ -237,8 +249,17 @@ export function validateDependencies(deps) {
|
|
|
237
249
|
if (!VALID_RUN_AS.has(runAs)) {
|
|
238
250
|
errors.push(`dependency "${name}": runAs must be 'user' or 'owner' (got "${runAs}")`);
|
|
239
251
|
}
|
|
252
|
+
// `target: pack` pins marketplace identity at the top level of the
|
|
253
|
+
// declaration — same check (and wording) as deploy.js.
|
|
254
|
+
if (decl.target === 'pack' && (typeof decl.pack !== 'string' || typeof decl.requires !== 'string')) {
|
|
255
|
+
errors.push(`dependency "${name}" (pack) requires "pack: <marketplace slug>" and "requires: <semver range>"`);
|
|
256
|
+
}
|
|
240
257
|
if (decl.defaultBinding != null) {
|
|
241
|
-
if (
|
|
258
|
+
if (!DEFAULT_BINDABLE_TARGETS.has(decl.target)) {
|
|
259
|
+
// Same rejection the deploy performs — surface it at dev boot
|
|
260
|
+
// rather than letting the deploy be the first to say no.
|
|
261
|
+
errors.push(`dependency "${name}": target "${decl.target}" does not support defaultBinding (bind it via PUT /api/apps/<id>/dependencies/${name})`);
|
|
262
|
+
} else if (typeof decl.defaultBinding !== 'string') {
|
|
242
263
|
errors.push(`dependency "${name}": defaultBinding must be a string UUID`);
|
|
243
264
|
} else if (!UUID_PATTERN.test(decl.defaultBinding)) {
|
|
244
265
|
errors.push(`dependency "${name}": defaultBinding must be a UUID (got "${decl.defaultBinding}")`);
|
|
@@ -248,6 +269,71 @@ export function validateDependencies(deps) {
|
|
|
248
269
|
return errors;
|
|
249
270
|
}
|
|
250
271
|
|
|
272
|
+
/**
|
|
273
|
+
* Validate the plugin's `devBindings` option against the declared dependencies.
|
|
274
|
+
* Returns human-readable error strings (empty when valid). Runs at dev boot so a
|
|
275
|
+
* typo'd slot ("kanbann") is caught loudly rather than silently ignored until
|
|
276
|
+
* the first call reports the slot as unbound — telling the developer to add the
|
|
277
|
+
* thing they think they already added.
|
|
278
|
+
*
|
|
279
|
+
* A binding key must name a declared `target: app` or `target: pack`
|
|
280
|
+
* dependency; its value must be a non-empty `owner:slug` string (shorthand for
|
|
281
|
+
* `{ app }`) or an object with a non-empty `app` (the target app, for
|
|
282
|
+
* request()). For a pack slot the value points at your locally-installed copy
|
|
283
|
+
* of the pack's app — dev has no marketplace install to resolve the pin
|
|
284
|
+
* against, so the developer says where it lives.
|
|
285
|
+
*
|
|
286
|
+
* @param {Object} deps - The raw `dependencies:` object from informer.yaml
|
|
287
|
+
* @param {Object} devBindings - The plugin's `devBindings` option
|
|
288
|
+
* @returns {string[]} Error messages, one per problem
|
|
289
|
+
*/
|
|
290
|
+
export function validateDevBindings(deps, devBindings) {
|
|
291
|
+
const errors = [];
|
|
292
|
+
const declared = (deps && typeof deps === 'object') ? deps : {};
|
|
293
|
+
for (const [name, binding] of Object.entries(devBindings || {})) {
|
|
294
|
+
const decl = declared[name];
|
|
295
|
+
if (!decl || typeof decl !== 'object') {
|
|
296
|
+
errors.push(`devBindings."${name}": no dependency "${name}" is declared in informer.yaml`);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (decl.target !== 'app' && decl.target !== 'pack') {
|
|
300
|
+
errors.push(`devBindings."${name}": only "target: app" and "target: pack" dependencies take a devBinding (got "${decl.target}")`);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (typeof binding === 'string') {
|
|
304
|
+
if (!binding.trim()) {
|
|
305
|
+
errors.push(`devBindings."${name}": binding string must be a non-empty "owner:slug"`);
|
|
306
|
+
}
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (!binding || typeof binding !== 'object') {
|
|
310
|
+
errors.push(`devBindings."${name}": must be an "owner:slug" string or an object { app }`);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (binding.app == null) {
|
|
314
|
+
errors.push(`devBindings."${name}": needs "app" (the target app, for request())`);
|
|
315
|
+
} else if (typeof binding.app !== 'string' || !binding.app.trim()) {
|
|
316
|
+
errors.push(`devBindings."${name}".app: must be a non-empty string`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return errors;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Normalize a devBindings entry to `{ app }`. A bare string is shorthand for
|
|
324
|
+
* `{ app }`. Every consumer of a binding — the `.d.ts` generator and the dev
|
|
325
|
+
* proxy alike — resolves `.app` from this result, never from the raw entry, so
|
|
326
|
+
* the string and object forms behave identically: a shorthand binding gets both
|
|
327
|
+
* generated types and a working request().
|
|
328
|
+
*
|
|
329
|
+
* @param {string|{app?: string}|null|undefined} binding
|
|
330
|
+
* @returns {{ app: string|null }}
|
|
331
|
+
*/
|
|
332
|
+
export function resolveAppBinding(binding) {
|
|
333
|
+
if (typeof binding === 'string') return { app: binding };
|
|
334
|
+
return { app: (binding && binding.app) || null };
|
|
335
|
+
}
|
|
336
|
+
|
|
251
337
|
/**
|
|
252
338
|
* Build the dev-mode `context` object passed to server route handlers, so
|
|
253
339
|
* `await context.myDep.method(...)` works the same locally as it does after
|
|
@@ -274,13 +360,31 @@ export function validateDependencies(deps) {
|
|
|
274
360
|
* @returns {Object} An object keyed by dependency name, values are typed
|
|
275
361
|
* proxies with methods matching the target's production method surface.
|
|
276
362
|
*/
|
|
277
|
-
export function buildDevContext({ deps, apiFetch }) {
|
|
363
|
+
export function buildDevContext({ deps, apiFetch, devBindings = {}, appFetch = null }) {
|
|
278
364
|
const context = {};
|
|
279
365
|
for (const [name, decl] of Object.entries(deps || {})) {
|
|
280
366
|
if (!decl || typeof decl !== 'object') continue;
|
|
281
367
|
const target = decl.target;
|
|
282
368
|
if (!VALID_TARGETS.has(target)) continue;
|
|
283
369
|
|
|
370
|
+
// App and pack slots bind from the plugin's `devBindings` (a human
|
|
371
|
+
// `owner:slug` pointing request() at the target app). App slots fall
|
|
372
|
+
// back to the manifest `defaultBinding` UUID; pack slots have no
|
|
373
|
+
// fallback — the pin resolves via marketplace installs, which dev
|
|
374
|
+
// doesn't have, so the devBinding names the locally-installed app.
|
|
375
|
+
// request() is the only surface either way.
|
|
376
|
+
if (target === 'app' || target === 'pack') {
|
|
377
|
+
const fallback = (target === 'app'
|
|
378
|
+
&& typeof decl.defaultBinding === 'string' && UUID_PATTERN.test(decl.defaultBinding))
|
|
379
|
+
? decl.defaultBinding
|
|
380
|
+
: null;
|
|
381
|
+
const binding = devBindings[name] != null ? devBindings[name] : fallback;
|
|
382
|
+
context[name] = binding
|
|
383
|
+
? makeAppDevProxy({ name, binding, appFetch, kind: target })
|
|
384
|
+
: makeUnboundDevProxy({ name, target });
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
|
|
284
388
|
const targetId = (typeof decl.defaultBinding === 'string' && UUID_PATTERN.test(decl.defaultBinding))
|
|
285
389
|
? decl.defaultBinding
|
|
286
390
|
: null;
|
|
@@ -292,6 +396,101 @@ export function buildDevContext({ deps, apiFetch }) {
|
|
|
292
396
|
return context;
|
|
293
397
|
}
|
|
294
398
|
|
|
399
|
+
/**
|
|
400
|
+
* Dev proxy for a `target: app` or `target: pack` slot. `request()` is the
|
|
401
|
+
* only surface. Pack slots reuse this wholesale — in production the pack
|
|
402
|
+
* driver resolves its marketplace pin and then delegates the runtime to the
|
|
403
|
+
* app driver, and the devBinding IS that resolution done by hand. The prod
|
|
404
|
+
* version gate (pack_dependency_out_of_range) is not emulated: dev has no
|
|
405
|
+
* pack_install to read a version from.
|
|
406
|
+
*
|
|
407
|
+
* Production runs `request()` through the target's own /view/_/ dispatch, and
|
|
408
|
+
* dev injects into the same route:
|
|
409
|
+
*
|
|
410
|
+
* request(payload) -> /api/apps/<app>/view/_/<url>
|
|
411
|
+
* The same route prod injects into, so the target's own handlers, authz
|
|
412
|
+
* and roles run. This dispatch accepts only the token/session auth
|
|
413
|
+
* strategies (not basic auth), so it uses `appFetch`: an INFORMER_APP_TOKEN
|
|
414
|
+
* Bearer, or the INFORMER_API_KEY Bearer reused when the plugin already
|
|
415
|
+
* runs in API-key mode. `appFetch` stamps `x-informer-app-depth: 1`, so the
|
|
416
|
+
* target runs one hop deep and enforces the same one-hop guard as prod.
|
|
417
|
+
* Non-JSON success responses follow the prod envelope contract: text/HTML
|
|
418
|
+
* returns a text envelope; binary is rejected (dev can't emulate it yet).
|
|
419
|
+
*
|
|
420
|
+
* @param {Object} args
|
|
421
|
+
* @param {string} args.name - dependency slot name
|
|
422
|
+
* @param {string|{app?: string}} args.binding - devBindings entry (or the
|
|
423
|
+
* manifest defaultBinding UUID). A bare string is shorthand for `{ app }`.
|
|
424
|
+
* @param {Function|null} args.appFetch - token-authed fetch, or null when
|
|
425
|
+
* INFORMER_APP_TOKEN is unset.
|
|
426
|
+
* @param {'app'|'pack'} [args.kind] - slot flavor, for error labels and the
|
|
427
|
+
* structured resourceType guest code branches on.
|
|
428
|
+
*/
|
|
429
|
+
function makeAppDevProxy({ name, binding, appFetch, kind = 'app' }) {
|
|
430
|
+
const { app } = resolveAppBinding(binding);
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
async request(payload) {
|
|
434
|
+
if (!app) {
|
|
435
|
+
throw new Error(
|
|
436
|
+
`Dependency "${name}" (${kind}): dev request() needs the target app — set devBindings.${name}.app (e.g. 'admin:kanban')`
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
if (!appFetch) {
|
|
440
|
+
throw new Error(
|
|
441
|
+
`Dependency "${name}" (${kind}): dev request() needs a Bearer credential — the target's /view/_/ dispatch does not accept basic auth. Run in API-key mode (INFORMER_API_KEY), or create a token (Admin → Tokens, or POST /api/tokens) and set INFORMER_APP_TOKEN in .env`
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
const { method = 'GET', url, params, data } = payload || {};
|
|
445
|
+
const httpMethod = String(method).toUpperCase();
|
|
446
|
+
if (!REQUEST_METHODS.includes(httpMethod)) {
|
|
447
|
+
throw new Error(`Dependency "${name}" (${kind}): unsupported method "${method}"`);
|
|
448
|
+
}
|
|
449
|
+
if (!url || typeof url !== 'string') {
|
|
450
|
+
throw new Error(`Dependency "${name}" (${kind}): request({ url }) requires the target route path`);
|
|
451
|
+
}
|
|
452
|
+
const path = url.replace(/^\/+/, '');
|
|
453
|
+
const search = params ? `?${new URLSearchParams(params).toString()}` : '';
|
|
454
|
+
// Path-traversal guard (mirrors entity-type/app.js): the path is
|
|
455
|
+
// interpolated into the target's /view/_/ dispatch and fetch resolves
|
|
456
|
+
// dot segments before the request lands, so `../../x` would escape the
|
|
457
|
+
// dispatch and reach arbitrary /api routes. Resolve it the way fetch
|
|
458
|
+
// will and confirm it still lives under the dispatch prefix.
|
|
459
|
+
const targetPrefix = `/api/apps/${encodeURIComponent(app)}/view/_/`;
|
|
460
|
+
let resolvedPath;
|
|
461
|
+
try {
|
|
462
|
+
resolvedPath = new URL(`${targetPrefix}${path}${search}`, 'http://localhost').pathname;
|
|
463
|
+
} catch {
|
|
464
|
+
throw new Error(`Dependency "${name}" (${kind}): request({ url }) is malformed`);
|
|
465
|
+
}
|
|
466
|
+
if (!resolvedPath.startsWith(targetPrefix)) {
|
|
467
|
+
throw new Error(`Dependency "${name}" (${kind}): request({ url }) must not escape the target app with ".." path segments`);
|
|
468
|
+
}
|
|
469
|
+
const { status, body, contentType } = await appFetch(
|
|
470
|
+
`apps/${encodeURIComponent(app)}/view/_/${path}${search}`,
|
|
471
|
+
{ method: httpMethod, body: data }
|
|
472
|
+
);
|
|
473
|
+
if (status >= 400) {
|
|
474
|
+
throw dependencyCallError(name, kind, status, body);
|
|
475
|
+
}
|
|
476
|
+
// Success-envelope contract mirrors entity-type/app.js: JSON → parsed
|
|
477
|
+
// body; non-JSON text/HTML → text envelope; binary → not emulatable
|
|
478
|
+
// via resp.text() without corruption, so fail loudly rather than
|
|
479
|
+
// silently hand back mangled bytes.
|
|
480
|
+
if (isBinaryContentType(contentType)) {
|
|
481
|
+
throw new Error(
|
|
482
|
+
`Dependency "${name}" (${kind}): the dev proxy can't return binary responses yet (upstream content-type "${contentType}"). Test binary endpoints against a deployed build.`
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
// (An empty content-type gets the text envelope too, matching prod.)
|
|
486
|
+
if (!(contentType || '').includes('json')) {
|
|
487
|
+
return { status, body, contentType: contentType || '', headers: { 'content-type': contentType || '' } };
|
|
488
|
+
}
|
|
489
|
+
return body;
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
295
494
|
function makeDevProxy({ name, target, targetId, apiFetch }) {
|
|
296
495
|
switch (target) {
|
|
297
496
|
case 'dataset':
|
|
@@ -335,29 +534,85 @@ function makeDevProxy({ name, target, targetId, apiFetch }) {
|
|
|
335
534
|
*/
|
|
336
535
|
function makeUnboundDevProxy({ name, target }) {
|
|
337
536
|
const methods = METHOD_SURFACE[target] || [];
|
|
537
|
+
// App slots bind via devBindings (a dev-local owner:slug) or a manifest
|
|
538
|
+
// defaultBinding UUID; pack slots ONLY via devBindings (the pin has no
|
|
539
|
+
// marketplace install to resolve against in dev). Point at whichever the
|
|
540
|
+
// reader is likelier to want.
|
|
541
|
+
const hint = target === 'app'
|
|
542
|
+
? `add \`devBindings: { ${name}: { app: '<owner:slug>' } }\` to the informer() plugin options in vite.config.js, or \`defaultBinding: <uuid>\` to its entry in informer.yaml`
|
|
543
|
+
: target === 'pack'
|
|
544
|
+
? `add \`devBindings: { ${name}: { app: '<owner:slug>' } }\` to the informer() plugin options in vite.config.js, pointing at your locally-installed copy of the pack`
|
|
545
|
+
: 'add `defaultBinding: <uuid>` to its entry in informer.yaml';
|
|
338
546
|
const proxy = {};
|
|
339
547
|
for (const method of methods) {
|
|
340
548
|
proxy[method] = async () => {
|
|
341
|
-
|
|
342
|
-
|
|
549
|
+
// Mirror the prod unbound-proxy contract (unbound-proxy.js): a 422
|
|
550
|
+
// with data.errorCode 'dependency_unbound', so guest code doing
|
|
551
|
+
// `catch (err) { if (err.data?.errorCode === 'dependency_unbound') }`
|
|
552
|
+
// fires in dev exactly as it does in production.
|
|
553
|
+
throw dependencyError(
|
|
554
|
+
`Dependency "${name}" is not bound in dev — ${hint}`,
|
|
555
|
+
422,
|
|
556
|
+
{ errorCode: 'dependency_unbound', dependencyName: name, resourceType: target }
|
|
343
557
|
);
|
|
344
558
|
};
|
|
345
559
|
}
|
|
346
560
|
return proxy;
|
|
347
561
|
}
|
|
348
562
|
|
|
563
|
+
/**
|
|
564
|
+
* Build an Error carrying the same shape production reconstructs across the V8
|
|
565
|
+
* isolate boundary (see the invoke-script wrapper in app-sandbox.js): a numeric
|
|
566
|
+
* `statusCode`, a structured `data` (with `errorCode`), and the boom-style
|
|
567
|
+
* `output.payload.data` mirror. Guest code branches on `err.statusCode` and
|
|
568
|
+
* `err.data.errorCode`, so dev must attach the same fields or those branches
|
|
569
|
+
* silently never fire locally — the exact dev/prod drift this feature removes.
|
|
570
|
+
*/
|
|
571
|
+
function dependencyError(message, statusCode, data) {
|
|
572
|
+
const err = new Error(message);
|
|
573
|
+
err.statusCode = statusCode;
|
|
574
|
+
err.data = data;
|
|
575
|
+
err.output = { statusCode, payload: { statusCode, message, data } };
|
|
576
|
+
return err;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Shape a >=400 dev-proxy response into the prod boundary-error contract
|
|
581
|
+
* (unwrapInject in inject-helpers.js): the dependency name is baked into the
|
|
582
|
+
* message and `data` carries { errorCode, dependencyName, resourceType,
|
|
583
|
+
* upstreamStatus } — propagating any structured `errorCode` the upstream boom
|
|
584
|
+
* body already carried (e.g. a target app's own `dependency_unbound`).
|
|
585
|
+
*/
|
|
586
|
+
function dependencyCallError(depName, resourceType, status, responseBody) {
|
|
587
|
+
const isObj = responseBody && typeof responseBody === 'object';
|
|
588
|
+
const message = isObj && responseBody.message ? responseBody.message : String(status);
|
|
589
|
+
const data = {
|
|
590
|
+
errorCode: (isObj && responseBody.data && responseBody.data.errorCode) || null,
|
|
591
|
+
dependencyName: depName,
|
|
592
|
+
resourceType,
|
|
593
|
+
upstreamStatus: status
|
|
594
|
+
};
|
|
595
|
+
const err = dependencyError(`Dependency "${depName}" (${resourceType}): ${message}`, status, data);
|
|
596
|
+
err.body = responseBody;
|
|
597
|
+
return err;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Local mirror of inject-helpers.js isBinaryContentType (can't import across the
|
|
602
|
+
* package boundary). Text, JSON and SSE are safe to carry as strings; anything
|
|
603
|
+
* else is bytes that `resp.text()` would corrupt.
|
|
604
|
+
*/
|
|
605
|
+
function isBinaryContentType(contentType) {
|
|
606
|
+
const ct = (contentType || '').toLowerCase();
|
|
607
|
+
return Boolean(ct) && !ct.startsWith('text/') && !ct.includes('json') && !ct.includes('event-stream');
|
|
608
|
+
}
|
|
609
|
+
|
|
349
610
|
async function devCall(apiFetch, method, path, body, depName, resourceType) {
|
|
350
611
|
const opts = { method };
|
|
351
612
|
if (body !== null && body !== undefined) opts.body = body;
|
|
352
613
|
const { status, body: responseBody } = await apiFetch(path, opts);
|
|
353
614
|
if (status >= 400) {
|
|
354
|
-
|
|
355
|
-
? responseBody.message
|
|
356
|
-
: String(status);
|
|
357
|
-
const err = new Error(`Dependency "${depName}" (${resourceType}) call failed: ${message}`);
|
|
358
|
-
err.status = status;
|
|
359
|
-
err.body = responseBody;
|
|
360
|
-
throw err;
|
|
615
|
+
throw dependencyCallError(depName, resourceType, status, responseBody);
|
|
361
616
|
}
|
|
362
617
|
return responseBody;
|
|
363
618
|
}
|
package/src/index.js
CHANGED
|
@@ -1,13 +1,39 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
|
-
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
3
3
|
import { resolve } from 'node:path';
|
|
4
4
|
import { createClient } from './client.js';
|
|
5
|
-
import { loadDependencies, validateDependencies } from './dev-dependencies.js';
|
|
5
|
+
import { loadDependencies, validateDependencies, validateDevBindings, resolveAppBinding } from './dev-dependencies.js';
|
|
6
|
+
import { buildDeclarations } from './openapi-to-dts.js';
|
|
6
7
|
import { loadEnv, envWritePath } from './env.js';
|
|
7
8
|
import { createMiddleware as createServerRoutes } from './server-routes.js';
|
|
8
9
|
import { createAgentMiddleware } from './agent-dev.js';
|
|
9
10
|
import { init, migrate } from './workspace.js';
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Write generated app-dependency type declarations under .informer/ and keep the
|
|
14
|
+
* directory out of git (idempotent).
|
|
15
|
+
*/
|
|
16
|
+
async function writeAppDepTypes (projectRoot, dts) {
|
|
17
|
+
const dir = resolve(projectRoot, '.informer');
|
|
18
|
+
await mkdir(dir, { recursive: true });
|
|
19
|
+
await writeFile(resolve(dir, 'app-deps.d.ts'), dts, 'utf8');
|
|
20
|
+
|
|
21
|
+
const giPath = resolve(projectRoot, '.gitignore');
|
|
22
|
+
let gi = '';
|
|
23
|
+
try {
|
|
24
|
+
gi = await readFile(giPath, 'utf8');
|
|
25
|
+
} catch (err) {
|
|
26
|
+
// Only a missing file is benign; a real read error (EACCES…) must not be
|
|
27
|
+
// swallowed, or we'd clobber the user's .gitignore with a single line.
|
|
28
|
+
if (err.code !== 'ENOENT') throw err;
|
|
29
|
+
}
|
|
30
|
+
const ignored = gi.split(/\r?\n/).some((l) => l.trim() === '.informer/' || l.trim() === '.informer');
|
|
31
|
+
if (!ignored) {
|
|
32
|
+
const sep = gi && !gi.endsWith('\n') ? '\n' : '';
|
|
33
|
+
await writeFile(giPath, `${gi}${sep}.informer/\n`, 'utf8');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
11
37
|
/**
|
|
12
38
|
* Vite plugin for local Informer App development.
|
|
13
39
|
*
|
|
@@ -16,7 +42,14 @@ import { init, migrate } from './workspace.js';
|
|
|
16
42
|
* - Injects window.__INFORMER__ context mock in dev mode
|
|
17
43
|
* - Sets base to './' so built assets use relative paths
|
|
18
44
|
*
|
|
19
|
-
* @param {
|
|
45
|
+
* @param {Object} [options]
|
|
46
|
+
* @param {{ report?: object, theme?: 'light'|'dark', roles?: string[] }} [options.mock]
|
|
47
|
+
* window.__INFORMER__ mock injected in dev.
|
|
48
|
+
* @param {Object} [options.devBindings] - dev bindings for `target: app` and
|
|
49
|
+
* `target: pack` deps. App slots can't be defaultBound in the manifest; pack
|
|
50
|
+
* slots resolve their marketplace pin via installs dev doesn't have, so the
|
|
51
|
+
* binding names the locally-installed app. See AppDevBinding in index.d.ts.
|
|
52
|
+
* @param {Object} [options.proxy] - extra Vite proxy options merged onto /api.
|
|
20
53
|
* @returns {import('vite').Plugin}
|
|
21
54
|
*/
|
|
22
55
|
export default function informer(options = {}) {
|
|
@@ -24,6 +57,11 @@ export default function informer(options = {}) {
|
|
|
24
57
|
let authHeader = null;
|
|
25
58
|
let serverOrigin = null;
|
|
26
59
|
let devWorkspaceId = null;
|
|
60
|
+
// Optional separate credential for cross-app request() — the target's
|
|
61
|
+
// /view/_/ dispatch accepts only the token/session strategies, not basic
|
|
62
|
+
// auth. In API-key mode the INFORMER_API_KEY Bearer is reused instead, so
|
|
63
|
+
// this is only needed under basic auth (INFORMER_USER/PASS).
|
|
64
|
+
let appToken = null;
|
|
27
65
|
let activeMode = null;
|
|
28
66
|
|
|
29
67
|
return {
|
|
@@ -46,6 +84,7 @@ export default function informer(options = {}) {
|
|
|
46
84
|
const pass = process.env.INFORMER_PASS;
|
|
47
85
|
|
|
48
86
|
devWorkspaceId = process.env.INFORMER_DEV_WORKSPACE || null;
|
|
87
|
+
appToken = process.env.INFORMER_APP_TOKEN || null;
|
|
49
88
|
|
|
50
89
|
if (baseUrl) {
|
|
51
90
|
serverOrigin = baseUrl.replace(/\/+$/, '');
|
|
@@ -77,15 +116,18 @@ export default function informer(options = {}) {
|
|
|
77
116
|
const projectRoot = process.cwd();
|
|
78
117
|
const migrationsDir = resolve(projectRoot, 'migrations');
|
|
79
118
|
|
|
119
|
+
// One API client for the whole dev-server setup — createClient just
|
|
120
|
+
// builds an auth header (no I/O), so it's hoisted out of the two
|
|
121
|
+
// branches that each used to rebuild an identical one.
|
|
122
|
+
const api = createClient({
|
|
123
|
+
baseUrl: serverOrigin,
|
|
124
|
+
apiKey: process.env.INFORMER_API_KEY,
|
|
125
|
+
user: process.env.INFORMER_USER,
|
|
126
|
+
pass: process.env.INFORMER_PASS
|
|
127
|
+
});
|
|
128
|
+
|
|
80
129
|
// Auto-provision workspace if migrations/ exists
|
|
81
130
|
if (existsSync(migrationsDir)) {
|
|
82
|
-
const api = createClient({
|
|
83
|
-
baseUrl: serverOrigin,
|
|
84
|
-
apiKey: process.env.INFORMER_API_KEY,
|
|
85
|
-
user: process.env.INFORMER_USER,
|
|
86
|
-
pass: process.env.INFORMER_PASS
|
|
87
|
-
});
|
|
88
|
-
|
|
89
131
|
try {
|
|
90
132
|
// Verify existing workspace or create a new one
|
|
91
133
|
let needsInit = !devWorkspaceId;
|
|
@@ -120,17 +162,91 @@ export default function informer(options = {}) {
|
|
|
120
162
|
}
|
|
121
163
|
}
|
|
122
164
|
|
|
165
|
+
// Read the manifest dependencies once for both consumers below —
|
|
166
|
+
// boot-time validation and app-dependency type generation (this
|
|
167
|
+
// used to parse informer.yaml twice). loadDependencies returns {}
|
|
168
|
+
// when there's no manifest and only throws on a malformed/unreadable
|
|
169
|
+
// one; the validators null-guard, so {} is a safe fallback.
|
|
170
|
+
let deps = {};
|
|
171
|
+
try {
|
|
172
|
+
deps = await loadDependencies(projectRoot);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
console.warn(`[informer] Could not read informer.yaml dependencies: ${err.message}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
123
177
|
// Surface manifest-level dependency declaration errors at boot,
|
|
124
178
|
// not at `npx informer publish` time. Matches the deploy.js
|
|
125
179
|
// validation so devs see the same wording pre-deploy.
|
|
180
|
+
for (const message of validateDependencies(deps)) {
|
|
181
|
+
console.error(`[informer] informer.yaml: ${message}`);
|
|
182
|
+
}
|
|
183
|
+
// Catch a typo'd or mis-targeted devBinding at boot rather than
|
|
184
|
+
// letting the first call report the slot as unbound.
|
|
185
|
+
for (const message of validateDevBindings(deps, options.devBindings || {})) {
|
|
186
|
+
console.error(`[informer] vite.config.js: ${message}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Generate .d.ts types for bound `target: app` / `target: pack`
|
|
190
|
+
// deps from their published OpenAPI docs, so server/ handlers get
|
|
191
|
+
// typed context.<slot>.request()/query() autocomplete.
|
|
126
192
|
try {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
193
|
+
// A bare string devBinding is shorthand for { app } — request()
|
|
194
|
+
// works under it, so its types must generate too, not just for
|
|
195
|
+
// the object form.
|
|
196
|
+
const boundAppRef = (name) =>
|
|
197
|
+
resolveAppBinding(options.devBindings && options.devBindings[name]).app;
|
|
198
|
+
const appSlots = Object.entries(deps).filter(
|
|
199
|
+
([name, decl]) => decl && (decl.target === 'app' || decl.target === 'pack') && boundAppRef(name)
|
|
200
|
+
);
|
|
201
|
+
if (appSlots.length) {
|
|
202
|
+
const specs = {};
|
|
203
|
+
let transientFailure = false; // a slot we couldn't reach (vs. a definitive "no doc")
|
|
204
|
+
for (const [name] of appSlots) {
|
|
205
|
+
const appRef = boundAppRef(name);
|
|
206
|
+
let spec;
|
|
207
|
+
try {
|
|
208
|
+
spec = await api.get(`apps/${appRef}/openapi.json`);
|
|
209
|
+
} catch (err) {
|
|
210
|
+
// 401/403 are as definitive as a 404 — the binding is
|
|
211
|
+
// wrong or unshared, and retrying changes nothing, so
|
|
212
|
+
// don't imply otherwise or mark the run transient.
|
|
213
|
+
if (err.status === 401 || err.status === 403) {
|
|
214
|
+
console.warn(`[informer] ${name}: ${appRef} returned ${err.status} — your credentials can't read that app; check the app ref and that it's shared with you`);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
// Network blip or a server-side failure: don't drop the
|
|
218
|
+
// slot — preserve its last-good types below.
|
|
219
|
+
transientFailure = true;
|
|
220
|
+
console.warn(`[informer] ${name}: could not reach ${appRef} OpenAPI (${err.message}) — keeping existing types if present`);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (spec === null) {
|
|
224
|
+
// 404: app ref not found, not shared with you, or a server older
|
|
225
|
+
// than the openapi endpoint. A deploy doesn't fix any of these.
|
|
226
|
+
console.warn(`[informer] ${name}: ${appRef} returned 404 — check the app ref exists and is shared with you (or the server predates /openapi.json)`);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (!spec.paths) {
|
|
230
|
+
console.warn(`[informer] ${name}: ${appRef} has no server routes to type yet — deploy its server/ handlers`);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
specs[name] = spec;
|
|
234
|
+
}
|
|
235
|
+
const slotNames = Object.keys(specs);
|
|
236
|
+
if (slotNames.length) {
|
|
237
|
+
// A transient failure would otherwise rewrite the file without the
|
|
238
|
+
// unreachable slot, making its types vanish on a network blip. If we
|
|
239
|
+
// already have a generated file, keep it intact instead.
|
|
240
|
+
if (transientFailure && existsSync(resolve(projectRoot, '.informer', 'app-deps.d.ts'))) {
|
|
241
|
+
console.warn('[informer] some app dependencies were unreachable — keeping the last-good .informer/app-deps.d.ts');
|
|
242
|
+
} else {
|
|
243
|
+
await writeAppDepTypes(projectRoot, buildDeclarations(specs));
|
|
244
|
+
console.log(`[informer] wrote app-dependency types → .informer/app-deps.d.ts (${slotNames.join(', ')})`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
131
247
|
}
|
|
132
248
|
} catch (err) {
|
|
133
|
-
console.warn(`[informer]
|
|
249
|
+
console.warn(`[informer] app-dependency type generation skipped: ${err.message}`);
|
|
134
250
|
}
|
|
135
251
|
|
|
136
252
|
// Mount server-side route handlers if a server/ directory exists
|
|
@@ -142,21 +258,30 @@ export default function informer(options = {}) {
|
|
|
142
258
|
authHeader,
|
|
143
259
|
devWorkspaceId,
|
|
144
260
|
projectRoot,
|
|
145
|
-
roles: (options.mock && options.mock.roles) || []
|
|
261
|
+
roles: (options.mock && options.mock.roles) || [],
|
|
262
|
+
// Dev-only bindings for `target: app` / `target: pack`
|
|
263
|
+
// slots (app: overrides the manifest defaultBinding; pack:
|
|
264
|
+
// names the locally-installed pack app). Shape:
|
|
265
|
+
// devBindings: { kanban: { app: 'admin:kanban' } }
|
|
266
|
+
devBindings: options.devBindings || {},
|
|
267
|
+
appToken
|
|
146
268
|
});
|
|
147
269
|
server.middlewares.use('/api/_server', serverRoutes);
|
|
148
270
|
}
|
|
149
271
|
|
|
150
|
-
// Mount agent dev middleware if tools
|
|
272
|
+
// Mount agent dev middleware if tools/, mcp/, or informer.yaml agents exist
|
|
151
273
|
const toolsDir = resolve(projectRoot, 'tools');
|
|
274
|
+
const mcpDir = resolve(projectRoot, 'mcp');
|
|
152
275
|
const yamlPath = resolve(projectRoot, 'informer.yaml');
|
|
153
276
|
|
|
154
|
-
if (existsSync(toolsDir) || existsSync(yamlPath)) {
|
|
277
|
+
if (existsSync(toolsDir) || existsSync(mcpDir) || existsSync(yamlPath)) {
|
|
155
278
|
const agentDev = createAgentMiddleware(server, {
|
|
156
279
|
serverOrigin,
|
|
157
280
|
authHeader,
|
|
158
281
|
devWorkspaceId,
|
|
159
|
-
projectRoot
|
|
282
|
+
projectRoot,
|
|
283
|
+
devBindings: options.devBindings || {},
|
|
284
|
+
appToken
|
|
160
285
|
});
|
|
161
286
|
server.middlewares.use('/api/_agent', agentDev);
|
|
162
287
|
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate TypeScript declarations for bound `target: app` dependencies from
|
|
3
|
+
* their published OpenAPI docs (GET /api/apps/<app>/openapi.json), so a
|
|
4
|
+
* consumer's server/ handlers get typed `context.<slot>.request()`
|
|
5
|
+
* autocomplete on the target app's routes, params, and response shapes.
|
|
6
|
+
*
|
|
7
|
+
* Pure — no I/O. `buildDeclarations({ slot: openapiDoc })` returns a `.d.ts` string.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// A slot name → a PascalCase fragment safe to use in a TS identifier. Splits on
|
|
11
|
+
// any non-alphanumeric run (so `a-b` and `a_b` both normalize the same way — the
|
|
12
|
+
// caller disambiguates collisions), and guarantees the result starts with a
|
|
13
|
+
// letter/underscore so a digit-leading slot (`2fa`) can't emit invalid TS.
|
|
14
|
+
function pascalCase (s) {
|
|
15
|
+
const parts = String(s).split(/[^A-Za-z0-9]+/).filter(Boolean);
|
|
16
|
+
let id = parts.map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('');
|
|
17
|
+
if (!id) id = 'Slot';
|
|
18
|
+
if (!/^[A-Za-z_$]/.test(id)) id = '_' + id;
|
|
19
|
+
return id;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// A safe TS property key: bare identifier, or a quoted string otherwise.
|
|
23
|
+
function tsKey (k) {
|
|
24
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(k) ? k : JSON.stringify(k);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The JSON Schema subset an app declares → a TS type string.
|
|
28
|
+
function jsonSchemaToTs (schema) {
|
|
29
|
+
if (!schema || typeof schema !== 'object') return 'any';
|
|
30
|
+
if (Array.isArray(schema.enum) && schema.enum.length) {
|
|
31
|
+
return schema.enum.map((v) => JSON.stringify(v)).join(' | ');
|
|
32
|
+
}
|
|
33
|
+
switch (schema.type) {
|
|
34
|
+
case 'string': return 'string';
|
|
35
|
+
case 'integer':
|
|
36
|
+
case 'number': return 'number';
|
|
37
|
+
case 'boolean': return 'boolean';
|
|
38
|
+
case 'null': return 'null';
|
|
39
|
+
case 'array': return `${jsonSchemaToTs(schema.items || {})}[]`;
|
|
40
|
+
default:
|
|
41
|
+
if (schema.properties) return objectType(schema);
|
|
42
|
+
return schema.type === 'object' ? 'Record<string, any>' : 'any';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function objectType (schema) {
|
|
47
|
+
const props = schema.properties || {};
|
|
48
|
+
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
|
|
49
|
+
const keys = Object.keys(props);
|
|
50
|
+
if (!keys.length) return 'Record<string, any>';
|
|
51
|
+
const fields = keys.map((k) => `${tsKey(k)}${required.has(k) ? '' : '?'}: ${jsonSchemaToTs(props[k])}`);
|
|
52
|
+
return `{ ${fields.join('; ')} }`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// OpenAPI path (/issues/{key}) → the `url` literal used in request({ url }).
|
|
56
|
+
// The consumer passes a leading-slash-free path; `{param}` segments become
|
|
57
|
+
// `${string}` template holes so an interpolated url still type-checks.
|
|
58
|
+
function urlType (path) {
|
|
59
|
+
const rel = path.replace(/^\//, '');
|
|
60
|
+
if (!rel.includes('{')) return JSON.stringify(rel);
|
|
61
|
+
return '`' + rel.replace(/\{[^}]+\}/g, '${string}') + '`';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function queryParamsType (op) {
|
|
65
|
+
const params = (op.parameters || []).filter((p) => p.in === 'query');
|
|
66
|
+
if (!params.length) return null;
|
|
67
|
+
const fields = params.map((p) => `${tsKey(p.name)}${p.required ? '' : '?'}: ${jsonSchemaToTs(p.schema || {})}`);
|
|
68
|
+
return { type: `{ ${fields.join('; ')} }`, required: params.some((p) => p.required) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function jsonSchemaOf (container) {
|
|
72
|
+
return container && container.content && container.content['application/json']
|
|
73
|
+
? container.content['application/json'].schema
|
|
74
|
+
: undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function requestOverload (method, path, op) {
|
|
78
|
+
const parts = [`method: ${JSON.stringify(method.toUpperCase())}`, `url: ${urlType(path)}`];
|
|
79
|
+
|
|
80
|
+
const q = queryParamsType(op);
|
|
81
|
+
if (q) parts.push(`params${q.required ? '' : '?'}: ${q.type}`);
|
|
82
|
+
|
|
83
|
+
const bodySchema = jsonSchemaOf(op.requestBody);
|
|
84
|
+
if (bodySchema) parts.push(`data: ${jsonSchemaToTs(bodySchema)}`);
|
|
85
|
+
|
|
86
|
+
const respSchema = op.responses && op.responses['200'] ? jsonSchemaOf(op.responses['200']) : undefined;
|
|
87
|
+
const ret = respSchema ? jsonSchemaToTs(respSchema) : 'any';
|
|
88
|
+
|
|
89
|
+
const summary = op.summary || op.description;
|
|
90
|
+
const doc = summary ? ` /** ${String(summary).replace(/\*\//g, '*\\/')} */\n` : '';
|
|
91
|
+
return `${doc} request(opts: { ${parts.join('; ')} }): Promise<${ret}>;`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete'];
|
|
95
|
+
|
|
96
|
+
function slotInterface (iface, spec) {
|
|
97
|
+
const overloads = [];
|
|
98
|
+
for (const [path, item] of Object.entries(spec.paths || {})) {
|
|
99
|
+
for (const method of HTTP_METHODS) {
|
|
100
|
+
if (item[method]) overloads.push(requestOverload(method, path, item[method]));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Escape hatch, listed last so the typed overloads win when they match. The
|
|
104
|
+
// shape mirrors what the runtime driver actually reads — { method, url,
|
|
105
|
+
// params, data } — no `headers`, which the driver ignores (entity-type/app.js).
|
|
106
|
+
overloads.push(' /** Escape hatch — any method/url (bypasses the typed contract). */');
|
|
107
|
+
overloads.push(' request(opts: { method?: string; url: string; params?: Record<string, unknown>; data?: unknown }): Promise<any>;');
|
|
108
|
+
|
|
109
|
+
return [
|
|
110
|
+
`export interface ${iface} {`,
|
|
111
|
+
overloads.join('\n'),
|
|
112
|
+
'}'
|
|
113
|
+
].join('\n');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param {Record<string, object>} specsBySlot - { slotName: openapiDoc }
|
|
118
|
+
* @returns {string} a `.d.ts` document
|
|
119
|
+
*/
|
|
120
|
+
export function buildDeclarations (specsBySlot) {
|
|
121
|
+
const interfaces = [];
|
|
122
|
+
const slotFields = [];
|
|
123
|
+
const usedIfaces = new Set();
|
|
124
|
+
for (const [slot, spec] of Object.entries(specsBySlot)) {
|
|
125
|
+
// Distinct slots can normalize to the same PascalCase (e.g. `a-b`/`a_b`);
|
|
126
|
+
// suffix on collision so TS doesn't silently merge the two interfaces.
|
|
127
|
+
const base = pascalCase(slot) + 'Api';
|
|
128
|
+
let iface = base;
|
|
129
|
+
for (let n = 2; usedIfaces.has(iface); n++) iface = `${base}_${n}`;
|
|
130
|
+
usedIfaces.add(iface);
|
|
131
|
+
|
|
132
|
+
interfaces.push(slotInterface(iface, spec));
|
|
133
|
+
slotFields.push(` ${tsKey(slot)}: ${iface};`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return `// AUTO-GENERATED by vite-plugin-informer — do not edit.
|
|
137
|
+
// Typed from bound \`target: app\` dependencies' OpenAPI docs; regenerated on \`npm run dev\`.
|
|
138
|
+
//
|
|
139
|
+
// Use in a server/ handler. The import path is relative to the handler file —
|
|
140
|
+
// add one ../ per directory below server/ (e.g. server/issues/index.js → ../../):
|
|
141
|
+
// /** @param {import('../../.informer/app-deps').HandlerBag} bag */
|
|
142
|
+
// export async function GET(bag) { const { context } = bag; /* context.<slot>.request(...) */ }
|
|
143
|
+
|
|
144
|
+
${interfaces.join('\n\n')}
|
|
145
|
+
|
|
146
|
+
export interface AppDependencies {
|
|
147
|
+
${slotFields.join('\n')}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface HandlerBag {
|
|
151
|
+
context: AppDependencies & Record<string, any>;
|
|
152
|
+
request: {
|
|
153
|
+
method: string;
|
|
154
|
+
path: string;
|
|
155
|
+
query: Record<string, string>;
|
|
156
|
+
body: any;
|
|
157
|
+
params: Record<string, string>;
|
|
158
|
+
roles: string[];
|
|
159
|
+
user: any;
|
|
160
|
+
headers: Record<string, string>;
|
|
161
|
+
};
|
|
162
|
+
query(sql: string, params?: unknown[]): Promise<any[]>;
|
|
163
|
+
[key: string]: any;
|
|
164
|
+
}
|
|
165
|
+
`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export const _internal = { jsonSchemaToTs, urlType, queryParamsType, pascalCase };
|
package/src/server-routes.js
CHANGED
|
@@ -5,6 +5,10 @@ import { loadDependencies, buildDevContext, loadAppEnv, buildDevCrypto, buildDev
|
|
|
5
5
|
|
|
6
6
|
const VALID_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
7
7
|
|
|
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
|
+
|
|
8
12
|
// Strict base64 — see view-api.js for the rationale. Mirror kept identical
|
|
9
13
|
// to keep dev and prod behavior aligned.
|
|
10
14
|
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
@@ -144,10 +148,17 @@ async function walkJsFiles(dir, basePath) {
|
|
|
144
148
|
* Create Connect middleware for dev-mode server route execution.
|
|
145
149
|
*
|
|
146
150
|
* @param {Object} viteServer - Vite dev server instance
|
|
147
|
-
* @param {
|
|
151
|
+
* @param {Object} opts
|
|
152
|
+
* @param {string} opts.serverOrigin - Informer server origin
|
|
153
|
+
* @param {string} opts.authHeader - Basic or Bearer auth header for /api calls
|
|
154
|
+
* @param {string|null} opts.devWorkspaceId - workspace datasource id for query()
|
|
155
|
+
* @param {string} opts.projectRoot - app project root
|
|
156
|
+
* @param {string[]} [opts.roles] - dev user roles surfaced on request.roles
|
|
157
|
+
* @param {Object} [opts.devBindings] - dev bindings for `target: app` deps
|
|
158
|
+
* @param {string|null} [opts.appToken] - INFORMER_APP_TOKEN for cross-app request()
|
|
148
159
|
* @returns {Function} Connect middleware
|
|
149
160
|
*/
|
|
150
|
-
export function createMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, roles }) {
|
|
161
|
+
export function createMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, roles, devBindings, appToken }) {
|
|
151
162
|
const serverDir = join(projectRoot, 'server');
|
|
152
163
|
|
|
153
164
|
// query() implementation — proxies to the workspace _sql endpoint
|
|
@@ -173,30 +184,65 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
|
|
|
173
184
|
}
|
|
174
185
|
|
|
175
186
|
// fetch() implementation — proxies API calls to the Informer server
|
|
176
|
-
async function
|
|
187
|
+
async function fetchAs(auth, path, opts = {}) {
|
|
177
188
|
const method = (opts.method || 'GET').toUpperCase();
|
|
178
189
|
// Same strict canonicalization as prod (app-sandbox.js normalizeFetchPath);
|
|
179
190
|
// reject non-canonical shapes here instead of silently accepting them.
|
|
180
191
|
const apiPath = normalizeFetchPath(path);
|
|
181
192
|
if (!apiPath) {
|
|
182
|
-
return { status: 400, body: { error: `Invalid fetch path: ${String(path).slice(0, 80)}` } };
|
|
193
|
+
return { status: 400, body: { error: `Invalid fetch path: ${String(path).slice(0, 80)}` }, contentType: 'application/json' };
|
|
183
194
|
}
|
|
184
195
|
const url = `${serverOrigin}${apiPath}`;
|
|
185
196
|
const fetchOpts = {
|
|
186
197
|
method,
|
|
187
|
-
headers: { Authorization:
|
|
198
|
+
headers: { Authorization: auth, 'Content-Type': 'application/json', ...(opts.headers || {}) }
|
|
188
199
|
};
|
|
189
200
|
|
|
190
|
-
if (opts.body && ['POST', 'PUT', 'PATCH'].includes(method)) {
|
|
201
|
+
if (opts.body && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
|
191
202
|
fetchOpts.body = JSON.stringify(opts.body);
|
|
192
203
|
}
|
|
193
204
|
|
|
194
|
-
|
|
205
|
+
// Read the stream exactly once — `.json()` consumes/locks the body, so a
|
|
206
|
+
// `.text()` fallback would throw "Body is unusable" on any non-JSON
|
|
207
|
+
// response (auth-bounce HTML, proxy error page). Parse in memory
|
|
208
|
+
// instead — same as prod's unwrapInject.
|
|
209
|
+
let status, contentType, text;
|
|
210
|
+
try {
|
|
211
|
+
const resp = await globalThis.fetch(url, { ...fetchOpts, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
212
|
+
status = resp.status;
|
|
213
|
+
contentType = resp.headers.get('content-type') || '';
|
|
214
|
+
text = await resp.text();
|
|
215
|
+
} catch (err) {
|
|
216
|
+
// Transport failure or timeout — fetch throws (TypeError 'fetch failed'
|
|
217
|
+
// with the real reason on err.cause, or a TimeoutError). Return a
|
|
218
|
+
// synthetic 502 so the dependency layer names it (dep + url + cause)
|
|
219
|
+
// rather than a bare unhandled "fetch failed".
|
|
220
|
+
const reason = (err.cause && err.cause.message) || err.message;
|
|
221
|
+
return { status: 502, body: { message: `fetch to ${url} failed: ${reason}` }, contentType: 'application/json' };
|
|
222
|
+
}
|
|
195
223
|
let body;
|
|
196
|
-
try { body =
|
|
197
|
-
return { status
|
|
224
|
+
try { body = JSON.parse(text); } catch { body = text; }
|
|
225
|
+
return { status, body, contentType };
|
|
198
226
|
}
|
|
199
227
|
|
|
228
|
+
async function apiFetch(path, opts = {}) {
|
|
229
|
+
return await fetchAs(authHeader, path, opts);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Cross-app request() targets /api/apps/<id>/view/_/<path>, whose auth accepts
|
|
233
|
+
// only the token/session strategies — NOT basic auth. In API-key mode the
|
|
234
|
+
// INFORMER_API_KEY Bearer already satisfies that, so reuse it; under basic
|
|
235
|
+
// auth a separate API token (INFORMER_APP_TOKEN) is required, and without one
|
|
236
|
+
// the app proxy's request() throws a pointed error instead of a bare 401.
|
|
237
|
+
// appFetch also stamps x-informer-app-depth:1 so the target runs one hop deep
|
|
238
|
+
// and enforces the same one-hop guard it does in production.
|
|
239
|
+
const appAuth = appToken
|
|
240
|
+
? `Bearer ${appToken}`
|
|
241
|
+
: (authHeader && authHeader.startsWith('Bearer ') ? authHeader : null);
|
|
242
|
+
const appFetch = appAuth
|
|
243
|
+
? async (path, opts = {}) => await fetchAs(appAuth, path, { ...opts, headers: { ...(opts.headers || {}), 'x-informer-app-depth': '1' } })
|
|
244
|
+
: null;
|
|
245
|
+
|
|
200
246
|
return async function serverRoutesMiddleware(req, res, next) {
|
|
201
247
|
try {
|
|
202
248
|
// The URL has already had /api/_server stripped by Vite's middleware.use()
|
|
@@ -307,7 +353,7 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
|
|
|
307
353
|
// work locally. Loaded per request so edits to informer.yaml take
|
|
308
354
|
// effect without a dev-server restart.
|
|
309
355
|
const deps = await loadDependencies(projectRoot);
|
|
310
|
-
const context = buildDevContext({ deps, apiFetch });
|
|
356
|
+
const context = buildDevContext({ deps, apiFetch, devBindings, appFetch });
|
|
311
357
|
const env = await loadAppEnv(projectRoot);
|
|
312
358
|
|
|
313
359
|
// Call handler — bag mirrors the prod sandbox (see app-sandbox.js).
|