@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.
- package/bin/publish.js +147 -0
- package/index.d.ts +25 -1
- package/package.json +6 -4
- package/src/agent-dev.js +41 -9
- package/src/assemble.js +96 -0
- package/src/changelog.js +39 -0
- package/src/deploy.js +18 -144
- package/src/dev-dependencies.js +243 -13
- package/src/index.js +138 -17
- package/src/openapi-to-dts.js +168 -0
- package/src/publish.js +103 -0
- package/src/server-routes.js +56 -10
|
@@ -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
|
+
}
|
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).
|