@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 +0 -0
- package/index.d.ts +25 -1
- package/package.json +4 -1
- package/src/agent-dev.js +41 -9
- package/src/assemble.js +14 -5
- package/src/dev-dependencies.js +243 -13
- package/src/index.js +138 -17
- package/src/openapi-to-dts.js +168 -0
- package/src/server-routes.js +56 -10
package/bin/publish.js
CHANGED
|
File without changes
|
package/index.d.ts
CHANGED
|
@@ -1,2 +1,26 @@
|
|
|
1
1
|
import type { Plugin } from 'vite';
|
|
2
|
-
|
|
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.
|
|
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
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
192
|
-
return { status
|
|
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/…,
|
|
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/dev-dependencies.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
342
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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]
|
|
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 };
|
package/src/server-routes.js
CHANGED
|
@@ -5,6 +5,10 @@ import { loadDependencies, buildDevContext, loadAppEnv, buildDevCrypto, buildDev
|
|
|
5
5
|
|
|
6
6
|
const VALID_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
7
7
|
|
|
8
|
+
// Cap dev proxy calls at 30s so a hung upstream fails loudly instead of
|
|
9
|
+
// hanging the handler.
|
|
10
|
+
const FETCH_TIMEOUT_MS = 30000;
|
|
11
|
+
|
|
8
12
|
// Strict base64 — see view-api.js for the rationale. Mirror kept identical
|
|
9
13
|
// to keep dev and prod behavior aligned.
|
|
10
14
|
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
@@ -144,10 +148,17 @@ async function walkJsFiles(dir, basePath) {
|
|
|
144
148
|
* Create Connect middleware for dev-mode server route execution.
|
|
145
149
|
*
|
|
146
150
|
* @param {Object} viteServer - Vite dev server instance
|
|
147
|
-
* @param {
|
|
151
|
+
* @param {Object} opts
|
|
152
|
+
* @param {string} opts.serverOrigin - Informer server origin
|
|
153
|
+
* @param {string} opts.authHeader - Basic or Bearer auth header for /api calls
|
|
154
|
+
* @param {string|null} opts.devWorkspaceId - workspace datasource id for query()
|
|
155
|
+
* @param {string} opts.projectRoot - app project root
|
|
156
|
+
* @param {string[]} [opts.roles] - dev user roles surfaced on request.roles
|
|
157
|
+
* @param {Object} [opts.devBindings] - dev bindings for `target: app` deps
|
|
158
|
+
* @param {string|null} [opts.appToken] - INFORMER_APP_TOKEN for cross-app request()
|
|
148
159
|
* @returns {Function} Connect middleware
|
|
149
160
|
*/
|
|
150
|
-
export function createMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, roles }) {
|
|
161
|
+
export function createMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, roles, devBindings, appToken }) {
|
|
151
162
|
const serverDir = join(projectRoot, 'server');
|
|
152
163
|
|
|
153
164
|
// query() implementation — proxies to the workspace _sql endpoint
|
|
@@ -173,30 +184,65 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
|
|
|
173
184
|
}
|
|
174
185
|
|
|
175
186
|
// fetch() implementation — proxies API calls to the Informer server
|
|
176
|
-
async function
|
|
187
|
+
async function fetchAs(auth, path, opts = {}) {
|
|
177
188
|
const method = (opts.method || 'GET').toUpperCase();
|
|
178
189
|
// Same strict canonicalization as prod (app-sandbox.js normalizeFetchPath);
|
|
179
190
|
// reject non-canonical shapes here instead of silently accepting them.
|
|
180
191
|
const apiPath = normalizeFetchPath(path);
|
|
181
192
|
if (!apiPath) {
|
|
182
|
-
return { status: 400, body: { error: `Invalid fetch path: ${String(path).slice(0, 80)}` } };
|
|
193
|
+
return { status: 400, body: { error: `Invalid fetch path: ${String(path).slice(0, 80)}` }, contentType: 'application/json' };
|
|
183
194
|
}
|
|
184
195
|
const url = `${serverOrigin}${apiPath}`;
|
|
185
196
|
const fetchOpts = {
|
|
186
197
|
method,
|
|
187
|
-
headers: { Authorization:
|
|
198
|
+
headers: { Authorization: auth, 'Content-Type': 'application/json', ...(opts.headers || {}) }
|
|
188
199
|
};
|
|
189
200
|
|
|
190
|
-
if (opts.body && ['POST', 'PUT', 'PATCH'].includes(method)) {
|
|
201
|
+
if (opts.body && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
|
191
202
|
fetchOpts.body = JSON.stringify(opts.body);
|
|
192
203
|
}
|
|
193
204
|
|
|
194
|
-
|
|
205
|
+
// Read the stream exactly once — `.json()` consumes/locks the body, so a
|
|
206
|
+
// `.text()` fallback would throw "Body is unusable" on any non-JSON
|
|
207
|
+
// response (auth-bounce HTML, proxy error page). Parse in memory
|
|
208
|
+
// instead — same as prod's unwrapInject.
|
|
209
|
+
let status, contentType, text;
|
|
210
|
+
try {
|
|
211
|
+
const resp = await globalThis.fetch(url, { ...fetchOpts, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
212
|
+
status = resp.status;
|
|
213
|
+
contentType = resp.headers.get('content-type') || '';
|
|
214
|
+
text = await resp.text();
|
|
215
|
+
} catch (err) {
|
|
216
|
+
// Transport failure or timeout — fetch throws (TypeError 'fetch failed'
|
|
217
|
+
// with the real reason on err.cause, or a TimeoutError). Return a
|
|
218
|
+
// synthetic 502 so the dependency layer names it (dep + url + cause)
|
|
219
|
+
// rather than a bare unhandled "fetch failed".
|
|
220
|
+
const reason = (err.cause && err.cause.message) || err.message;
|
|
221
|
+
return { status: 502, body: { message: `fetch to ${url} failed: ${reason}` }, contentType: 'application/json' };
|
|
222
|
+
}
|
|
195
223
|
let body;
|
|
196
|
-
try { body =
|
|
197
|
-
return { status
|
|
224
|
+
try { body = JSON.parse(text); } catch { body = text; }
|
|
225
|
+
return { status, body, contentType };
|
|
198
226
|
}
|
|
199
227
|
|
|
228
|
+
async function apiFetch(path, opts = {}) {
|
|
229
|
+
return await fetchAs(authHeader, path, opts);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Cross-app request() targets /api/apps/<id>/view/_/<path>, whose auth accepts
|
|
233
|
+
// only the token/session strategies — NOT basic auth. In API-key mode the
|
|
234
|
+
// INFORMER_API_KEY Bearer already satisfies that, so reuse it; under basic
|
|
235
|
+
// auth a separate API token (INFORMER_APP_TOKEN) is required, and without one
|
|
236
|
+
// the app proxy's request() throws a pointed error instead of a bare 401.
|
|
237
|
+
// appFetch also stamps x-informer-app-depth:1 so the target runs one hop deep
|
|
238
|
+
// and enforces the same one-hop guard it does in production.
|
|
239
|
+
const appAuth = appToken
|
|
240
|
+
? `Bearer ${appToken}`
|
|
241
|
+
: (authHeader && authHeader.startsWith('Bearer ') ? authHeader : null);
|
|
242
|
+
const appFetch = appAuth
|
|
243
|
+
? async (path, opts = {}) => await fetchAs(appAuth, path, { ...opts, headers: { ...(opts.headers || {}), 'x-informer-app-depth': '1' } })
|
|
244
|
+
: null;
|
|
245
|
+
|
|
200
246
|
return async function serverRoutesMiddleware(req, res, next) {
|
|
201
247
|
try {
|
|
202
248
|
// The URL has already had /api/_server stripped by Vite's middleware.use()
|
|
@@ -307,7 +353,7 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
|
|
|
307
353
|
// work locally. Loaded per request so edits to informer.yaml take
|
|
308
354
|
// effect without a dev-server restart.
|
|
309
355
|
const deps = await loadDependencies(projectRoot);
|
|
310
|
-
const context = buildDevContext({ deps, apiFetch });
|
|
356
|
+
const context = buildDevContext({ deps, apiFetch, devBindings, appFetch });
|
|
311
357
|
const env = await loadAppEnv(projectRoot);
|
|
312
358
|
|
|
313
359
|
// Call handler — bag mirrors the prod sandbox (see app-sandbox.js).
|