@entrinsik/vite-plugin-informer 2.6.0-beta.0 → 2.6.0-beta.1

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,26 @@
1
1
  import type { Plugin } from 'vite';
2
- export default function informer(): Plugin;
2
+
3
+ /**
4
+ * Dev-only binding for a `target: app` dependency slot. Points `request()` at
5
+ * the target app in dev; overrides the manifest `defaultBinding` when both are
6
+ * present.
7
+ *
8
+ * - `app` — the target app (`owner:slug` or UUID). Powers `request()`.
9
+ *
10
+ * A bare string is shorthand for `{ app }`.
11
+ */
12
+ export interface AppDevBinding {
13
+ app?: string;
14
+ }
15
+
16
+ export interface InformerPluginOptions {
17
+ mock?: {
18
+ report?: { id?: string; name?: string };
19
+ theme?: 'light' | 'dark';
20
+ roles?: string[];
21
+ };
22
+ devBindings?: Record<string, string | AppDevBinding>;
23
+ proxy?: Record<string, unknown>;
24
+ }
25
+
26
+ 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.1",
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
  *
@@ -145,7 +149,7 @@ async function readSSE(response) {
145
149
  * @param {Object} opts - Configuration
146
150
  * @returns {Function} Connect middleware
147
151
  */
148
- export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot }) {
152
+ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings, appToken }) {
149
153
 
150
154
  // query() — proxies to the workspace _sql endpoint (same as server-routes.js)
151
155
  async function query(sql, params) {
@@ -169,29 +173,57 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
169
173
  }
170
174
 
171
175
  // fetch() — proxies API calls to the Informer server (same as server-routes.js)
172
- async function apiFetch(path, opts = {}) {
176
+ async function fetchAs(auth, path, opts = {}) {
173
177
  const method = (opts.method || 'GET').toUpperCase();
174
178
  // Same strict canonicalization as prod (app-sandbox.js normalizeFetchPath).
175
179
  const apiPath = normalizeFetchPath(path);
176
180
  if (!apiPath) {
177
- return { status: 400, body: { error: `Invalid fetch path: ${String(path).slice(0, 80)}` } };
181
+ return { status: 400, body: { error: `Invalid fetch path: ${String(path).slice(0, 80)}` }, contentType: 'application/json' };
178
182
  }
179
183
  const url = `${serverOrigin}${apiPath}`;
180
184
  const fetchOpts = {
181
185
  method,
182
- headers: { Authorization: authHeader, 'Content-Type': 'application/json' }
186
+ headers: { Authorization: auth, 'Content-Type': 'application/json', ...(opts.headers || {}) }
183
187
  };
184
188
 
185
- if (opts.body && ['POST', 'PUT', 'PATCH'].includes(method)) {
189
+ if (opts.body && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
186
190
  fetchOpts.body = JSON.stringify(opts.body);
187
191
  }
188
192
 
189
- const resp = await globalThis.fetch(url, fetchOpts);
193
+ // Read the stream once — `.json()` consumes it, so a `.text()` fallback
194
+ // would throw "Body is unusable" on any non-JSON response. Transport
195
+ // failure/timeout → synthetic 502 so the dep layer names it. See server-routes.js.
196
+ let status, contentType, text;
197
+ try {
198
+ const resp = await globalThis.fetch(url, { ...fetchOpts, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
199
+ status = resp.status;
200
+ contentType = resp.headers.get('content-type') || '';
201
+ text = await resp.text();
202
+ } catch (err) {
203
+ const reason = (err.cause && err.cause.message) || err.message;
204
+ return { status: 502, body: { message: `fetch to ${url} failed: ${reason}` }, contentType: 'application/json' };
205
+ }
190
206
  let body;
191
- try { body = await resp.json(); } catch { body = await resp.text(); }
192
- return { status: resp.status, body };
207
+ try { body = JSON.parse(text); } catch { body = text; }
208
+ return { status, body, contentType };
193
209
  }
194
210
 
211
+ async function apiFetch(path, opts = {}) {
212
+ return await fetchAs(authHeader, path, opts);
213
+ }
214
+
215
+ // Cross-app request() hits the target's /view/_/ dispatch, which accepts only
216
+ // the token/session strategies — not basic auth. In API-key mode the
217
+ // INFORMER_API_KEY Bearer is reused; under basic auth INFORMER_APP_TOKEN is
218
+ // required. appFetch stamps x-informer-app-depth:1 for the one-hop guard.
219
+ // See server-routes.js.
220
+ const appAuth = appToken
221
+ ? `Bearer ${appToken}`
222
+ : (authHeader && authHeader.startsWith('Bearer ') ? authHeader : null);
223
+ const appFetch = appAuth
224
+ ? async (path, opts = {}) => await fetchAs(appAuth, path, { ...opts, headers: { ...(opts.headers || {}), 'x-informer-app-depth': '1' } })
225
+ : null;
226
+
195
227
  // emit() — no-op in dev mode (logs to console)
196
228
  function emit(event, payload) {
197
229
  console.log(`[agent-dev] emit("${event}",`, JSON.stringify(payload), ')');
@@ -287,7 +319,7 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
287
319
  // `context.<slot>.<method>(...)` and `env` work locally and match
288
320
  // the prod sandbox bag.
289
321
  const deps = await loadDependencies(projectRoot);
290
- const context = buildDevContext({ deps, apiFetch });
322
+ const context = buildDevContext({ deps, apiFetch, devBindings, appFetch });
291
323
  const env = await loadAppEnv(projectRoot);
292
324
 
293
325
  // 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', '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
  }
@@ -150,16 +150,25 @@ 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']);
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
+ const DEFAULT_BINDABLE_TARGETS = new Set(['dataset', 'query', 'datasource', 'integration', 'app']);
159
+
156
160
  const METHOD_SURFACE = {
157
161
  dataset: ['search', 'fields'],
158
162
  query: ['execute'],
159
163
  datasource: ['query'],
160
- integration: ['request']
164
+ integration: ['request'],
165
+ app: ['request']
161
166
  };
162
167
 
168
+ // Cross-app request() method allow-list — mirrors REQUEST_METHODS in
169
+ // entity-type/app.js so dev rejects the same shapes prod 400s.
170
+ const REQUEST_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
171
+
163
172
  /**
164
173
  * Load the `dependencies:` map from informer.yaml. Returns `{}` if the file
165
174
  * is missing or the section is absent — both are valid (an app may have no
@@ -238,7 +247,11 @@ export function validateDependencies(deps) {
238
247
  errors.push(`dependency "${name}": runAs must be 'user' or 'owner' (got "${runAs}")`);
239
248
  }
240
249
  if (decl.defaultBinding != null) {
241
- if (typeof decl.defaultBinding !== 'string') {
250
+ if (!DEFAULT_BINDABLE_TARGETS.has(decl.target)) {
251
+ // Same rejection the deploy performs — surface it at dev boot
252
+ // rather than letting the deploy be the first to say no.
253
+ errors.push(`dependency "${name}": target "${decl.target}" does not support defaultBinding (bind it via PUT /api/apps/<id>/dependencies/${name})`);
254
+ } else if (typeof decl.defaultBinding !== 'string') {
242
255
  errors.push(`dependency "${name}": defaultBinding must be a string UUID`);
243
256
  } else if (!UUID_PATTERN.test(decl.defaultBinding)) {
244
257
  errors.push(`dependency "${name}": defaultBinding must be a UUID (got "${decl.defaultBinding}")`);
@@ -248,6 +261,68 @@ export function validateDependencies(deps) {
248
261
  return errors;
249
262
  }
250
263
 
264
+ /**
265
+ * Validate the plugin's `devBindings` option against the declared dependencies.
266
+ * Returns human-readable error strings (empty when valid). Runs at dev boot so a
267
+ * typo'd slot ("kanbann") is caught loudly rather than silently ignored until
268
+ * the first call reports the slot as unbound — telling the developer to add the
269
+ * thing they think they already added.
270
+ *
271
+ * A binding key must name a declared `target: app` dependency; its value must be
272
+ * a non-empty `owner:slug` string (shorthand for `{ app }`) or an object with a
273
+ * non-empty `app` (the target app, for request()).
274
+ *
275
+ * @param {Object} deps - The raw `dependencies:` object from informer.yaml
276
+ * @param {Object} devBindings - The plugin's `devBindings` option
277
+ * @returns {string[]} Error messages, one per problem
278
+ */
279
+ export function validateDevBindings(deps, devBindings) {
280
+ const errors = [];
281
+ const declared = (deps && typeof deps === 'object') ? deps : {};
282
+ for (const [name, binding] of Object.entries(devBindings || {})) {
283
+ const decl = declared[name];
284
+ if (!decl || typeof decl !== 'object') {
285
+ errors.push(`devBindings."${name}": no dependency "${name}" is declared in informer.yaml`);
286
+ continue;
287
+ }
288
+ if (decl.target !== 'app') {
289
+ errors.push(`devBindings."${name}": only "target: app" dependencies take a devBinding (got "${decl.target}")`);
290
+ continue;
291
+ }
292
+ if (typeof binding === 'string') {
293
+ if (!binding.trim()) {
294
+ errors.push(`devBindings."${name}": binding string must be a non-empty "owner:slug"`);
295
+ }
296
+ continue;
297
+ }
298
+ if (!binding || typeof binding !== 'object') {
299
+ errors.push(`devBindings."${name}": must be an "owner:slug" string or an object { app }`);
300
+ continue;
301
+ }
302
+ if (binding.app == null) {
303
+ errors.push(`devBindings."${name}": needs "app" (the target app, for request())`);
304
+ } else if (typeof binding.app !== 'string' || !binding.app.trim()) {
305
+ errors.push(`devBindings."${name}".app: must be a non-empty string`);
306
+ }
307
+ }
308
+ return errors;
309
+ }
310
+
311
+ /**
312
+ * Normalize a devBindings entry to `{ app }`. A bare string is shorthand for
313
+ * `{ app }`. Every consumer of a binding — the `.d.ts` generator and the dev
314
+ * proxy alike — resolves `.app` from this result, never from the raw entry, so
315
+ * the string and object forms behave identically: a shorthand binding gets both
316
+ * generated types and a working request().
317
+ *
318
+ * @param {string|{app?: string}|null|undefined} binding
319
+ * @returns {{ app: string|null }}
320
+ */
321
+ export function resolveAppBinding(binding) {
322
+ if (typeof binding === 'string') return { app: binding };
323
+ return { app: (binding && binding.app) || null };
324
+ }
325
+
251
326
  /**
252
327
  * Build the dev-mode `context` object passed to server route handlers, so
253
328
  * `await context.myDep.method(...)` works the same locally as it does after
@@ -274,13 +349,28 @@ export function validateDependencies(deps) {
274
349
  * @returns {Object} An object keyed by dependency name, values are typed
275
350
  * proxies with methods matching the target's production method surface.
276
351
  */
277
- export function buildDevContext({ deps, apiFetch }) {
352
+ export function buildDevContext({ deps, apiFetch, devBindings = {}, appFetch = null }) {
278
353
  const context = {};
279
354
  for (const [name, decl] of Object.entries(deps || {})) {
280
355
  if (!decl || typeof decl !== 'object') continue;
281
356
  const target = decl.target;
282
357
  if (!VALID_TARGETS.has(target)) continue;
283
358
 
359
+ // App slots bind from the plugin's `devBindings` (a human `owner:slug`
360
+ // pointing request() at the target app), falling back to the manifest
361
+ // `defaultBinding` UUID like every other target. request() is the only
362
+ // surface.
363
+ if (target === 'app') {
364
+ const fallback = (typeof decl.defaultBinding === 'string' && UUID_PATTERN.test(decl.defaultBinding))
365
+ ? decl.defaultBinding
366
+ : null;
367
+ const binding = devBindings[name] != null ? devBindings[name] : fallback;
368
+ context[name] = binding
369
+ ? makeAppDevProxy({ name, binding, appFetch })
370
+ : makeUnboundDevProxy({ name, target });
371
+ continue;
372
+ }
373
+
284
374
  const targetId = (typeof decl.defaultBinding === 'string' && UUID_PATTERN.test(decl.defaultBinding))
285
375
  ? decl.defaultBinding
286
376
  : null;
@@ -292,6 +382,94 @@ export function buildDevContext({ deps, apiFetch }) {
292
382
  return context;
293
383
  }
294
384
 
385
+ /**
386
+ * Dev proxy for a `target: app` slot. `request()` is the only surface.
387
+ *
388
+ * Production runs `request()` through the target's own /view/_/ dispatch, and
389
+ * dev injects into the same route:
390
+ *
391
+ * request(payload) -> /api/apps/<app>/view/_/<url>
392
+ * The same route prod injects into, so the target's own handlers, authz
393
+ * and roles run. This dispatch accepts only the token/session auth
394
+ * strategies (not basic auth), so it uses `appFetch`: an INFORMER_APP_TOKEN
395
+ * Bearer, or the INFORMER_API_KEY Bearer reused when the plugin already
396
+ * runs in API-key mode. `appFetch` stamps `x-informer-app-depth: 1`, so the
397
+ * target runs one hop deep and enforces the same one-hop guard as prod.
398
+ * Non-JSON success responses follow the prod envelope contract: text/HTML
399
+ * returns a text envelope; binary is rejected (dev can't emulate it yet).
400
+ *
401
+ * @param {Object} args
402
+ * @param {string} args.name - dependency slot name
403
+ * @param {string|{app?: string}} args.binding - devBindings entry (or the
404
+ * manifest defaultBinding UUID). A bare string is shorthand for `{ app }`.
405
+ * @param {Function|null} args.appFetch - token-authed fetch, or null when
406
+ * INFORMER_APP_TOKEN is unset.
407
+ */
408
+ function makeAppDevProxy({ name, binding, appFetch }) {
409
+ const { app } = resolveAppBinding(binding);
410
+
411
+ return {
412
+ async request(payload) {
413
+ if (!app) {
414
+ throw new Error(
415
+ `Dependency "${name}" (app): dev request() needs the target app — set devBindings.${name}.app (e.g. 'admin:kanban')`
416
+ );
417
+ }
418
+ if (!appFetch) {
419
+ throw new Error(
420
+ `Dependency "${name}" (app): 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`
421
+ );
422
+ }
423
+ const { method = 'GET', url, params, data } = payload || {};
424
+ const httpMethod = String(method).toUpperCase();
425
+ if (!REQUEST_METHODS.includes(httpMethod)) {
426
+ throw new Error(`Dependency "${name}" (app): unsupported method "${method}"`);
427
+ }
428
+ if (!url || typeof url !== 'string') {
429
+ throw new Error(`Dependency "${name}" (app): request({ url }) requires the target route path`);
430
+ }
431
+ const path = url.replace(/^\/+/, '');
432
+ const search = params ? `?${new URLSearchParams(params).toString()}` : '';
433
+ // Path-traversal guard (mirrors entity-type/app.js): the path is
434
+ // interpolated into the target's /view/_/ dispatch and fetch resolves
435
+ // dot segments before the request lands, so `../../x` would escape the
436
+ // dispatch and reach arbitrary /api routes. Resolve it the way fetch
437
+ // will and confirm it still lives under the dispatch prefix.
438
+ const targetPrefix = `/api/apps/${encodeURIComponent(app)}/view/_/`;
439
+ let resolvedPath;
440
+ try {
441
+ resolvedPath = new URL(`${targetPrefix}${path}${search}`, 'http://localhost').pathname;
442
+ } catch {
443
+ throw new Error(`Dependency "${name}" (app): request({ url }) is malformed`);
444
+ }
445
+ if (!resolvedPath.startsWith(targetPrefix)) {
446
+ throw new Error(`Dependency "${name}" (app): request({ url }) must not escape the target app with ".." path segments`);
447
+ }
448
+ const { status, body, contentType } = await appFetch(
449
+ `apps/${encodeURIComponent(app)}/view/_/${path}${search}`,
450
+ { method: httpMethod, body: data }
451
+ );
452
+ if (status >= 400) {
453
+ throw dependencyCallError(name, 'app', status, body);
454
+ }
455
+ // Success-envelope contract mirrors entity-type/app.js: JSON → parsed
456
+ // body; non-JSON text/HTML → text envelope; binary → not emulatable
457
+ // via resp.text() without corruption, so fail loudly rather than
458
+ // silently hand back mangled bytes.
459
+ if (isBinaryContentType(contentType)) {
460
+ throw new Error(
461
+ `Dependency "${name}" (app): the dev proxy can't return binary responses yet (upstream content-type "${contentType}"). Test binary endpoints against a deployed build.`
462
+ );
463
+ }
464
+ // (An empty content-type gets the text envelope too, matching prod.)
465
+ if (!(contentType || '').includes('json')) {
466
+ return { status, body, contentType: contentType || '', headers: { 'content-type': contentType || '' } };
467
+ }
468
+ return body;
469
+ }
470
+ };
471
+ }
472
+
295
473
  function makeDevProxy({ name, target, targetId, apiFetch }) {
296
474
  switch (target) {
297
475
  case 'dataset':
@@ -335,29 +513,81 @@ function makeDevProxy({ name, target, targetId, apiFetch }) {
335
513
  */
336
514
  function makeUnboundDevProxy({ name, target }) {
337
515
  const methods = METHOD_SURFACE[target] || [];
516
+ // App slots bind via devBindings (a dev-local owner:slug) or a manifest
517
+ // defaultBinding UUID, so point at whichever the reader is likelier to want.
518
+ const hint = target === 'app'
519
+ ? `add \`devBindings: { ${name}: { app: '<owner:slug>' } }\` to the informer() plugin options in vite.config.js, or \`defaultBinding: <uuid>\` to its entry in informer.yaml`
520
+ : 'add `defaultBinding: <uuid>` to its entry in informer.yaml';
338
521
  const proxy = {};
339
522
  for (const method of methods) {
340
523
  proxy[method] = async () => {
341
- throw new Error(
342
- `Dependency "${name}" is not bound in dev — add \`defaultBinding: <uuid>\` to its entry in informer.yaml`
524
+ // Mirror the prod unbound-proxy contract (unbound-proxy.js): a 422
525
+ // with data.errorCode 'dependency_unbound', so guest code doing
526
+ // `catch (err) { if (err.data?.errorCode === 'dependency_unbound') }`
527
+ // fires in dev exactly as it does in production.
528
+ throw dependencyError(
529
+ `Dependency "${name}" is not bound in dev — ${hint}`,
530
+ 422,
531
+ { errorCode: 'dependency_unbound', dependencyName: name, resourceType: target }
343
532
  );
344
533
  };
345
534
  }
346
535
  return proxy;
347
536
  }
348
537
 
538
+ /**
539
+ * Build an Error carrying the same shape production reconstructs across the V8
540
+ * isolate boundary (see the invoke-script wrapper in app-sandbox.js): a numeric
541
+ * `statusCode`, a structured `data` (with `errorCode`), and the boom-style
542
+ * `output.payload.data` mirror. Guest code branches on `err.statusCode` and
543
+ * `err.data.errorCode`, so dev must attach the same fields or those branches
544
+ * silently never fire locally — the exact dev/prod drift this feature removes.
545
+ */
546
+ function dependencyError(message, statusCode, data) {
547
+ const err = new Error(message);
548
+ err.statusCode = statusCode;
549
+ err.data = data;
550
+ err.output = { statusCode, payload: { statusCode, message, data } };
551
+ return err;
552
+ }
553
+
554
+ /**
555
+ * Shape a >=400 dev-proxy response into the prod boundary-error contract
556
+ * (unwrapInject in inject-helpers.js): the dependency name is baked into the
557
+ * message and `data` carries { errorCode, dependencyName, resourceType,
558
+ * upstreamStatus } — propagating any structured `errorCode` the upstream boom
559
+ * body already carried (e.g. a target app's own `dependency_unbound`).
560
+ */
561
+ function dependencyCallError(depName, resourceType, status, responseBody) {
562
+ const isObj = responseBody && typeof responseBody === 'object';
563
+ const message = isObj && responseBody.message ? responseBody.message : String(status);
564
+ const data = {
565
+ errorCode: (isObj && responseBody.data && responseBody.data.errorCode) || null,
566
+ dependencyName: depName,
567
+ resourceType,
568
+ upstreamStatus: status
569
+ };
570
+ const err = dependencyError(`Dependency "${depName}" (${resourceType}): ${message}`, status, data);
571
+ err.body = responseBody;
572
+ return err;
573
+ }
574
+
575
+ /**
576
+ * Local mirror of inject-helpers.js isBinaryContentType (can't import across the
577
+ * package boundary). Text, JSON and SSE are safe to carry as strings; anything
578
+ * else is bytes that `resp.text()` would corrupt.
579
+ */
580
+ function isBinaryContentType(contentType) {
581
+ const ct = (contentType || '').toLowerCase();
582
+ return Boolean(ct) && !ct.startsWith('text/') && !ct.includes('json') && !ct.includes('event-stream');
583
+ }
584
+
349
585
  async function devCall(apiFetch, method, path, body, depName, resourceType) {
350
586
  const opts = { method };
351
587
  if (body !== null && body !== undefined) opts.body = body;
352
588
  const { status, body: responseBody } = await apiFetch(path, opts);
353
589
  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;
590
+ throw dependencyCallError(depName, resourceType, status, responseBody);
361
591
  }
362
592
  return responseBody;
363
593
  }
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,12 @@ 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` deps,
49
+ * which can't be defaultBound in the manifest. See AppDevBinding in index.d.ts.
50
+ * @param {Object} [options.proxy] - extra Vite proxy options merged onto /api.
20
51
  * @returns {import('vite').Plugin}
21
52
  */
22
53
  export default function informer(options = {}) {
@@ -24,6 +55,11 @@ export default function informer(options = {}) {
24
55
  let authHeader = null;
25
56
  let serverOrigin = null;
26
57
  let devWorkspaceId = null;
58
+ // Optional separate credential for cross-app request() — the target's
59
+ // /view/_/ dispatch accepts only the token/session strategies, not basic
60
+ // auth. In API-key mode the INFORMER_API_KEY Bearer is reused instead, so
61
+ // this is only needed under basic auth (INFORMER_USER/PASS).
62
+ let appToken = null;
27
63
  let activeMode = null;
28
64
 
29
65
  return {
@@ -46,6 +82,7 @@ export default function informer(options = {}) {
46
82
  const pass = process.env.INFORMER_PASS;
47
83
 
48
84
  devWorkspaceId = process.env.INFORMER_DEV_WORKSPACE || null;
85
+ appToken = process.env.INFORMER_APP_TOKEN || null;
49
86
 
50
87
  if (baseUrl) {
51
88
  serverOrigin = baseUrl.replace(/\/+$/, '');
@@ -77,15 +114,18 @@ export default function informer(options = {}) {
77
114
  const projectRoot = process.cwd();
78
115
  const migrationsDir = resolve(projectRoot, 'migrations');
79
116
 
117
+ // One API client for the whole dev-server setup — createClient just
118
+ // builds an auth header (no I/O), so it's hoisted out of the two
119
+ // branches that each used to rebuild an identical one.
120
+ const api = createClient({
121
+ baseUrl: serverOrigin,
122
+ apiKey: process.env.INFORMER_API_KEY,
123
+ user: process.env.INFORMER_USER,
124
+ pass: process.env.INFORMER_PASS
125
+ });
126
+
80
127
  // Auto-provision workspace if migrations/ exists
81
128
  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
129
  try {
90
130
  // Verify existing workspace or create a new one
91
131
  let needsInit = !devWorkspaceId;
@@ -120,17 +160,91 @@ export default function informer(options = {}) {
120
160
  }
121
161
  }
122
162
 
163
+ // Read the manifest dependencies once for both consumers below —
164
+ // boot-time validation and app-dependency type generation (this
165
+ // used to parse informer.yaml twice). loadDependencies returns {}
166
+ // when there's no manifest and only throws on a malformed/unreadable
167
+ // one; the validators null-guard, so {} is a safe fallback.
168
+ let deps = {};
169
+ try {
170
+ deps = await loadDependencies(projectRoot);
171
+ } catch (err) {
172
+ console.warn(`[informer] Could not read informer.yaml dependencies: ${err.message}`);
173
+ }
174
+
123
175
  // Surface manifest-level dependency declaration errors at boot,
124
176
  // not at `npx informer publish` time. Matches the deploy.js
125
177
  // validation so devs see the same wording pre-deploy.
178
+ for (const message of validateDependencies(deps)) {
179
+ console.error(`[informer] informer.yaml: ${message}`);
180
+ }
181
+ // Catch a typo'd or mis-targeted devBinding at boot rather than
182
+ // letting the first call report the slot as unbound.
183
+ for (const message of validateDevBindings(deps, options.devBindings || {})) {
184
+ console.error(`[informer] vite.config.js: ${message}`);
185
+ }
186
+
187
+ // Generate .d.ts types for bound `target: app` deps from their
188
+ // published OpenAPI docs, so server/ handlers get typed
189
+ // context.<slot>.request()/query() autocomplete.
126
190
  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}`);
191
+ // A bare string devBinding is shorthand for { app } — request()
192
+ // works under it, so its types must generate too, not just for
193
+ // the object form.
194
+ const boundAppRef = (name) =>
195
+ resolveAppBinding(options.devBindings && options.devBindings[name]).app;
196
+ const appSlots = Object.entries(deps).filter(
197
+ ([name, decl]) => decl && decl.target === 'app' && boundAppRef(name)
198
+ );
199
+ if (appSlots.length) {
200
+ const specs = {};
201
+ let transientFailure = false; // a slot we couldn't reach (vs. a definitive "no doc")
202
+ for (const [name] of appSlots) {
203
+ const appRef = boundAppRef(name);
204
+ let spec;
205
+ try {
206
+ spec = await api.get(`apps/${appRef}/openapi.json`);
207
+ } catch (err) {
208
+ // 401/403 are as definitive as a 404 — the binding is
209
+ // wrong or unshared, and retrying changes nothing, so
210
+ // don't imply otherwise or mark the run transient.
211
+ if (err.status === 401 || err.status === 403) {
212
+ 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`);
213
+ continue;
214
+ }
215
+ // Network blip or a server-side failure: don't drop the
216
+ // slot — preserve its last-good types below.
217
+ transientFailure = true;
218
+ console.warn(`[informer] ${name}: could not reach ${appRef} OpenAPI (${err.message}) — keeping existing types if present`);
219
+ continue;
220
+ }
221
+ if (spec === null) {
222
+ // 404: app ref not found, not shared with you, or a server older
223
+ // than the openapi endpoint. A deploy doesn't fix any of these.
224
+ console.warn(`[informer] ${name}: ${appRef} returned 404 — check the app ref exists and is shared with you (or the server predates /openapi.json)`);
225
+ continue;
226
+ }
227
+ if (!spec.paths) {
228
+ console.warn(`[informer] ${name}: ${appRef} has no server routes to type yet — deploy its server/ handlers`);
229
+ continue;
230
+ }
231
+ specs[name] = spec;
232
+ }
233
+ const slotNames = Object.keys(specs);
234
+ if (slotNames.length) {
235
+ // A transient failure would otherwise rewrite the file without the
236
+ // unreachable slot, making its types vanish on a network blip. If we
237
+ // already have a generated file, keep it intact instead.
238
+ if (transientFailure && existsSync(resolve(projectRoot, '.informer', 'app-deps.d.ts'))) {
239
+ console.warn('[informer] some app dependencies were unreachable — keeping the last-good .informer/app-deps.d.ts');
240
+ } else {
241
+ await writeAppDepTypes(projectRoot, buildDeclarations(specs));
242
+ console.log(`[informer] wrote app-dependency types → .informer/app-deps.d.ts (${slotNames.join(', ')})`);
243
+ }
244
+ }
131
245
  }
132
246
  } catch (err) {
133
- console.warn(`[informer] Could not validate informer.yaml dependencies: ${err.message}`);
247
+ console.warn(`[informer] app-dependency type generation skipped: ${err.message}`);
134
248
  }
135
249
 
136
250
  // Mount server-side route handlers if a server/ directory exists
@@ -142,7 +256,12 @@ export default function informer(options = {}) {
142
256
  authHeader,
143
257
  devWorkspaceId,
144
258
  projectRoot,
145
- roles: (options.mock && options.mock.roles) || []
259
+ roles: (options.mock && options.mock.roles) || [],
260
+ // Dev-only bindings for `target: app` slots (override the
261
+ // manifest defaultBinding for local dev). Shape:
262
+ // devBindings: { kanban: { app: 'admin:kanban' } }
263
+ devBindings: options.devBindings || {},
264
+ appToken
146
265
  });
147
266
  server.middlewares.use('/api/_server', serverRoutes);
148
267
  }
@@ -156,7 +275,9 @@ export default function informer(options = {}) {
156
275
  serverOrigin,
157
276
  authHeader,
158
277
  devWorkspaceId,
159
- projectRoot
278
+ projectRoot,
279
+ devBindings: options.devBindings || {},
280
+ appToken
160
281
  });
161
282
  server.middlewares.use('/api/_agent', agentDev);
162
283
  }
@@ -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).