@entrinsik/vite-plugin-informer 2.5.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.
@@ -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/publish.js ADDED
@@ -0,0 +1,103 @@
1
+ import { mkdtemp, mkdir, copyFile, rm, readFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join, dirname } from 'node:path';
4
+ import { execFile } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+
7
+ const execFileP = promisify(execFile);
8
+
9
+ class PublishError extends Error {
10
+ constructor(status, body, url) {
11
+ super(`Marketplace publish failed: ${status}${body ? ` — ${body}` : ''}${url ? ` (${url})` : ''}`);
12
+ this.name = 'PublishError';
13
+ this.status = status;
14
+ this.body = body;
15
+ this.url = url;
16
+ }
17
+ }
18
+
19
+ export { PublishError };
20
+
21
+ /**
22
+ * Stage the assembled files into a temp dir (preserving their library-relative
23
+ * layout) and tar.gz them. System `tar` keeps the archive format exactly what
24
+ * the server's node-tar expects; COPYFILE_DISABLE suppresses macOS AppleDouble
25
+ * (`._*`) junk from leaking into the archive.
26
+ *
27
+ * @param {Array<{abs: string, rel: string}>} files
28
+ * @returns {Promise<Buffer>}
29
+ */
30
+ async function buildArchive(files) {
31
+ const stage = await mkdtemp(join(tmpdir(), 'informer-publish-'));
32
+ const archivePath = `${stage}.tgz`;
33
+ try {
34
+ for (const { abs, rel } of files) {
35
+ const dest = join(stage, rel);
36
+ await mkdir(dirname(dest), { recursive: true });
37
+ await copyFile(abs, dest);
38
+ }
39
+ await execFileP('tar', ['-czf', archivePath, '-C', stage, '.'], {
40
+ env: { ...process.env, COPYFILE_DISABLE: '1' }
41
+ });
42
+ return await readFile(archivePath);
43
+ } finally {
44
+ await rm(stage, { recursive: true, force: true });
45
+ await rm(archivePath, { force: true });
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Publish an assembled app to the marketplace (License Manager `/packs/publish`).
51
+ *
52
+ * @param {Object} opts
53
+ * @param {string} opts.marketplaceUrl - base URL fronting the cloud-api
54
+ * @param {string} opts.token - vendor publish token (sent as Bearer)
55
+ * @param {Array<{abs: string, rel: string}>} opts.files - assembled app files
56
+ * @param {Object} opts.fields - publish metadata; non-string values are JSON-encoded
57
+ * (the endpoint parses categories/requires/metadata as JSON strings)
58
+ * @param {{abs: string, filename: string}} [opts.icon] - optional listing icon
59
+ * @returns {Promise<Object>} the publish response
60
+ */
61
+ export async function publish({ marketplaceUrl, token, files, fields, icon, screenshots = [] }) {
62
+ const archive = await buildArchive(files);
63
+
64
+ const form = new FormData();
65
+ for (const [key, value] of Object.entries(fields)) {
66
+ if (value === undefined || value === null || value === '') continue;
67
+ form.append(key, typeof value === 'string' ? value : JSON.stringify(value));
68
+ }
69
+ form.append(
70
+ 'archive',
71
+ new Blob([archive], { type: 'application/gzip' }),
72
+ `${fields.slug}-${fields.version}.tgz`
73
+ );
74
+ if (icon) {
75
+ const iconBuffer = await readFile(icon.abs);
76
+ form.append('icon', new Blob([iconBuffer]), icon.filename);
77
+ }
78
+ // Listing screenshots — appended in order; the server records sortOrder by
79
+ // the order parts arrive.
80
+ for (const shot of screenshots) {
81
+ const buffer = await readFile(shot.abs);
82
+ form.append('screenshots', new Blob([buffer]), shot.filename);
83
+ }
84
+
85
+ const url = `${marketplaceUrl.replace(/\/+$/, '')}/packs/publish`;
86
+ const res = await fetch(url, {
87
+ method: 'POST',
88
+ headers: { Authorization: `Bearer ${token}` },
89
+ body: form
90
+ });
91
+
92
+ if (!res.ok) {
93
+ let body = '';
94
+ try {
95
+ body = await res.text();
96
+ } catch {
97
+ body = '';
98
+ }
99
+ throw new PublishError(res.status, body, url);
100
+ }
101
+
102
+ return await res.json();
103
+ }
@@ -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).