@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 CHANGED
File without changes
package/index.d.ts CHANGED
@@ -1,2 +1,28 @@
1
1
  import type { Plugin } from 'vite';
2
- export default function informer(): Plugin;
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.0",
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/ directory for .js files that export a handler.
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
- await walkToolFiles(toolsDir, 'tools', tools);
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 apiFetch(path, opts = {}) {
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: authHeader, 'Content-Type': 'application/json' }
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
- const resp = await globalThis.fetch(url, fetchOpts);
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 = await resp.json(); } catch { body = await resp.text(); }
192
- return { status: resp.status, body };
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/…, tools/…, webhooks/…)
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
  }
@@ -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 (typeof decl.defaultBinding !== 'string') {
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
- throw new Error(
342
- `Dependency "${name}" is not bound in dev — add \`defaultBinding: <uuid>\` to its entry in informer.yaml`
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
- const message = responseBody && typeof responseBody === 'object' && responseBody.message
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 {{ mock?: object }} options
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
- const deps = await loadDependencies(projectRoot);
128
- const errors = validateDependencies(deps);
129
- for (const message of errors) {
130
- console.error(`[informer] informer.yaml: ${message}`);
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] Could not validate informer.yaml dependencies: ${err.message}`);
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/ or informer.yaml agents exist
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 };
@@ -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 {{ serverOrigin: string, authHeader: string, devWorkspaceId: string|null, projectRoot: string }} opts
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 apiFetch(path, opts = {}) {
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: authHeader, 'Content-Type': 'application/json' }
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
- const resp = await globalThis.fetch(url, fetchOpts);
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 = await resp.json(); } catch { body = await resp.text(); }
197
- return { status: resp.status, body };
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).