@entrinsik/vite-plugin-informer 2.6.0-beta.1 → 2.6.0
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 +11 -0
- package/index.d.ts +5 -3
- package/package.json +1 -1
- package/src/agent-dev.js +12 -10
- package/src/assemble.js +5 -2
- package/src/deploy.js +4 -1
- package/src/dev-dependencies.js +51 -26
- package/src/index.js +14 -10
- package/src/openapi-local.js +283 -0
- package/src/publish.js +8 -4
- package/src/server-routes.js +18 -4
package/bin/publish.js
CHANGED
|
@@ -4,6 +4,7 @@ import { readFile, access, readdir } from 'node:fs/promises';
|
|
|
4
4
|
import { resolve, join } from 'node:path';
|
|
5
5
|
import { collectAppFiles } from '../src/assemble.js';
|
|
6
6
|
import { extractNotes } from '../src/changelog.js';
|
|
7
|
+
import { buildLocalOpenApi } from '../src/openapi-local.js';
|
|
7
8
|
import { publish } from '../src/publish.js';
|
|
8
9
|
import { loadEnv, parseModeArg } from '../src/env.js';
|
|
9
10
|
|
|
@@ -63,6 +64,14 @@ try {
|
|
|
63
64
|
process.exit(1);
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
// Freeze the API contract with this version: build the OpenAPI doc from the
|
|
68
|
+
// local handlers (see openapi-local.js) and ship it in the archive as root
|
|
69
|
+
// openapi.json — the License Manager extracts it (like informer.yaml), stores
|
|
70
|
+
// it per version, and diffs the public surface against the previous version.
|
|
71
|
+
const { spec: apiSpec, warnings: apiWarnings } = await buildLocalOpenApi({ projectRoot, name, slug, version });
|
|
72
|
+
for (const w of apiWarnings) console.warn(` warning: ${w}`);
|
|
73
|
+
if (apiSpec) files.push({ rel: 'openapi.json', content: JSON.stringify(apiSpec, null, 2) });
|
|
74
|
+
|
|
66
75
|
const icon = await resolveIcon(projectRoot, inf.icon);
|
|
67
76
|
const screenshots = await resolveScreenshots(projectRoot, inf.screenshots);
|
|
68
77
|
const channel = version.includes('-') ? 'beta' : 'stable';
|
|
@@ -93,6 +102,8 @@ try {
|
|
|
93
102
|
}
|
|
94
103
|
});
|
|
95
104
|
console.log(`Published ${slug} v${version} (${result.channel || channel}).`);
|
|
105
|
+
// Server-side advisories (e.g. "public API changed without a major bump").
|
|
106
|
+
for (const w of result.warnings || []) console.warn(` warning: ${w}`);
|
|
96
107
|
} catch (err) {
|
|
97
108
|
console.error(err.message);
|
|
98
109
|
process.exit(1);
|
package/index.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { Plugin } from 'vite';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Dev-only binding for a `target: app` dependency slot.
|
|
5
|
-
* the target app in dev
|
|
6
|
-
* present
|
|
4
|
+
* Dev-only binding for a `target: app` or `target: pack` dependency slot.
|
|
5
|
+
* Points `request()` at the target app in dev. For app slots it overrides the
|
|
6
|
+
* manifest `defaultBinding` when both are present; for pack slots it is the
|
|
7
|
+
* only way to bind — the marketplace pin has no install to resolve against in
|
|
8
|
+
* dev, so point it at your locally-installed copy of the pack's app.
|
|
7
9
|
*
|
|
8
10
|
* - `app` — the target app (`owner:slug` or UUID). Powers `request()`.
|
|
9
11
|
*
|
package/package.json
CHANGED
package/src/agent-dev.js
CHANGED
|
@@ -29,21 +29,23 @@ async function loadInformerYaml(projectRoot) {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* Scan the tools/
|
|
32
|
+
* Scan the tools/ and mcp/ directories for .js files that export a handler.
|
|
33
|
+
* Both share one tool namespace; agents may reference tools from either.
|
|
33
34
|
*
|
|
34
35
|
* @param {string} projectRoot
|
|
35
36
|
* @returns {Promise<Array<{ name: string, filePath: string }>>}
|
|
36
37
|
*/
|
|
37
38
|
async function scanLocalTools(projectRoot) {
|
|
38
|
-
const toolsDir = join(projectRoot, 'tools');
|
|
39
|
-
try {
|
|
40
|
-
await access(toolsDir);
|
|
41
|
-
} catch {
|
|
42
|
-
return [];
|
|
43
|
-
}
|
|
44
|
-
|
|
45
39
|
const tools = [];
|
|
46
|
-
|
|
40
|
+
for (const dirName of ['tools', 'mcp']) {
|
|
41
|
+
const toolsDir = join(projectRoot, dirName);
|
|
42
|
+
try {
|
|
43
|
+
await access(toolsDir);
|
|
44
|
+
} catch {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
await walkToolFiles(toolsDir, dirName, tools);
|
|
48
|
+
}
|
|
47
49
|
return tools;
|
|
48
50
|
}
|
|
49
51
|
|
|
@@ -64,7 +66,7 @@ async function walkToolFiles(dir, basePath, results) {
|
|
|
64
66
|
await walkToolFiles(full, childPath, results);
|
|
65
67
|
} else if (item.endsWith('.js')) {
|
|
66
68
|
const name = childPath
|
|
67
|
-
.replace(/^tools\//, '')
|
|
69
|
+
.replace(/^(tools|mcp)\//, '')
|
|
68
70
|
.replace(/\.js$/, '')
|
|
69
71
|
.replace(/\//g, '_');
|
|
70
72
|
results.push({ name, filePath: full });
|
package/src/assemble.js
CHANGED
|
@@ -14,8 +14,11 @@ import { join, relative, posix } from 'node:path';
|
|
|
14
14
|
* (index.html, assets/…), while the source trees keep their directory prefix
|
|
15
15
|
* (server/…, tools/…) — matching how an app's library is structured in Informer.
|
|
16
16
|
*/
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
// API.md is the author-written integration guide: deployed to the library root
|
|
18
|
+
// (the live openapi.json route folds it into info.description) and tarred into
|
|
19
|
+
// the published archive for the marketplace listing's Integrate surface.
|
|
20
|
+
const ROOT_CONFIG_FILES = ['informer.yaml', 'data-access.yaml', 'API.md'];
|
|
21
|
+
const SOURCE_DIRS = ['migrations', 'tools', 'mcp', 'server', 'webhooks', 'lib', 'shared'];
|
|
19
22
|
|
|
20
23
|
// Entries never worth shipping in an app's server-side library: OS/editor
|
|
21
24
|
// dotfiles (.DS_Store, .env), nested dependency trees, and test files. Applied
|
package/src/deploy.js
CHANGED
|
@@ -105,7 +105,7 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
|
|
|
105
105
|
await api.post(`${entityPath}/files/_clear`);
|
|
106
106
|
|
|
107
107
|
// 6. Upload the app-library file set: dist output at the library root, plus
|
|
108
|
-
// informer.yaml / data-access.yaml and the server/tools/migrations/webhooks
|
|
108
|
+
// informer.yaml / data-access.yaml and the server/tools/mcp/migrations/webhooks
|
|
109
109
|
// source trees. Sourced from the shared collectAppFiles() so a deploy and a
|
|
110
110
|
// marketplace publish package byte-identical contents.
|
|
111
111
|
console.log('Uploading files...');
|
|
@@ -160,6 +160,9 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
|
|
|
160
160
|
if (result.tools && result.tools.length > 0) {
|
|
161
161
|
console.log(` Registered ${result.tools.length} tool(s): ${result.tools.join(', ')}`);
|
|
162
162
|
}
|
|
163
|
+
if (result.mcpTools && result.mcpTools.length > 0) {
|
|
164
|
+
console.log(` Registered ${result.mcpTools.length} MCP tool(s): ${result.mcpTools.join(', ')}`);
|
|
165
|
+
}
|
|
163
166
|
if (result.agents && result.agents.length > 0) {
|
|
164
167
|
console.log(` Deployed ${result.agents.length} agent(s): ${result.agents.join(', ')}`);
|
|
165
168
|
}
|
package/src/dev-dependencies.js
CHANGED
|
@@ -150,11 +150,13 @@ 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', 'app']);
|
|
153
|
+
const VALID_TARGETS = new Set(['dataset', 'query', 'datasource', 'integration', 'app', 'pack']);
|
|
154
154
|
const VALID_RUN_AS = new Set(['user', 'owner']);
|
|
155
155
|
|
|
156
156
|
// Targets that accept `defaultBinding:` in the manifest — mirrors
|
|
157
157
|
// DEFAULT_BINDING_LOOKUP in deploy.js. All resolve the target under read_access.
|
|
158
|
+
// `pack` is deliberately absent: its identity is the marketplace pin
|
|
159
|
+
// (`pack:` + `requires:`), and the installer consents per instance.
|
|
158
160
|
const DEFAULT_BINDABLE_TARGETS = new Set(['dataset', 'query', 'datasource', 'integration', 'app']);
|
|
159
161
|
|
|
160
162
|
const METHOD_SURFACE = {
|
|
@@ -162,7 +164,8 @@ const METHOD_SURFACE = {
|
|
|
162
164
|
query: ['execute'],
|
|
163
165
|
datasource: ['query'],
|
|
164
166
|
integration: ['request'],
|
|
165
|
-
app: ['request']
|
|
167
|
+
app: ['request'],
|
|
168
|
+
pack: ['request']
|
|
166
169
|
};
|
|
167
170
|
|
|
168
171
|
// Cross-app request() method allow-list — mirrors REQUEST_METHODS in
|
|
@@ -246,6 +249,11 @@ export function validateDependencies(deps) {
|
|
|
246
249
|
if (!VALID_RUN_AS.has(runAs)) {
|
|
247
250
|
errors.push(`dependency "${name}": runAs must be 'user' or 'owner' (got "${runAs}")`);
|
|
248
251
|
}
|
|
252
|
+
// `target: pack` pins marketplace identity at the top level of the
|
|
253
|
+
// declaration — same check (and wording) as deploy.js.
|
|
254
|
+
if (decl.target === 'pack' && (typeof decl.pack !== 'string' || typeof decl.requires !== 'string')) {
|
|
255
|
+
errors.push(`dependency "${name}" (pack) requires "pack: <marketplace slug>" and "requires: <semver range>"`);
|
|
256
|
+
}
|
|
249
257
|
if (decl.defaultBinding != null) {
|
|
250
258
|
if (!DEFAULT_BINDABLE_TARGETS.has(decl.target)) {
|
|
251
259
|
// Same rejection the deploy performs — surface it at dev boot
|
|
@@ -268,9 +276,12 @@ export function validateDependencies(deps) {
|
|
|
268
276
|
* the first call reports the slot as unbound — telling the developer to add the
|
|
269
277
|
* thing they think they already added.
|
|
270
278
|
*
|
|
271
|
-
* A binding key must name a declared `target: app`
|
|
272
|
-
* a non-empty `owner:slug` string (shorthand for
|
|
273
|
-
* non-empty `app` (the target app, for
|
|
279
|
+
* A binding key must name a declared `target: app` or `target: pack`
|
|
280
|
+
* dependency; its value must be a non-empty `owner:slug` string (shorthand for
|
|
281
|
+
* `{ app }`) or an object with a non-empty `app` (the target app, for
|
|
282
|
+
* request()). For a pack slot the value points at your locally-installed copy
|
|
283
|
+
* of the pack's app — dev has no marketplace install to resolve the pin
|
|
284
|
+
* against, so the developer says where it lives.
|
|
274
285
|
*
|
|
275
286
|
* @param {Object} deps - The raw `dependencies:` object from informer.yaml
|
|
276
287
|
* @param {Object} devBindings - The plugin's `devBindings` option
|
|
@@ -285,8 +296,8 @@ export function validateDevBindings(deps, devBindings) {
|
|
|
285
296
|
errors.push(`devBindings."${name}": no dependency "${name}" is declared in informer.yaml`);
|
|
286
297
|
continue;
|
|
287
298
|
}
|
|
288
|
-
if (decl.target !== 'app') {
|
|
289
|
-
errors.push(`devBindings."${name}": only "target: app" dependencies take a devBinding (got "${decl.target}")`);
|
|
299
|
+
if (decl.target !== 'app' && decl.target !== 'pack') {
|
|
300
|
+
errors.push(`devBindings."${name}": only "target: app" and "target: pack" dependencies take a devBinding (got "${decl.target}")`);
|
|
290
301
|
continue;
|
|
291
302
|
}
|
|
292
303
|
if (typeof binding === 'string') {
|
|
@@ -356,17 +367,20 @@ export function buildDevContext({ deps, apiFetch, devBindings = {}, appFetch = n
|
|
|
356
367
|
const target = decl.target;
|
|
357
368
|
if (!VALID_TARGETS.has(target)) continue;
|
|
358
369
|
|
|
359
|
-
// App slots bind from the plugin's `devBindings` (a human
|
|
360
|
-
// pointing request() at the target app)
|
|
361
|
-
// `defaultBinding` UUID
|
|
362
|
-
//
|
|
363
|
-
|
|
364
|
-
|
|
370
|
+
// App and pack slots bind from the plugin's `devBindings` (a human
|
|
371
|
+
// `owner:slug` pointing request() at the target app). App slots fall
|
|
372
|
+
// back to the manifest `defaultBinding` UUID; pack slots have no
|
|
373
|
+
// fallback — the pin resolves via marketplace installs, which dev
|
|
374
|
+
// doesn't have, so the devBinding names the locally-installed app.
|
|
375
|
+
// request() is the only surface either way.
|
|
376
|
+
if (target === 'app' || target === 'pack') {
|
|
377
|
+
const fallback = (target === 'app'
|
|
378
|
+
&& typeof decl.defaultBinding === 'string' && UUID_PATTERN.test(decl.defaultBinding))
|
|
365
379
|
? decl.defaultBinding
|
|
366
380
|
: null;
|
|
367
381
|
const binding = devBindings[name] != null ? devBindings[name] : fallback;
|
|
368
382
|
context[name] = binding
|
|
369
|
-
? makeAppDevProxy({ name, binding, appFetch })
|
|
383
|
+
? makeAppDevProxy({ name, binding, appFetch, kind: target })
|
|
370
384
|
: makeUnboundDevProxy({ name, target });
|
|
371
385
|
continue;
|
|
372
386
|
}
|
|
@@ -383,7 +397,12 @@ export function buildDevContext({ deps, apiFetch, devBindings = {}, appFetch = n
|
|
|
383
397
|
}
|
|
384
398
|
|
|
385
399
|
/**
|
|
386
|
-
* Dev proxy for a `target: app` slot. `request()` is the
|
|
400
|
+
* Dev proxy for a `target: app` or `target: pack` slot. `request()` is the
|
|
401
|
+
* only surface. Pack slots reuse this wholesale — in production the pack
|
|
402
|
+
* driver resolves its marketplace pin and then delegates the runtime to the
|
|
403
|
+
* app driver, and the devBinding IS that resolution done by hand. The prod
|
|
404
|
+
* version gate (pack_dependency_out_of_range) is not emulated: dev has no
|
|
405
|
+
* pack_install to read a version from.
|
|
387
406
|
*
|
|
388
407
|
* Production runs `request()` through the target's own /view/_/ dispatch, and
|
|
389
408
|
* dev injects into the same route:
|
|
@@ -404,29 +423,31 @@ export function buildDevContext({ deps, apiFetch, devBindings = {}, appFetch = n
|
|
|
404
423
|
* manifest defaultBinding UUID). A bare string is shorthand for `{ app }`.
|
|
405
424
|
* @param {Function|null} args.appFetch - token-authed fetch, or null when
|
|
406
425
|
* INFORMER_APP_TOKEN is unset.
|
|
426
|
+
* @param {'app'|'pack'} [args.kind] - slot flavor, for error labels and the
|
|
427
|
+
* structured resourceType guest code branches on.
|
|
407
428
|
*/
|
|
408
|
-
function makeAppDevProxy({ name, binding, appFetch }) {
|
|
429
|
+
function makeAppDevProxy({ name, binding, appFetch, kind = 'app' }) {
|
|
409
430
|
const { app } = resolveAppBinding(binding);
|
|
410
431
|
|
|
411
432
|
return {
|
|
412
433
|
async request(payload) {
|
|
413
434
|
if (!app) {
|
|
414
435
|
throw new Error(
|
|
415
|
-
`Dependency "${name}" (
|
|
436
|
+
`Dependency "${name}" (${kind}): dev request() needs the target app — set devBindings.${name}.app (e.g. 'admin:kanban')`
|
|
416
437
|
);
|
|
417
438
|
}
|
|
418
439
|
if (!appFetch) {
|
|
419
440
|
throw new Error(
|
|
420
|
-
`Dependency "${name}" (
|
|
441
|
+
`Dependency "${name}" (${kind}): dev request() needs a Bearer credential — the target's /view/_/ dispatch does not accept basic auth. Run in API-key mode (INFORMER_API_KEY), or create a token (Admin → Tokens, or POST /api/tokens) and set INFORMER_APP_TOKEN in .env`
|
|
421
442
|
);
|
|
422
443
|
}
|
|
423
444
|
const { method = 'GET', url, params, data } = payload || {};
|
|
424
445
|
const httpMethod = String(method).toUpperCase();
|
|
425
446
|
if (!REQUEST_METHODS.includes(httpMethod)) {
|
|
426
|
-
throw new Error(`Dependency "${name}" (
|
|
447
|
+
throw new Error(`Dependency "${name}" (${kind}): unsupported method "${method}"`);
|
|
427
448
|
}
|
|
428
449
|
if (!url || typeof url !== 'string') {
|
|
429
|
-
throw new Error(`Dependency "${name}" (
|
|
450
|
+
throw new Error(`Dependency "${name}" (${kind}): request({ url }) requires the target route path`);
|
|
430
451
|
}
|
|
431
452
|
const path = url.replace(/^\/+/, '');
|
|
432
453
|
const search = params ? `?${new URLSearchParams(params).toString()}` : '';
|
|
@@ -440,17 +461,17 @@ function makeAppDevProxy({ name, binding, appFetch }) {
|
|
|
440
461
|
try {
|
|
441
462
|
resolvedPath = new URL(`${targetPrefix}${path}${search}`, 'http://localhost').pathname;
|
|
442
463
|
} catch {
|
|
443
|
-
throw new Error(`Dependency "${name}" (
|
|
464
|
+
throw new Error(`Dependency "${name}" (${kind}): request({ url }) is malformed`);
|
|
444
465
|
}
|
|
445
466
|
if (!resolvedPath.startsWith(targetPrefix)) {
|
|
446
|
-
throw new Error(`Dependency "${name}" (
|
|
467
|
+
throw new Error(`Dependency "${name}" (${kind}): request({ url }) must not escape the target app with ".." path segments`);
|
|
447
468
|
}
|
|
448
469
|
const { status, body, contentType } = await appFetch(
|
|
449
470
|
`apps/${encodeURIComponent(app)}/view/_/${path}${search}`,
|
|
450
471
|
{ method: httpMethod, body: data }
|
|
451
472
|
);
|
|
452
473
|
if (status >= 400) {
|
|
453
|
-
throw dependencyCallError(name,
|
|
474
|
+
throw dependencyCallError(name, kind, status, body);
|
|
454
475
|
}
|
|
455
476
|
// Success-envelope contract mirrors entity-type/app.js: JSON → parsed
|
|
456
477
|
// body; non-JSON text/HTML → text envelope; binary → not emulatable
|
|
@@ -458,7 +479,7 @@ function makeAppDevProxy({ name, binding, appFetch }) {
|
|
|
458
479
|
// silently hand back mangled bytes.
|
|
459
480
|
if (isBinaryContentType(contentType)) {
|
|
460
481
|
throw new Error(
|
|
461
|
-
`Dependency "${name}" (
|
|
482
|
+
`Dependency "${name}" (${kind}): the dev proxy can't return binary responses yet (upstream content-type "${contentType}"). Test binary endpoints against a deployed build.`
|
|
462
483
|
);
|
|
463
484
|
}
|
|
464
485
|
// (An empty content-type gets the text envelope too, matching prod.)
|
|
@@ -514,10 +535,14 @@ function makeDevProxy({ name, target, targetId, apiFetch }) {
|
|
|
514
535
|
function makeUnboundDevProxy({ name, target }) {
|
|
515
536
|
const methods = METHOD_SURFACE[target] || [];
|
|
516
537
|
// App slots bind via devBindings (a dev-local owner:slug) or a manifest
|
|
517
|
-
// defaultBinding UUID
|
|
538
|
+
// defaultBinding UUID; pack slots ONLY via devBindings (the pin has no
|
|
539
|
+
// marketplace install to resolve against in dev). Point at whichever the
|
|
540
|
+
// reader is likelier to want.
|
|
518
541
|
const hint = target === 'app'
|
|
519
542
|
? `add \`devBindings: { ${name}: { app: '<owner:slug>' } }\` to the informer() plugin options in vite.config.js, or \`defaultBinding: <uuid>\` to its entry in informer.yaml`
|
|
520
|
-
:
|
|
543
|
+
: target === 'pack'
|
|
544
|
+
? `add \`devBindings: { ${name}: { app: '<owner:slug>' } }\` to the informer() plugin options in vite.config.js, pointing at your locally-installed copy of the pack`
|
|
545
|
+
: 'add `defaultBinding: <uuid>` to its entry in informer.yaml';
|
|
521
546
|
const proxy = {};
|
|
522
547
|
for (const method of methods) {
|
|
523
548
|
proxy[method] = async () => {
|
package/src/index.js
CHANGED
|
@@ -45,8 +45,10 @@ async function writeAppDepTypes (projectRoot, dts) {
|
|
|
45
45
|
* @param {Object} [options]
|
|
46
46
|
* @param {{ report?: object, theme?: 'light'|'dark', roles?: string[] }} [options.mock]
|
|
47
47
|
* window.__INFORMER__ mock injected in dev.
|
|
48
|
-
* @param {Object} [options.devBindings] - dev bindings for `target: app`
|
|
49
|
-
*
|
|
48
|
+
* @param {Object} [options.devBindings] - dev bindings for `target: app` and
|
|
49
|
+
* `target: pack` deps. App slots can't be defaultBound in the manifest; pack
|
|
50
|
+
* slots resolve their marketplace pin via installs dev doesn't have, so the
|
|
51
|
+
* binding names the locally-installed app. See AppDevBinding in index.d.ts.
|
|
50
52
|
* @param {Object} [options.proxy] - extra Vite proxy options merged onto /api.
|
|
51
53
|
* @returns {import('vite').Plugin}
|
|
52
54
|
*/
|
|
@@ -184,9 +186,9 @@ export default function informer(options = {}) {
|
|
|
184
186
|
console.error(`[informer] vite.config.js: ${message}`);
|
|
185
187
|
}
|
|
186
188
|
|
|
187
|
-
// Generate .d.ts types for bound `target: app`
|
|
188
|
-
// published OpenAPI docs, so server/ handlers get
|
|
189
|
-
// context.<slot>.request()/query() autocomplete.
|
|
189
|
+
// Generate .d.ts types for bound `target: app` / `target: pack`
|
|
190
|
+
// deps from their published OpenAPI docs, so server/ handlers get
|
|
191
|
+
// typed context.<slot>.request()/query() autocomplete.
|
|
190
192
|
try {
|
|
191
193
|
// A bare string devBinding is shorthand for { app } — request()
|
|
192
194
|
// works under it, so its types must generate too, not just for
|
|
@@ -194,7 +196,7 @@ export default function informer(options = {}) {
|
|
|
194
196
|
const boundAppRef = (name) =>
|
|
195
197
|
resolveAppBinding(options.devBindings && options.devBindings[name]).app;
|
|
196
198
|
const appSlots = Object.entries(deps).filter(
|
|
197
|
-
([name, decl]) => decl && decl.target === 'app' && boundAppRef(name)
|
|
199
|
+
([name, decl]) => decl && (decl.target === 'app' || decl.target === 'pack') && boundAppRef(name)
|
|
198
200
|
);
|
|
199
201
|
if (appSlots.length) {
|
|
200
202
|
const specs = {};
|
|
@@ -257,8 +259,9 @@ export default function informer(options = {}) {
|
|
|
257
259
|
devWorkspaceId,
|
|
258
260
|
projectRoot,
|
|
259
261
|
roles: (options.mock && options.mock.roles) || [],
|
|
260
|
-
// Dev-only bindings for `target: app`
|
|
261
|
-
//
|
|
262
|
+
// Dev-only bindings for `target: app` / `target: pack`
|
|
263
|
+
// slots (app: overrides the manifest defaultBinding; pack:
|
|
264
|
+
// names the locally-installed pack app). Shape:
|
|
262
265
|
// devBindings: { kanban: { app: 'admin:kanban' } }
|
|
263
266
|
devBindings: options.devBindings || {},
|
|
264
267
|
appToken
|
|
@@ -266,11 +269,12 @@ export default function informer(options = {}) {
|
|
|
266
269
|
server.middlewares.use('/api/_server', serverRoutes);
|
|
267
270
|
}
|
|
268
271
|
|
|
269
|
-
// Mount agent dev middleware if tools
|
|
272
|
+
// Mount agent dev middleware if tools/, mcp/, or informer.yaml agents exist
|
|
270
273
|
const toolsDir = resolve(projectRoot, 'tools');
|
|
274
|
+
const mcpDir = resolve(projectRoot, 'mcp');
|
|
271
275
|
const yamlPath = resolve(projectRoot, 'informer.yaml');
|
|
272
276
|
|
|
273
|
-
if (existsSync(toolsDir) || existsSync(yamlPath)) {
|
|
277
|
+
if (existsSync(toolsDir) || existsSync(mcpDir) || existsSync(yamlPath)) {
|
|
274
278
|
const agentDev = createAgentMiddleware(server, {
|
|
275
279
|
serverOrigin,
|
|
276
280
|
authHeader,
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { filePathToRoute, walkJsFiles } from './server-routes.js';
|
|
5
|
+
|
|
6
|
+
// Build the app's OpenAPI 3.0.3 document from the LOCAL project at publish
|
|
7
|
+
// time — the frozen, per-version contract the marketplace stores alongside the
|
|
8
|
+
// changelog. CI often has no access to the Informer host an app was developed
|
|
9
|
+
// against, so this cannot fetch the live /apps/{id}/openapi.json; instead the
|
|
10
|
+
// handlers are import()ed (this runs in the author's own project, where
|
|
11
|
+
// executing their code is exactly what vite does anyway) and their real
|
|
12
|
+
// exports — methods, config, schema, description — are read directly.
|
|
13
|
+
//
|
|
14
|
+
// The document-shaping half below is a deliberate port of
|
|
15
|
+
// informer-server/modules/app/lib/openapi-emitter.js (the deploy-time emitter):
|
|
16
|
+
// the frozen doc must match what a live instance serves for the same source.
|
|
17
|
+
// Keep the two in sync when either changes. This package publishes to npm on
|
|
18
|
+
// its own, so importing across the monorepo is not an option.
|
|
19
|
+
|
|
20
|
+
const VALID_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
21
|
+
const HTTP_METHOD_KEYS = new Set(VALID_METHODS);
|
|
22
|
+
|
|
23
|
+
// Fallback when a handler cannot be import()ed (a missing optional dep, a
|
|
24
|
+
// top-level env access that only resolves in dev): the file still contributes
|
|
25
|
+
// skeleton routes (method + path) so the frozen doc doesn't silently lose them.
|
|
26
|
+
const METHOD_EXPORT_RE = /export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE)\b|export\s+(?:const|let|var)\s+(GET|POST|PUT|PATCH|DELETE)\b/g;
|
|
27
|
+
|
|
28
|
+
function routePathToOpenApi(path) {
|
|
29
|
+
return path.replace(/:([A-Za-z0-9_]+)/g, '{$1}');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function pathParamNames(path) {
|
|
33
|
+
const names = new Set();
|
|
34
|
+
const re = /:([A-Za-z0-9_]+)/g;
|
|
35
|
+
let m;
|
|
36
|
+
while ((m = re.exec(path)) !== null) names.add(m[1]);
|
|
37
|
+
return [...names];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function operationId(method, path) {
|
|
41
|
+
const slug = path.split('/').filter(Boolean)
|
|
42
|
+
.map(seg => (seg.startsWith(':') ? `by_${seg.slice(1)}` : seg))
|
|
43
|
+
.join('_') || 'root';
|
|
44
|
+
return `${method.toLowerCase()}_${slug}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// OpenAPI 3.0.3 Schema Object keywords, by required value type (port of the
|
|
48
|
+
// server emitter's sanitizeSchema — see file header).
|
|
49
|
+
const SCHEMA_STRING_KEYS = new Set(['type', 'format', 'title', 'description', 'pattern']);
|
|
50
|
+
const SCHEMA_NUMBER_KEYS = new Set(['multipleOf', 'maximum', 'minimum', 'maxLength', 'minLength', 'maxItems', 'minItems', 'maxProperties', 'minProperties']);
|
|
51
|
+
const SCHEMA_BOOLEAN_KEYS = new Set(['exclusiveMaximum', 'exclusiveMinimum', 'uniqueItems', 'nullable', 'readOnly', 'writeOnly', 'deprecated']);
|
|
52
|
+
const SCHEMA_ANY_KEYS = new Set(['default', 'example']);
|
|
53
|
+
const SCHEMA_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'array', 'object']);
|
|
54
|
+
const MAX_SCHEMA_DEPTH = 32;
|
|
55
|
+
|
|
56
|
+
function sanitizeSchema(schema, depth = 0) {
|
|
57
|
+
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return null;
|
|
58
|
+
if (depth >= MAX_SCHEMA_DEPTH) return null;
|
|
59
|
+
|
|
60
|
+
const out = {};
|
|
61
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
62
|
+
if (SCHEMA_STRING_KEYS.has(key)) {
|
|
63
|
+
if (typeof value !== 'string') continue;
|
|
64
|
+
if (key === 'type' && !SCHEMA_TYPES.has(value)) continue;
|
|
65
|
+
out[key] = value;
|
|
66
|
+
} else if (SCHEMA_NUMBER_KEYS.has(key)) {
|
|
67
|
+
if (typeof value === 'number') out[key] = value;
|
|
68
|
+
} else if (SCHEMA_BOOLEAN_KEYS.has(key)) {
|
|
69
|
+
if (typeof value === 'boolean') out[key] = value;
|
|
70
|
+
} else if (SCHEMA_ANY_KEYS.has(key)) {
|
|
71
|
+
out[key] = value;
|
|
72
|
+
} else if (key === 'enum') {
|
|
73
|
+
if (Array.isArray(value) && value.length) out.enum = value;
|
|
74
|
+
} else if (key === 'required') {
|
|
75
|
+
const names = Array.isArray(value) ? value.filter(v => typeof v === 'string') : [];
|
|
76
|
+
if (names.length) out.required = names;
|
|
77
|
+
} else if (key === 'properties') {
|
|
78
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
|
|
79
|
+
const props = {};
|
|
80
|
+
for (const [name, sub] of Object.entries(value)) {
|
|
81
|
+
const clean = sanitizeSchema(sub, depth + 1);
|
|
82
|
+
props[name] = clean || {};
|
|
83
|
+
}
|
|
84
|
+
out.properties = props;
|
|
85
|
+
} else if (key === 'items' || key === 'not') {
|
|
86
|
+
const clean = sanitizeSchema(value, depth + 1);
|
|
87
|
+
if (clean) out[key] = clean;
|
|
88
|
+
} else if (key === 'allOf' || key === 'anyOf' || key === 'oneOf') {
|
|
89
|
+
const clean = (Array.isArray(value) ? value : []).map(v => sanitizeSchema(v, depth + 1)).filter(Boolean);
|
|
90
|
+
if (clean.length) out[key] = clean;
|
|
91
|
+
} else if (key === 'additionalProperties') {
|
|
92
|
+
if (typeof value === 'boolean') out.additionalProperties = value;
|
|
93
|
+
else {
|
|
94
|
+
const clean = sanitizeSchema(value, depth + 1);
|
|
95
|
+
if (clean) out.additionalProperties = clean;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// anything else (including $ref) is dropped
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (out.type === 'array' && !out.items) out.items = {};
|
|
102
|
+
|
|
103
|
+
return Object.keys(out).length ? out : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// A method-keyed `export const schema` returns its per-method slice; a flat
|
|
107
|
+
// schema applies to every method (mirror of the server scanner's sliceSchema).
|
|
108
|
+
function sliceSchema(schema, method) {
|
|
109
|
+
if (!schema || typeof schema !== 'object') return null;
|
|
110
|
+
const methodKeyed = Object.keys(schema).some(k => HTTP_METHOD_KEYS.has(k));
|
|
111
|
+
if (!methodKeyed) return schema;
|
|
112
|
+
return schema[method] || null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function buildOperation(row) {
|
|
116
|
+
const cfg = row.config || {};
|
|
117
|
+
const s = (row.schema && typeof row.schema === 'object') ? row.schema : {};
|
|
118
|
+
const successDesc = cfg.responseDescription ? String(cfg.responseDescription) : 'Success';
|
|
119
|
+
|
|
120
|
+
const op = {
|
|
121
|
+
operationId: operationId(row.method, row.path),
|
|
122
|
+
responses: {}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
if (cfg.summary) op.summary = String(cfg.summary);
|
|
126
|
+
const desc = cfg.description || row.description;
|
|
127
|
+
if (desc) op.description = String(desc);
|
|
128
|
+
if (cfg.deprecated) op.deprecated = true;
|
|
129
|
+
if (Array.isArray(cfg.tags) && cfg.tags.length) op.tags = cfg.tags.map(String);
|
|
130
|
+
|
|
131
|
+
const params = pathParamNames(row.path).map(name => ({
|
|
132
|
+
name,
|
|
133
|
+
in: 'path',
|
|
134
|
+
required: true,
|
|
135
|
+
schema: { type: 'string' }
|
|
136
|
+
}));
|
|
137
|
+
if (s.query && s.query.properties && typeof s.query.properties === 'object') {
|
|
138
|
+
const required = new Set(Array.isArray(s.query.required) ? s.query.required : []);
|
|
139
|
+
for (const [name, propSchema] of Object.entries(s.query.properties)) {
|
|
140
|
+
params.push({
|
|
141
|
+
name,
|
|
142
|
+
in: 'query',
|
|
143
|
+
required: required.has(name),
|
|
144
|
+
schema: sanitizeSchema(propSchema) || {}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (params.length) op.parameters = params;
|
|
149
|
+
|
|
150
|
+
const body = sanitizeSchema(s.body);
|
|
151
|
+
if (body) {
|
|
152
|
+
op.requestBody = { content: { 'application/json': { schema: body } } };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const response = sanitizeSchema(s.response);
|
|
156
|
+
if (response) {
|
|
157
|
+
op.responses['200'] = {
|
|
158
|
+
description: successDesc,
|
|
159
|
+
content: { 'application/json': { schema: response } }
|
|
160
|
+
};
|
|
161
|
+
} else {
|
|
162
|
+
op.responses['200'] = { description: successDesc };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (Array.isArray(cfg.roles) && cfg.roles.length) {
|
|
166
|
+
op['x-informer-roles'] = cfg.roles.map(String);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Curated public surface — see the server emitter for semantics.
|
|
170
|
+
if (cfg.api === 'public') {
|
|
171
|
+
op['x-informer-public'] = true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return op;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Import every server/ handler and read its real exports into emitter rows.
|
|
179
|
+
* Returns { rows, warnings }: a file that fails to import degrades to skeleton
|
|
180
|
+
* routes (regex-detected methods) with a warning, never a lost route.
|
|
181
|
+
*/
|
|
182
|
+
async function collectLocalRoutes(projectRoot) {
|
|
183
|
+
const files = await walkJsFiles(join(projectRoot, 'server'), 'server');
|
|
184
|
+
const rows = [];
|
|
185
|
+
const warnings = [];
|
|
186
|
+
|
|
187
|
+
for (const { relPath, absPath } of files) {
|
|
188
|
+
const routePath = filePathToRoute(relPath);
|
|
189
|
+
let mod = null;
|
|
190
|
+
try {
|
|
191
|
+
mod = await import(pathToFileURL(absPath).href);
|
|
192
|
+
} catch (err) {
|
|
193
|
+
warnings.push(`${relPath}: could not be imported (${err.message}) — frozen doc keeps its routes at skeleton level`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (mod) {
|
|
197
|
+
for (const method of VALID_METHODS) {
|
|
198
|
+
if (typeof mod[method] !== 'function') continue;
|
|
199
|
+
rows.push({
|
|
200
|
+
method,
|
|
201
|
+
path: routePath,
|
|
202
|
+
handlerPath: relPath,
|
|
203
|
+
config: (mod.config && typeof mod.config === 'object') ? mod.config : {},
|
|
204
|
+
description: typeof mod.description === 'string' ? mod.description : null,
|
|
205
|
+
schema: sliceSchema((mod.schema && typeof mod.schema === 'object') ? mod.schema : null, method)
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
const source = await readFile(absPath, 'utf8');
|
|
210
|
+
const methods = new Set();
|
|
211
|
+
let m;
|
|
212
|
+
while ((m = METHOD_EXPORT_RE.exec(source)) !== null) methods.add(m[1] || m[2]);
|
|
213
|
+
for (const method of methods) {
|
|
214
|
+
rows.push({ method, path: routePath, handlerPath: relPath, config: {}, description: null, schema: null });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Deterministic doc: same ordering the live endpoint uses.
|
|
220
|
+
rows.sort((a, b) => (a.path === b.path ? a.method.localeCompare(b.method) : a.path.localeCompare(b.path)));
|
|
221
|
+
return { rows, warnings };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Build the frozen OpenAPI document for a publish.
|
|
226
|
+
*
|
|
227
|
+
* The installing tenant's naturalId ({owner}:{slug}) is unknowable at publish
|
|
228
|
+
* time, so the dispatch base is emitted as an OpenAPI server variable with the
|
|
229
|
+
* slug as its default.
|
|
230
|
+
*
|
|
231
|
+
* @param {Object} opts
|
|
232
|
+
* @param {string} opts.projectRoot
|
|
233
|
+
* @param {string} opts.name - listing name (info.title)
|
|
234
|
+
* @param {string} opts.slug - marketplace slug
|
|
235
|
+
* @param {string} opts.version - the version being published (info.version)
|
|
236
|
+
* @returns {Promise<{ spec: Object|null, warnings: string[] }>} spec is null
|
|
237
|
+
* when the app has no server/ routes at all (nothing to freeze).
|
|
238
|
+
*/
|
|
239
|
+
export async function buildLocalOpenApi({ projectRoot, name, slug, version }) {
|
|
240
|
+
const { rows, warnings } = await collectLocalRoutes(projectRoot);
|
|
241
|
+
if (!rows.length) return { spec: null, warnings };
|
|
242
|
+
|
|
243
|
+
const paths = {};
|
|
244
|
+
const usedIds = new Set();
|
|
245
|
+
for (const row of rows) {
|
|
246
|
+
const oaPath = routePathToOpenApi(row.path);
|
|
247
|
+
paths[oaPath] = paths[oaPath] || {};
|
|
248
|
+
const op = buildOperation(row);
|
|
249
|
+
while (usedIds.has(op.operationId)) {
|
|
250
|
+
const m = op.operationId.match(/^(.*?)(?:_(\d+))?$/);
|
|
251
|
+
op.operationId = `${m[1]}_${(parseInt(m[2], 10) || 1) + 1}`;
|
|
252
|
+
}
|
|
253
|
+
usedIds.add(op.operationId);
|
|
254
|
+
paths[oaPath][row.method.toLowerCase()] = op;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let description;
|
|
258
|
+
try {
|
|
259
|
+
description = await readFile(join(projectRoot, 'API.md'), 'utf8');
|
|
260
|
+
} catch {
|
|
261
|
+
// no API.md — fall through to the generated blurb
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const spec = {
|
|
265
|
+
openapi: '3.0.3',
|
|
266
|
+
info: {
|
|
267
|
+
title: name || slug,
|
|
268
|
+
version: String(version),
|
|
269
|
+
description: description || (
|
|
270
|
+
`Auto-generated from the server/ routes of "${name || slug}". ` +
|
|
271
|
+
'Invoke a route through a cross-app dependency: context.<slot>.request({ method, url }).'
|
|
272
|
+
)
|
|
273
|
+
},
|
|
274
|
+
servers: [{
|
|
275
|
+
url: '/api/apps/{app}/view/_',
|
|
276
|
+
description: 'App server-route dispatch base ({app} is the installed naturalId, owner:name)',
|
|
277
|
+
variables: { app: { default: slug } }
|
|
278
|
+
}],
|
|
279
|
+
paths
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
return { spec, warnings };
|
|
283
|
+
}
|
package/src/publish.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdtemp, mkdir, copyFile, rm, readFile } from 'node:fs/promises';
|
|
1
|
+
import { mkdtemp, mkdir, copyFile, rm, readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join, dirname } from 'node:path';
|
|
4
4
|
import { execFile } from 'node:child_process';
|
|
@@ -24,17 +24,21 @@ export { PublishError };
|
|
|
24
24
|
* the server's node-tar expects; COPYFILE_DISABLE suppresses macOS AppleDouble
|
|
25
25
|
* (`._*`) junk from leaking into the archive.
|
|
26
26
|
*
|
|
27
|
-
*
|
|
27
|
+
* Entries carry either `abs` (copied from disk) or `content` (generated at
|
|
28
|
+
* publish time, e.g. the frozen openapi.json).
|
|
29
|
+
*
|
|
30
|
+
* @param {Array<{abs?: string, rel: string, content?: string}>} files
|
|
28
31
|
* @returns {Promise<Buffer>}
|
|
29
32
|
*/
|
|
30
33
|
async function buildArchive(files) {
|
|
31
34
|
const stage = await mkdtemp(join(tmpdir(), 'informer-publish-'));
|
|
32
35
|
const archivePath = `${stage}.tgz`;
|
|
33
36
|
try {
|
|
34
|
-
for (const { abs, rel } of files) {
|
|
37
|
+
for (const { abs, rel, content } of files) {
|
|
35
38
|
const dest = join(stage, rel);
|
|
36
39
|
await mkdir(dirname(dest), { recursive: true });
|
|
37
|
-
await
|
|
40
|
+
if (content !== undefined) await writeFile(dest, content);
|
|
41
|
+
else await copyFile(abs, dest);
|
|
38
42
|
}
|
|
39
43
|
await execFileP('tar', ['-czf', archivePath, '-C', stage, '.'], {
|
|
40
44
|
env: { ...process.env, COPYFILE_DISABLE: '1' }
|
package/src/server-routes.js
CHANGED
|
@@ -9,9 +9,21 @@ const VALID_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
|
9
9
|
// hanging the handler.
|
|
10
10
|
const FETCH_TIMEOUT_MS = 30000;
|
|
11
11
|
|
|
12
|
-
// Strict base64 — see view-api.js for the rationale. Mirror kept identical
|
|
13
|
-
//
|
|
14
|
-
|
|
12
|
+
// Strict base64 — see view-api.js for the rationale. Mirror kept identical to
|
|
13
|
+
// keep dev and prod behavior aligned, down to the scan: the quantified
|
|
14
|
+
// /^[A-Za-z0-9+/]+={0,2}$/ throws "RangeError: Maximum call stack size
|
|
15
|
+
// exceeded" instead of answering once a body passes ~4M characters, so dev
|
|
16
|
+
// would 500 on exactly the large images and PDFs prod serves fine.
|
|
17
|
+
const NON_BASE64 = /[^A-Za-z0-9+/]/; // unquantified: never backtracks
|
|
18
|
+
|
|
19
|
+
function isBase64(s) {
|
|
20
|
+
if (typeof s !== 'string') return false;
|
|
21
|
+
let end = s.length;
|
|
22
|
+
while (end > 0 && s.charCodeAt(end - 1) === 0x3d /* '=' */) end--;
|
|
23
|
+
if (s.length - end > 2) return false;
|
|
24
|
+
if (end === 0) return true; // '', '=', '==' → zero bytes
|
|
25
|
+
return !NON_BASE64.test(end === s.length ? s : s.slice(0, end));
|
|
26
|
+
}
|
|
15
27
|
|
|
16
28
|
/**
|
|
17
29
|
* Convert a file path under server/ to a route path.
|
|
@@ -120,6 +132,8 @@ async function scanRoutes(serverDir) {
|
|
|
120
132
|
}));
|
|
121
133
|
}
|
|
122
134
|
|
|
135
|
+
export { filePathToRoute, walkJsFiles };
|
|
136
|
+
|
|
123
137
|
async function walkJsFiles(dir, basePath) {
|
|
124
138
|
const results = [];
|
|
125
139
|
let items;
|
|
@@ -398,7 +412,7 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
|
|
|
398
412
|
if (responseBody === null) {
|
|
399
413
|
res.end();
|
|
400
414
|
} else if (encoding === 'base64' && typeof responseBody === 'string') {
|
|
401
|
-
if (!
|
|
415
|
+
if (!isBase64(responseBody)) {
|
|
402
416
|
throw new Error('Handler returned malformed base64 body');
|
|
403
417
|
}
|
|
404
418
|
res.end(Buffer.from(responseBody, 'base64'));
|