@entrinsik/vite-plugin-informer 2.1.0 → 2.3.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/init.js CHANGED
@@ -8,141 +8,59 @@ import { randomUUID } from 'node:crypto';
8
8
  const cwd = process.cwd();
9
9
 
10
10
  /**
11
- * Generate the default data-access.yaml content with helpful comments
11
+ * Generate the default informer.yaml content with helpful comments
12
12
  */
13
- function generateDataAccessYaml() {
14
- return `# Data Access Configuration
15
- # ========================
16
- # This file controls which Informer APIs your report can access.
17
- # Without this file, all API access is blocked (secure by default).
13
+ function generateInformerYaml() {
14
+ return `# Informer App Configuration
15
+ # =========================
16
+ # This file controls which Informer APIs your app can access and defines
17
+ # custom roles for role-based UIs.
18
18
  #
19
- # Documentation: https://docs.entrinsik.com/informer/magic-reports/data-access
19
+ # Without an access section, all API access is blocked (secure by default).
20
20
 
21
21
  # ============================================================================
22
- # DATASETS
22
+ # DATA ACCESS
23
23
  # ============================================================================
24
- # Grant access to dataset search and field metadata.
25
- # Each entry generates:
26
- # - POST /api/datasets/{id}/_search
27
- # - GET /api/datasets/{id}/fields
24
+ # The access section controls which APIs the app can call when shared.
28
25
  #
29
- # Simple access (full dataset):
26
+ # access:
30
27
  # datasets:
31
- # - admin:sales-data
32
- # - admin:customers
33
- #
34
- # With row-level security (filter injected server-side):
35
- # datasets:
36
- # - id: admin:orders
28
+ # - admin:sales-data # Full dataset access
29
+ # - id: admin:orders # With row-level security
37
30
  # filter:
38
- # region: $user.custom.region # User only sees their region
39
- # sales_rep: $user.username # User only sees their own records
40
- #
41
- datasets: []
42
-
43
- # ============================================================================
44
- # QUERIES
45
- # ============================================================================
46
- # Grant access to execute saved queries.
47
- # Each entry generates:
48
- # - POST /api/queries/{id}/_execute
31
+ # region: $user.custom.region
49
32
  #
50
- # Example:
51
33
  # queries:
52
- # - admin:daily-summary
53
- # - admin:monthly-report
54
- #
55
- queries: []
56
-
57
- # ============================================================================
58
- # INTEGRATIONS
59
- # ============================================================================
60
- # Grant access to make requests through integrations (Salesforce, REST APIs, etc.)
61
- # Each entry generates:
62
- # - POST /api/integrations/{id}/request
34
+ # - admin:monthly-summary
63
35
  #
64
- # Simple access:
65
36
  # integrations:
66
37
  # - salesforce
67
- # - quickbooks
68
- #
69
- # With credential injection (headers/params expanded server-side, never exposed to JS):
70
- # integrations:
71
- # - id: partner-api
72
- # headers:
73
- # Authorization: Bearer $user.custom.partnerToken
74
- # X-Client-ID: $tenant.id
75
- # params:
76
- # user_id: $user.custom.externalId
77
- #
78
- # With path restrictions (only allow specific endpoints):
79
- # integrations:
80
- # - id: salesforce
81
- # paths:
82
- # - /data/*/query
83
- # - /data/*/sobjects/*
84
- #
85
- integrations: []
86
-
87
- # ============================================================================
88
- # DATASOURCES
89
- # ============================================================================
90
- # Grant access to run SQL queries against datasources.
91
- # Each entry generates:
92
- # - POST /api/datasources/{id}/_query
93
38
  #
94
- # Example:
95
39
  # datasources:
96
40
  # - postgres-main
97
- # - mysql-analytics
98
- #
99
- datasources: []
100
-
101
- # ============================================================================
102
- # LIBRARIES
103
- # ============================================================================
104
- # Grant access to read files from other libraries.
105
- # Each entry generates:
106
- # - GET /api/libraries/{id}/contents/*
107
- #
108
- # Example:
109
- # libraries:
110
- # - admin:shared-assets
111
- # - admin:common-templates
112
- #
113
- libraries: []
114
-
115
- # ============================================================================
116
- # RAW API ACCESS (Advanced)
117
- # ============================================================================
118
- # For edge cases not covered by resource types above.
119
- # Specify exact method and path.
120
41
  #
121
- # Example:
122
- # apis:
42
+ # apis: # Raw API paths (advanced)
123
43
  # - POST /api/custom/endpoint
124
- # - GET /api/special/resource
125
- #
126
- apis: []
44
+
45
+ access:
46
+ datasets: []
47
+ queries: []
48
+ integrations: []
127
49
 
128
50
  # ============================================================================
129
- # VARIABLE REFERENCE
51
+ # ROLES (optional)
130
52
  # ============================================================================
131
- # Variables are expanded server-side, keeping sensitive values secure.
132
- #
133
- # User variables:
134
- # $user.username - Login name
135
- # $user.email - Email address
136
- # $user.displayName - Full name (e.g., "John Smith")
137
- # $user.custom.xxx - Custom user field value
138
- #
139
- # Tenant variables:
140
- # $tenant.id - Tenant identifier
141
- #
142
- # Report variables:
143
- # $report.id - Report UUID
144
- # $report.name - Report display name
53
+ # Define custom roles that publishers assign when sharing the app.
54
+ # Roles are available in client code via window.__INFORMER__.roles
55
+ # and in server handlers via request.roles.
145
56
  #
57
+ # roles:
58
+ # - id: viewer
59
+ # name: Viewer
60
+ # description: Can view reports but not take actions
61
+ # - id: approver
62
+ # name: Approver
63
+ # description: Can approve or reject requests
146
64
  `;
147
65
  }
148
66
 
@@ -187,7 +105,7 @@ function packageNameToDisplayName(name) {
187
105
  }
188
106
 
189
107
  async function init() {
190
- console.log('Initializing Informer Magic Report project...\n');
108
+ console.log('Initializing Informer App project...\n');
191
109
 
192
110
  // 1. Check for package.json
193
111
  const pkgPath = resolve(cwd, 'package.json');
@@ -210,9 +128,9 @@ async function init() {
210
128
  // 3. Get report info from user
211
129
  const defaultName = pkg.informer?.name || packageNameToDisplayName(pkg.name || basename(cwd));
212
130
 
213
- console.log('Configure your Magic Report:\n');
131
+ console.log('Configure your app:\n');
214
132
 
215
- const reportName = await prompt('Report name', defaultName);
133
+ const reportName = await prompt('App name', defaultName);
216
134
 
217
135
  console.log('');
218
136
 
@@ -276,11 +194,11 @@ INFORMER_API_KEY=your-api-key
276
194
  console.log('Created .env (update with your credentials)');
277
195
  }
278
196
 
279
- // 9. Create data-access.yaml if it doesn't exist
280
- const dataAccessPath = resolve(cwd, 'data-access.yaml');
281
- if (!await exists(dataAccessPath)) {
282
- await writeFile(dataAccessPath, generateDataAccessYaml());
283
- console.log('Created data-access.yaml (configure API access for your report)');
197
+ // 9. Create informer.yaml if it doesn't exist
198
+ const informerYamlPath = resolve(cwd, 'informer.yaml');
199
+ if (!await exists(informerYamlPath)) {
200
+ await writeFile(informerYamlPath, generateInformerYaml());
201
+ console.log('Created informer.yaml (configure API access and roles)');
284
202
  }
285
203
 
286
204
  // 10. Add .env to .gitignore if not present
@@ -289,11 +207,9 @@ INFORMER_API_KEY=your-api-key
289
207
  console.log('\nSetup complete!\n');
290
208
  console.log('Next steps:');
291
209
  console.log(' 1. Update .env with your Informer credentials');
292
- console.log(' 2. Update data-access.yaml with the datasets/APIs your report needs');
210
+ console.log(' 2. Update informer.yaml with the datasets/APIs your app needs');
293
211
  console.log(' 3. Run: npm install');
294
212
  console.log(' 4. Run: npm run dev');
295
- console.log(' 5. Install the Claude skill: /plugin marketplace add entrinsik-org/claude-plugins');
296
- console.log(' 6. Then: /plugin install magic-reports@entrinsik-plugins');
297
213
  console.log('');
298
214
  }
299
215
 
package/package.json CHANGED
@@ -1,46 +1,47 @@
1
1
  {
2
- "name": "@entrinsik/vite-plugin-informer",
3
- "version": "2.1.0",
4
- "description": "Vite plugin and publish tool for local Magic Report development",
5
- "repository": {
6
- "type": "git",
7
- "url": "https://github.com/entrinsik-org/i5.git",
8
- "directory": "packages/vite-plugin-informer"
9
- },
10
- "publishConfig": {
11
- "registry": "https://docker.entrinsik.com/repository/entNPM/"
12
- },
13
- "author": "Entrinsik Inc.",
14
- "license": "UNLICENSED",
15
- "type": "module",
16
- "types": "index.d.ts",
17
- "main": "./src/index.js",
18
- "engines": {
19
- "node": ">=18.0.0"
20
- },
21
- "exports": {
22
- ".": {
23
- "types": "./index.d.ts",
24
- "default": "./src/index.js"
2
+ "name": "@entrinsik/vite-plugin-informer",
3
+ "version": "2.3.0",
4
+ "description": "Vite plugin and deploy tool for Informer App development",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/entrinsik-org/i5.git",
8
+ "directory": "packages/vite-plugin-informer"
25
9
  },
26
- "./deploy": "./src/deploy.js",
27
- "./workspace": "./src/workspace.js"
28
- },
29
- "bin": {
30
- "informer-deploy": "./bin/deploy.js",
31
- "informer-init": "./bin/init.js",
32
- "informer-workspace": "./bin/workspace.js",
33
- "create-magic-report": "./bin/init.js"
34
- },
35
- "files": [
36
- "src",
37
- "bin",
38
- "index.d.ts"
39
- ],
40
- "peerDependencies": {
41
- "vite": ">=5.0.0"
42
- },
43
- "dependencies": {
44
- "dotenv": "16.6.1"
45
- }
10
+ "publishConfig": {
11
+ "registry": "https://docker.entrinsik.com/repository/entNPM/"
12
+ },
13
+ "author": "Entrinsik Inc.",
14
+ "license": "UNLICENSED",
15
+ "type": "module",
16
+ "types": "index.d.ts",
17
+ "main": "./src/index.js",
18
+ "engines": {
19
+ "node": ">=18.0.0"
20
+ },
21
+ "exports": {
22
+ ".": {
23
+ "types": "./index.d.ts",
24
+ "default": "./src/index.js"
25
+ },
26
+ "./deploy": "./src/deploy.js",
27
+ "./workspace": "./src/workspace.js"
28
+ },
29
+ "bin": {
30
+ "informer-deploy": "./bin/deploy.js",
31
+ "informer-init": "./bin/init.js",
32
+ "informer-workspace": "./bin/workspace.js",
33
+ "create-magic-report": "./bin/init.js"
34
+ },
35
+ "files": [
36
+ "src",
37
+ "bin",
38
+ "index.d.ts"
39
+ ],
40
+ "peerDependencies": {
41
+ "vite": ">=5.0.0"
42
+ },
43
+ "dependencies": {
44
+ "dotenv": "16.6.1",
45
+ "yaml": "^2.7.0"
46
+ }
46
47
  }
@@ -0,0 +1,422 @@
1
+ import { readFile, readdir, stat, access } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { parse as parseUrl } from 'node:url';
4
+ import yaml from 'yaml';
5
+ const parseYaml = yaml.parse;
6
+
7
+ const MAX_STEPS = 20;
8
+
9
+ /**
10
+ * Read and parse informer.yaml from the project root.
11
+ *
12
+ * @param {string} projectRoot
13
+ * @returns {Promise<Object|null>} Parsed YAML or null
14
+ */
15
+ async function loadInformerYaml(projectRoot) {
16
+ const yamlPath = join(projectRoot, 'informer.yaml');
17
+ try {
18
+ await access(yamlPath);
19
+ const content = await readFile(yamlPath, 'utf8');
20
+ return parseYaml(content);
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Scan the tools/ directory for .js files that export a handler.
28
+ *
29
+ * @param {string} projectRoot
30
+ * @returns {Promise<Array<{ name: string, filePath: string }>>}
31
+ */
32
+ async function scanLocalTools(projectRoot) {
33
+ const toolsDir = join(projectRoot, 'tools');
34
+ try {
35
+ await access(toolsDir);
36
+ } catch {
37
+ return [];
38
+ }
39
+
40
+ const tools = [];
41
+ await walkToolFiles(toolsDir, 'tools', tools);
42
+ return tools;
43
+ }
44
+
45
+ async function walkToolFiles(dir, basePath, results) {
46
+ let items;
47
+ try {
48
+ items = await readdir(dir);
49
+ } catch {
50
+ return;
51
+ }
52
+
53
+ for (const item of items) {
54
+ const full = join(dir, item);
55
+ const childPath = `${basePath}/${item}`;
56
+ const s = await stat(full);
57
+
58
+ if (s.isDirectory()) {
59
+ await walkToolFiles(full, childPath, results);
60
+ } else if (item.endsWith('.js')) {
61
+ const name = childPath
62
+ .replace(/^tools\//, '')
63
+ .replace(/\.js$/, '')
64
+ .replace(/\//g, '_');
65
+ results.push({ name, filePath: full });
66
+ }
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Read an SSE stream from a fetch response, collecting text and tool calls.
72
+ *
73
+ * @param {Response} response - Fetch response with SSE body
74
+ * @returns {Promise<{ text: string, toolCalls: Array<{ id: string, name: string, input: Object }>, usage: Object }>}
75
+ */
76
+ async function readSSE(response) {
77
+ const reader = response.body.getReader();
78
+ const decoder = new TextDecoder();
79
+ let buffer = '';
80
+ let text = '';
81
+ const toolCalls = [];
82
+ const pendingInputs = new Map(); // toolCallId -> partial JSON string
83
+ let usage = {};
84
+
85
+ while (true) {
86
+ const { done, value } = await reader.read();
87
+ if (done) break;
88
+
89
+ buffer += decoder.decode(value, { stream: true });
90
+ const lines = buffer.split('\n');
91
+ buffer = lines.pop();
92
+
93
+ for (const line of lines) {
94
+ if (!line.startsWith('data: ')) continue;
95
+ const payload = line.slice(6);
96
+ if (payload === '[DONE]') break;
97
+
98
+ try {
99
+ const event = JSON.parse(payload);
100
+ switch (event.type) {
101
+ case 'text-delta':
102
+ text += event.delta;
103
+ break;
104
+ case 'tool-input-start':
105
+ pendingInputs.set(event.toolCallId, '');
106
+ break;
107
+ case 'tool-input-delta':
108
+ if (pendingInputs.has(event.toolCallId)) {
109
+ pendingInputs.set(
110
+ event.toolCallId,
111
+ pendingInputs.get(event.toolCallId) + event.inputTextDelta
112
+ );
113
+ }
114
+ break;
115
+ case 'tool-input-available':
116
+ toolCalls.push({
117
+ id: event.toolCallId,
118
+ name: event.toolName,
119
+ input: event.input || {}
120
+ });
121
+ pendingInputs.delete(event.toolCallId);
122
+ break;
123
+ case 'finish-step':
124
+ if (event.usage) usage = event.usage;
125
+ break;
126
+ }
127
+ } catch {
128
+ // Skip malformed SSE events
129
+ }
130
+ }
131
+ }
132
+
133
+ return { text, toolCalls, usage };
134
+ }
135
+
136
+ /**
137
+ * Create Connect middleware for local agent development.
138
+ *
139
+ * Provides:
140
+ * GET /api/_agent → list agents from informer.yaml
141
+ * POST /api/_agent/:name/_trigger → run agent locally with tool dispatch
142
+ *
143
+ * @param {Object} viteServer - Vite dev server instance
144
+ * @param {Object} opts - Configuration
145
+ * @returns {Function} Connect middleware
146
+ */
147
+ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot }) {
148
+
149
+ // query() — proxies to the workspace _sql endpoint (same as server-routes.js)
150
+ async function query(sql, params) {
151
+ if (!devWorkspaceId) {
152
+ throw new Error('query() requires INFORMER_DEV_WORKSPACE. Run: npx informer-workspace init');
153
+ }
154
+
155
+ const resp = await globalThis.fetch(`${serverOrigin}/api/datasources/${devWorkspaceId}/_sql`, {
156
+ method: 'POST',
157
+ headers: { 'Content-Type': 'application/json', Authorization: authHeader },
158
+ body: JSON.stringify({ sql, params: params || [] })
159
+ });
160
+
161
+ if (!resp.ok) {
162
+ const err = await resp.json().catch(() => ({}));
163
+ throw new Error(`query() failed: ${resp.status} ${err.message || resp.statusText}`);
164
+ }
165
+
166
+ const data = await resp.json();
167
+ return data.rows;
168
+ }
169
+
170
+ // fetch() — proxies API calls to the Informer server (same as server-routes.js)
171
+ async function apiFetch(path, opts = {}) {
172
+ const method = (opts.method || 'GET').toUpperCase();
173
+ const url = `${serverOrigin}/api/${path.replace(/^\/?(?:api\/)?/, '')}`;
174
+ const fetchOpts = {
175
+ method,
176
+ headers: { Authorization: authHeader, 'Content-Type': 'application/json' }
177
+ };
178
+
179
+ if (opts.body && ['POST', 'PUT', 'PATCH'].includes(method)) {
180
+ fetchOpts.body = JSON.stringify(opts.body);
181
+ }
182
+
183
+ const resp = await globalThis.fetch(url, fetchOpts);
184
+ let body;
185
+ try { body = await resp.json(); } catch { body = await resp.text(); }
186
+ return { status: resp.status, body };
187
+ }
188
+
189
+ // emit() — no-op in dev mode (logs to console)
190
+ function emit(event, payload) {
191
+ console.log(`[agent-dev] emit("${event}",`, JSON.stringify(payload), ')');
192
+ return { ok: true };
193
+ }
194
+
195
+ return async function agentDevMiddleware(req, res, next) {
196
+ try {
197
+ const parsed = parseUrl(req.url, true);
198
+ const routePath = parsed.pathname || '/';
199
+
200
+ // GET / → list agents
201
+ if (req.method === 'GET' && (routePath === '/' || routePath === '')) {
202
+ const yaml = await loadInformerYaml(projectRoot);
203
+ const agents = yaml && yaml.agents ? Object.entries(yaml.agents).map(([name, def]) => ({
204
+ name,
205
+ description: def.description || null,
206
+ model: def.model || 'go_everyday',
207
+ tools: def.tools || [],
208
+ on: def.on || []
209
+ })) : [];
210
+
211
+ res.statusCode = 200;
212
+ res.setHeader('Content-Type', 'application/json');
213
+ res.end(JSON.stringify(agents));
214
+ return;
215
+ }
216
+
217
+ // POST /:name/_trigger → run agent
218
+ const triggerMatch = routePath.match(/^\/([^/]+)\/_trigger$/);
219
+ if (req.method === 'POST' && triggerMatch) {
220
+ const agentName = decodeURIComponent(triggerMatch[1]);
221
+
222
+ // Read request body
223
+ const chunks = [];
224
+ for await (const chunk of req) {
225
+ chunks.push(chunk);
226
+ }
227
+ const rawBody = Buffer.concat(chunks).toString('utf8');
228
+ let body = {};
229
+ if (rawBody) {
230
+ try { body = JSON.parse(rawBody); } catch { body = {}; }
231
+ }
232
+
233
+ const triggerEvent = {
234
+ event: body.event || '_manual',
235
+ payload: body.payload || {}
236
+ };
237
+
238
+ // Load agent definition
239
+ const yaml = await loadInformerYaml(projectRoot);
240
+ if (!yaml || !yaml.agents || !yaml.agents[agentName]) {
241
+ res.statusCode = 404;
242
+ res.setHeader('Content-Type', 'application/json');
243
+ res.end(JSON.stringify({ error: `Agent "${agentName}" not found in informer.yaml` }));
244
+ return;
245
+ }
246
+
247
+ const agentDef = yaml.agents[agentName];
248
+ const model = agentDef.model || 'go_everyday';
249
+ const instructions = agentDef.instructions || '';
250
+ const agentToolNames = agentDef.tools || [];
251
+
252
+ // Load tool handlers via ssrLoadModule
253
+ const localTools = await scanLocalTools(projectRoot);
254
+ const toolMap = new Map(localTools.map(t => [t.name, t]));
255
+
256
+ const loadedTools = {};
257
+ for (const toolName of agentToolNames) {
258
+ const tool = toolMap.get(toolName);
259
+ if (!tool) {
260
+ console.warn(`[agent-dev] Tool "${toolName}" not found in tools/`);
261
+ continue;
262
+ }
263
+
264
+ const mod = await viteServer.ssrLoadModule(tool.filePath);
265
+ if (typeof mod.handler !== 'function') {
266
+ console.warn(`[agent-dev] Tool "${toolName}" has no handler export`);
267
+ continue;
268
+ }
269
+
270
+ loadedTools[toolName] = {
271
+ handler: mod.handler,
272
+ description: mod.description || `Tool: ${toolName}`,
273
+ schema: mod.schema || { type: 'object', properties: {} }
274
+ };
275
+ }
276
+
277
+ // Build AI SDK tool definitions for _chat
278
+ const chatTools = {};
279
+ for (const [name, tool] of Object.entries(loadedTools)) {
280
+ // Ensure schema always has type: 'object' at top level (Anthropic API requires it)
281
+ const schema = tool.schema || {};
282
+ chatTools[name] = {
283
+ description: tool.description,
284
+ inputSchema: {
285
+ type: 'object',
286
+ ...schema
287
+ }
288
+ };
289
+ }
290
+
291
+ // Run the AI loop
292
+ console.log(`[agent-dev] Triggering "${agentName}" (model: ${model}, tools: ${Object.keys(loadedTools).join(', ')})`);
293
+ for (const [name, def] of Object.entries(chatTools)) {
294
+ console.log(`[agent-dev] Tool "${name}" schema:`, JSON.stringify(def.inputSchema));
295
+ }
296
+
297
+ const messages = [
298
+ {
299
+ role: 'user',
300
+ parts: [{
301
+ type: 'text',
302
+ text: JSON.stringify({
303
+ event: triggerEvent.event,
304
+ payload: triggerEvent.payload,
305
+ timestamp: new Date().toISOString()
306
+ })
307
+ }]
308
+ }
309
+ ];
310
+
311
+ const steps = [];
312
+ let totalTokens = 0;
313
+
314
+ for (let step = 0; step < MAX_STEPS; step++) {
315
+ // Call the AI model
316
+ const chatResp = await globalThis.fetch(`${serverOrigin}/api/models/${model}/_chat`, {
317
+ method: 'POST',
318
+ headers: { 'Content-Type': 'application/json', Authorization: authHeader },
319
+ body: JSON.stringify({
320
+ messages,
321
+ system: instructions,
322
+ tools: Object.keys(chatTools).length > 0 ? chatTools : undefined,
323
+ webSearch: !!agentDef.webSearch,
324
+ maxSteps: 1
325
+ })
326
+ });
327
+
328
+ if (!chatResp.ok) {
329
+ const err = await chatResp.json().catch(() => ({}));
330
+ throw new Error(`Model call failed: ${chatResp.status} ${err.message || chatResp.statusText}`);
331
+ }
332
+
333
+ const { text, toolCalls, usage } = await readSSE(chatResp);
334
+ totalTokens += (usage.promptTokens || 0) + (usage.completionTokens || 0);
335
+
336
+ // No tool calls — model is done
337
+ if (toolCalls.length === 0) {
338
+ if (text) {
339
+ steps.push({ type: 'text', text });
340
+ console.log(`[agent-dev] Step ${step}: text response (${text.length} chars)`);
341
+ }
342
+ break;
343
+ }
344
+
345
+ // Execute tool calls locally
346
+ const assistantParts = [];
347
+ if (text) {
348
+ assistantParts.push({ type: 'text', text });
349
+ }
350
+
351
+ const toolResultParts = [];
352
+
353
+ for (const tc of toolCalls) {
354
+ console.log(`[agent-dev] Step ${step}: calling tool "${tc.name}"`, JSON.stringify(tc.input));
355
+
356
+ assistantParts.push({
357
+ type: 'tool-invocation',
358
+ toolCallId: tc.id,
359
+ toolName: tc.name,
360
+ args: tc.input,
361
+ state: 'result'
362
+ });
363
+
364
+ const tool = loadedTools[tc.name];
365
+ let result;
366
+
367
+ if (tool) {
368
+ try {
369
+ result = await tool.handler(tc.input, { query, fetch: apiFetch, emit, context: triggerEvent });
370
+ } catch (err) {
371
+ console.error(`[agent-dev] Tool "${tc.name}" failed:`, err.message);
372
+ result = { error: err.message };
373
+ }
374
+ } else {
375
+ result = { error: `Unknown tool: ${tc.name}` };
376
+ }
377
+
378
+ console.log(`[agent-dev] → result:`, JSON.stringify(result).slice(0, 200));
379
+
380
+ toolResultParts.push({
381
+ type: 'tool-result',
382
+ toolCallId: tc.id,
383
+ toolName: tc.name,
384
+ result: JSON.stringify(result)
385
+ });
386
+
387
+ steps.push({
388
+ type: 'tool_call',
389
+ toolCalls: [{ name: tc.name, args: tc.input, result }]
390
+ });
391
+ }
392
+
393
+ // Append assistant message with tool invocations + tool results
394
+ messages.push({ role: 'assistant', parts: assistantParts });
395
+ messages.push({ role: 'tool', parts: toolResultParts });
396
+ }
397
+
398
+ const runResult = {
399
+ agent: agentName,
400
+ trigger: triggerEvent.event,
401
+ status: 'completed',
402
+ tokens: totalTokens,
403
+ steps
404
+ };
405
+
406
+ console.log(`[agent-dev] Agent "${agentName}" completed: ${steps.length} steps, ${totalTokens} tokens`);
407
+
408
+ res.statusCode = 200;
409
+ res.setHeader('Content-Type', 'application/json');
410
+ res.end(JSON.stringify(runResult));
411
+ return;
412
+ }
413
+
414
+ next();
415
+ } catch (err) {
416
+ console.error('[agent-dev]', err);
417
+ res.statusCode = 500;
418
+ res.setHeader('Content-Type', 'application/json');
419
+ res.end(JSON.stringify({ error: err.message }));
420
+ }
421
+ };
422
+ }
package/src/deploy.js CHANGED
@@ -14,7 +14,7 @@ const CHUNK_THRESHOLD = 512 * 1024; // 512KB
14
14
  const ROOT_CONFIG_FILES = ['informer.yaml', 'data-access.yaml'];
15
15
 
16
16
  /**
17
- * Deploy a built Vite project to Informer as an App (or legacy Magic Report).
17
+ * Deploy a built Vite project to Informer as an App.
18
18
  *
19
19
  * Tries the new /api/apps endpoint first. If the server doesn't support it,
20
20
  * falls back to the legacy /api/reports endpoint transparently.
@@ -149,7 +149,7 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
149
149
  }
150
150
  }
151
151
 
152
- // 7. Upload config files from project root (e.g., data-access.yaml)
152
+ // 7. Upload config files from project root (informer.yaml, data-access.yaml)
153
153
  const projectRoot = dirname(distDir);
154
154
  let configCount = 0;
155
155
  for (const configFile of ROOT_CONFIG_FILES) {
@@ -188,20 +188,115 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
188
188
  // No migrations directory, skip
189
189
  }
190
190
 
191
- // 9. Run migrations (if the server supports it)
191
+ // 9. Upload tools/ directory from project root (if it exists)
192
+ const toolsDir = join(projectRoot, 'tools');
193
+ let toolsCount = 0;
194
+ try {
195
+ await access(toolsDir);
196
+ const toolFiles = await walkDir(toolsDir);
197
+ for (const filePath of toolFiles) {
198
+ const relPath = posix.normalize(relative(projectRoot, filePath).split('\\').join('/'));
199
+ const content = await readFile(filePath);
200
+ const ext = '.' + relPath.split('.').pop();
201
+ const isText = TEXT_EXTENSIONS.has(ext.toLowerCase());
202
+ const payload = isText
203
+ ? { content: content.toString('utf8'), encoding: 'utf8' }
204
+ : { content: content.toString('base64'), encoding: 'base64' };
205
+
206
+ await api.put(`${entityPath}/contents/${relPath}`, payload);
207
+ console.log(` ${relPath} (from project root)`);
208
+ toolsCount++;
209
+ }
210
+ } catch {
211
+ // No tools directory, skip
212
+ }
213
+
214
+ // 10. Upload server/ directory from project root (if it exists)
215
+ const serverDir = join(projectRoot, 'server');
216
+ let serverCount = 0;
217
+ try {
218
+ await access(serverDir);
219
+ const serverFiles = await walkDir(serverDir);
220
+ for (const filePath of serverFiles) {
221
+ const relPath = posix.normalize(relative(projectRoot, filePath).split('\\').join('/'));
222
+ const content = await readFile(filePath);
223
+ const ext = '.' + relPath.split('.').pop();
224
+ const isText = TEXT_EXTENSIONS.has(ext.toLowerCase());
225
+ const payload = isText
226
+ ? { content: content.toString('utf8'), encoding: 'utf8' }
227
+ : { content: content.toString('base64'), encoding: 'base64' };
228
+
229
+ await api.put(`${entityPath}/contents/${relPath}`, payload);
230
+ console.log(` ${relPath} (from project root)`);
231
+ serverCount++;
232
+ }
233
+ } catch {
234
+ // No server directory, skip
235
+ }
236
+
237
+ // 11. Upload webhooks/ directory from project root (if it exists)
238
+ const webhooksDir = join(projectRoot, 'webhooks');
239
+ let webhooksCount = 0;
240
+ try {
241
+ await access(webhooksDir);
242
+ const webhookFiles = await walkDir(webhooksDir);
243
+ for (const filePath of webhookFiles) {
244
+ const relPath = posix.normalize(relative(projectRoot, filePath).split('\\').join('/'));
245
+ const content = await readFile(filePath);
246
+ const ext = '.' + relPath.split('.').pop();
247
+ const isText = TEXT_EXTENSIONS.has(ext.toLowerCase());
248
+ const payload = isText
249
+ ? { content: content.toString('utf8'), encoding: 'utf8' }
250
+ : { content: content.toString('base64'), encoding: 'base64' };
251
+
252
+ await api.put(`${entityPath}/contents/${relPath}`, payload);
253
+ console.log(` ${relPath} (from project root)`);
254
+ webhooksCount++;
255
+ }
256
+ } catch {
257
+ // No webhooks directory, skip
258
+ }
259
+
260
+ // 12. Deploy: run migrations + scan/bundle server routes + webhooks + tools + agents
192
261
  if (apiPrefix === 'apps') {
193
262
  try {
194
- const result = await api.post(`${entityPath}/_migrate`);
195
- if (result && result.migrated && result.migrated.length > 0) {
196
- console.log(`Ran ${result.migrated.length} migration(s): ${result.migrated.join(', ')}`);
263
+ console.log('Deploying...');
264
+ const result = await api.post(`${entityPath}/_deploy`);
265
+ if (result) {
266
+ if (result.migrated && result.migrated.length > 0) {
267
+ console.log(` Ran ${result.migrated.length} migration(s): ${result.migrated.join(', ')}`);
268
+ }
269
+ if (result.routes && result.routes.length > 0) {
270
+ console.log(` Registered ${result.routes.length} server route(s):`);
271
+ for (const r of result.routes) {
272
+ console.log(` ${r}`);
273
+ }
274
+ }
275
+ if (result.webhooks && result.webhooks.length > 0) {
276
+ console.log(` Registered ${result.webhooks.length} webhook(s):`);
277
+ for (const r of result.webhooks) {
278
+ console.log(` ${r}`);
279
+ }
280
+ }
281
+ if (result.tools && result.tools.length > 0) {
282
+ console.log(` Registered ${result.tools.length} tool(s): ${result.tools.join(', ')}`);
283
+ }
284
+ if (result.agents && result.agents.length > 0) {
285
+ console.log(` Deployed ${result.agents.length} agent(s): ${result.agents.join(', ')}`);
286
+ }
287
+ }
288
+ } catch (err) {
289
+ if (err.status === 404) {
290
+ // Server may not support _deploy yet — ignore
291
+ } else {
292
+ const detail = err.body || err.message;
293
+ console.error(` Deploy failed: ${detail}`);
197
294
  }
198
- } catch {
199
- // Server may not support _migrate yet — ignore
200
295
  }
201
296
  }
202
297
 
203
- // 9. Print URL and return UUID for saving
204
- const totalFiles = entries.length + configCount + migrationsCount;
298
+ // 13. Print URL and return UUID for saving
299
+ const totalFiles = entries.length + configCount + migrationsCount + toolsCount + serverCount + webhooksCount;
205
300
  const base = baseUrl.replace(/\/+$/, '');
206
301
  const entityUrl = apiPrefix === 'apps'
207
302
  ? `${base}/api/apps/${naturalId}/view`
package/src/index.js CHANGED
@@ -1,10 +1,17 @@
1
1
  import dotenv from 'dotenv';
2
+ import { existsSync } from 'node:fs';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { resolve } from 'node:path';
5
+ import { createClient } from './client.js';
6
+ import { createMiddleware as createServerRoutes } from './server-routes.js';
7
+ import { createAgentMiddleware } from './agent-dev.js';
8
+ import { init, migrate } from './workspace.js';
2
9
 
3
10
  /**
4
- * Vite plugin for local Magic Report development.
11
+ * Vite plugin for local Informer App development.
5
12
  *
6
13
  * - Proxies /api requests to the Informer server with Basic auth
7
- * - Rewrites /api/_query to the dev workspace's _sql route (if configured)
14
+ * - Runs server/ route handlers locally via ssrLoadModule (if server/ dir exists)
8
15
  * - Injects window.__INFORMER__ context mock in dev mode
9
16
  * - Sets base to './' so built assets use relative paths
10
17
  *
@@ -60,43 +67,83 @@ export default function informer(options = {}) {
60
67
  return cfg;
61
68
  },
62
69
 
63
- configureServer(server) {
64
- if (!isDev || !devWorkspaceId || !serverOrigin) return;
70
+ async configureServer(server) {
71
+ if (!isDev || !serverOrigin) return;
65
72
 
66
- // Intercept POST /api/_query and proxy to the dev workspace's _sql route.
67
- // This middleware runs before the generic /api proxy, so the rewrite takes effect.
68
- server.middlewares.use('/api/_query', async (req, res, next) => {
69
- if (req.method !== 'POST') return next();
73
+ const projectRoot = process.cwd();
74
+ const migrationsDir = resolve(projectRoot, 'migrations');
70
75
 
71
- // Read request body
72
- const chunks = [];
73
- for await (const chunk of req) {
74
- chunks.push(chunk);
75
- }
76
- const body = Buffer.concat(chunks).toString('utf8');
76
+ // Auto-provision workspace if migrations/ exists
77
+ if (existsSync(migrationsDir)) {
78
+ const api = createClient({
79
+ baseUrl: serverOrigin,
80
+ apiKey: process.env.INFORMER_API_KEY,
81
+ user: process.env.INFORMER_USER,
82
+ pass: process.env.INFORMER_PASS
83
+ });
77
84
 
78
- // Forward to workspace _sql endpoint
79
- const url = `${serverOrigin}/api/datasources/${devWorkspaceId}/_sql`;
80
85
  try {
81
- const upstream = await fetch(url, {
82
- method: 'POST',
83
- headers: {
84
- 'Content-Type': 'application/json',
85
- Authorization: authHeader
86
- },
87
- body
88
- });
89
-
90
- res.statusCode = upstream.status;
91
- res.setHeader('Content-Type', 'application/json');
92
- const text = await upstream.text();
93
- res.end(text);
86
+ // Verify existing workspace or create a new one
87
+ let needsInit = !devWorkspaceId;
88
+ if (devWorkspaceId) {
89
+ const ds = await api.get(`datasources/${devWorkspaceId}`);
90
+ if (ds) {
91
+ await migrate({ api, workspaceId: devWorkspaceId, migrationsDir });
92
+ } else {
93
+ console.warn(`[informer] Workspace ${devWorkspaceId} not found on server. Re-creating...`);
94
+ needsInit = true;
95
+ }
96
+ }
97
+
98
+ if (needsInit) {
99
+ const pkg = JSON.parse(await readFile(resolve(projectRoot, 'package.json'), 'utf8'));
100
+ let slug = pkg.name;
101
+ if (slug && slug.startsWith('@') && slug.includes('/')) {
102
+ slug = slug.split('/')[1];
103
+ }
104
+
105
+ const result = await init({
106
+ api,
107
+ slug,
108
+ migrationsDir,
109
+ envPath: resolve(projectRoot, '.env')
110
+ });
111
+ devWorkspaceId = result.workspaceId;
112
+ }
94
113
  } catch (err) {
95
- res.statusCode = 502;
96
- res.setHeader('Content-Type', 'application/json');
97
- res.end(JSON.stringify({ error: err.message }));
114
+ console.warn(`[informer] Workspace setup failed: ${err.message}`);
115
+ console.warn('[informer] query() will not work. Run: npx informer-workspace init');
98
116
  }
99
- });
117
+ }
118
+
119
+ // Mount server-side route handlers if a server/ directory exists
120
+ const serverDir = resolve(projectRoot, 'server');
121
+
122
+ if (existsSync(serverDir)) {
123
+ const serverRoutes = createServerRoutes(server, {
124
+ serverOrigin,
125
+ authHeader,
126
+ devWorkspaceId,
127
+ projectRoot,
128
+ roles: (options.mock && options.mock.roles) || []
129
+ });
130
+ server.middlewares.use('/api/_server', serverRoutes);
131
+ }
132
+
133
+ // Mount agent dev middleware if tools/ or informer.yaml agents exist
134
+ const toolsDir = resolve(projectRoot, 'tools');
135
+ const yamlPath = resolve(projectRoot, 'informer.yaml');
136
+
137
+ if (existsSync(toolsDir) || existsSync(yamlPath)) {
138
+ const agentDev = createAgentMiddleware(server, {
139
+ serverOrigin,
140
+ authHeader,
141
+ devWorkspaceId,
142
+ projectRoot
143
+ });
144
+ server.middlewares.use('/api/_agent', agentDev);
145
+ }
146
+
100
147
  },
101
148
 
102
149
  transformIndexHtml: {
@@ -0,0 +1,312 @@
1
+ import { createHmac } from 'node:crypto';
2
+ import { readdir, stat } from 'node:fs/promises';
3
+ import { join, relative, posix } from 'node:path';
4
+ import { parse as parseUrl } from 'node:url';
5
+
6
+ const VALID_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
7
+
8
+ /**
9
+ * Convert a file path under server/ to a route path.
10
+ *
11
+ * Examples:
12
+ * server/orders/index.js -> /orders
13
+ * server/orders/[id].js -> /orders/:id
14
+ * server/orders/[id]/approve.js -> /orders/:id/approve
15
+ * server/index.js -> /
16
+ */
17
+ function filePathToRoute(filePath) {
18
+ let route = filePath
19
+ .replace(/^server\//, '')
20
+ .replace(/\.js$/, '');
21
+
22
+ route = route.replace(/\[([^\]]+)\]/g, ':$1');
23
+
24
+ route = route.replace(/\/index$/, '');
25
+
26
+ if (route === 'index' || route === '') {
27
+ return '/';
28
+ }
29
+
30
+ if (!route.startsWith('/')) {
31
+ route = '/' + route;
32
+ }
33
+
34
+ if (route.length > 1 && route.endsWith('/')) {
35
+ route = route.slice(0, -1);
36
+ }
37
+
38
+ return route;
39
+ }
40
+
41
+ /**
42
+ * Match an incoming request against a route table.
43
+ *
44
+ * @param {Array<{ method: string, path: string }>} routes
45
+ * @param {string} method - HTTP method
46
+ * @param {string} requestPath - e.g. "/orders/123"
47
+ * @returns {{ route: Object, params: Object }|null}
48
+ */
49
+ function matchRoute(routes, method, requestPath) {
50
+ const upperMethod = method.toUpperCase();
51
+ const candidates = routes.filter(r => r.method === upperMethod);
52
+
53
+ const normalizedPath = requestPath.length > 1 && requestPath.endsWith('/')
54
+ ? requestPath.slice(0, -1)
55
+ : requestPath;
56
+
57
+ const requestSegments = normalizedPath.split('/').filter(Boolean);
58
+
59
+ let bestMatch = null;
60
+ let bestScore = -1;
61
+
62
+ for (const route of candidates) {
63
+ const routeSegments = route.path.split('/').filter(Boolean);
64
+
65
+ if (routeSegments.length !== requestSegments.length) continue;
66
+
67
+ const params = {};
68
+ let score = 0;
69
+ let matched = true;
70
+
71
+ for (let i = 0; i < routeSegments.length; i++) {
72
+ const routeSeg = routeSegments[i];
73
+ const reqSeg = requestSegments[i];
74
+
75
+ if (routeSeg.startsWith(':')) {
76
+ params[routeSeg.slice(1)] = decodeURIComponent(reqSeg);
77
+ } else if (routeSeg === reqSeg) {
78
+ score++;
79
+ } else {
80
+ matched = false;
81
+ break;
82
+ }
83
+ }
84
+
85
+ if (matched && score > bestScore) {
86
+ bestMatch = { route, params };
87
+ bestScore = score;
88
+ }
89
+ }
90
+
91
+ if (requestSegments.length === 0) {
92
+ const rootRoute = candidates.find(r => r.path === '/');
93
+ if (rootRoute) {
94
+ return { route: rootRoute, params: {} };
95
+ }
96
+ }
97
+
98
+ return bestMatch;
99
+ }
100
+
101
+ /**
102
+ * Recursively scan the server/ directory for .js handler files.
103
+ *
104
+ * @param {string} serverDir - Absolute path to server/ directory
105
+ * @returns {Promise<Array<{ path: string, filePath: string }>>}
106
+ */
107
+ async function scanRoutes(serverDir) {
108
+ const files = await walkJsFiles(serverDir, 'server');
109
+ return files.map(({ relPath, absPath }) => ({
110
+ path: filePathToRoute(relPath),
111
+ filePath: absPath
112
+ }));
113
+ }
114
+
115
+ async function walkJsFiles(dir, basePath) {
116
+ const results = [];
117
+ let items;
118
+ try {
119
+ items = await readdir(dir);
120
+ } catch {
121
+ return results;
122
+ }
123
+
124
+ for (const item of items) {
125
+ const full = join(dir, item);
126
+ const childPath = `${basePath}/${item}`;
127
+ const s = await stat(full);
128
+
129
+ if (s.isDirectory()) {
130
+ results.push(...await walkJsFiles(full, childPath));
131
+ } else if (item.endsWith('.js')) {
132
+ results.push({ relPath: childPath, absPath: full });
133
+ }
134
+ }
135
+
136
+ return results;
137
+ }
138
+
139
+ /**
140
+ * Create Connect middleware for dev-mode server route execution.
141
+ *
142
+ * @param {Object} viteServer - Vite dev server instance
143
+ * @param {{ serverOrigin: string, authHeader: string, devWorkspaceId: string|null, projectRoot: string }} opts
144
+ * @returns {Function} Connect middleware
145
+ */
146
+ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, roles }) {
147
+ const serverDir = join(projectRoot, 'server');
148
+
149
+ // query() implementation — proxies to the workspace _sql endpoint
150
+ async function query(sql, params) {
151
+ if (!devWorkspaceId) {
152
+ throw new Error('query() requires INFORMER_DEV_WORKSPACE. Run: npx informer-workspace init');
153
+ }
154
+
155
+ const resp = await globalThis.fetch(`${serverOrigin}/api/datasources/${devWorkspaceId}/_sql`, {
156
+ method: 'POST',
157
+ headers: { 'Content-Type': 'application/json', Authorization: authHeader },
158
+ body: JSON.stringify({ sql, params: params || [] })
159
+ });
160
+
161
+ if (!resp.ok) {
162
+ const err = await resp.json().catch(() => ({}));
163
+ const detail = err.message || resp.statusText;
164
+ throw new Error(`query() failed: ${resp.status} ${detail} (${serverOrigin}/api/datasources/${devWorkspaceId}/_sql)`);
165
+ }
166
+
167
+ const data = await resp.json();
168
+ return data.rows;
169
+ }
170
+
171
+ // fetch() implementation — proxies API calls to the Informer server
172
+ async function apiFetch(path, opts = {}) {
173
+ const method = (opts.method || 'GET').toUpperCase();
174
+ const url = `${serverOrigin}/api/${path.replace(/^\/?(?:api\/)?/, '')}`;
175
+ const fetchOpts = {
176
+ method,
177
+ headers: { Authorization: authHeader, 'Content-Type': 'application/json' }
178
+ };
179
+
180
+ if (opts.body && ['POST', 'PUT', 'PATCH'].includes(method)) {
181
+ fetchOpts.body = JSON.stringify(opts.body);
182
+ }
183
+
184
+ const resp = await globalThis.fetch(url, fetchOpts);
185
+ let body;
186
+ try { body = await resp.json(); } catch { body = await resp.text(); }
187
+ return { status: resp.status, body };
188
+ }
189
+
190
+ return async function serverRoutesMiddleware(req, res, next) {
191
+ try {
192
+ // The URL has already had /api/_server stripped by Vite's middleware.use()
193
+ const parsed = parseUrl(req.url, true);
194
+ const routePath = parsed.pathname || '/';
195
+
196
+ // Scan server/ directory for handler files
197
+ const scannedRoutes = await scanRoutes(serverDir);
198
+
199
+ // Build route table with all valid methods per file
200
+ const routeTable = [];
201
+ for (const scanned of scannedRoutes) {
202
+ for (const method of VALID_METHODS) {
203
+ routeTable.push({ method, path: scanned.path, filePath: scanned.filePath });
204
+ }
205
+ }
206
+
207
+ // Match request against route table
208
+ const match = matchRoute(routeTable, req.method, routePath);
209
+ if (!match) return next();
210
+
211
+ // Load handler via Vite's ssrLoadModule (ESM + HMR)
212
+ const mod = await viteServer.ssrLoadModule(match.route.filePath);
213
+ const handler = mod[req.method.toUpperCase()];
214
+
215
+ if (typeof handler !== 'function') {
216
+ res.statusCode = 404;
217
+ res.setHeader('Content-Type', 'application/json');
218
+ res.end(JSON.stringify({ error: 'Method not found' }));
219
+ return;
220
+ }
221
+
222
+ // Read request body — preserve raw bytes for HMAC verification
223
+ const chunks = [];
224
+ for await (const chunk of req) {
225
+ chunks.push(chunk);
226
+ }
227
+ const rawBody = Buffer.concat(chunks).toString('utf8') || null;
228
+ let body = null;
229
+ if (rawBody) {
230
+ try { body = JSON.parse(rawBody); } catch { body = rawBody; }
231
+ }
232
+
233
+ // crypto helper — mirrors the sandbox's crypto.hmac()
234
+ const cryptoHelper = {
235
+ hmac(algorithm, key, data, encoding) {
236
+ return createHmac(algorithm, key).update(data).digest(encoding || 'hex');
237
+ }
238
+ };
239
+
240
+ // Build request context
241
+ const request = {
242
+ method: req.method.toUpperCase(),
243
+ path: routePath,
244
+ params: match.params,
245
+ query: parsed.query || {},
246
+ body,
247
+ rawBody,
248
+ headers: req.headers,
249
+ roles: roles || [],
250
+ user: {
251
+ username: 'dev-user',
252
+ displayName: 'Dev User',
253
+ email: null,
254
+ timezone: null
255
+ }
256
+ };
257
+
258
+ // respond() — sends early response, handler continues in background
259
+ let responded = false;
260
+ function respond(earlyBody) {
261
+ if (responded) return;
262
+ responded = true;
263
+ res.statusCode = 200;
264
+ res.setHeader('Content-Type', 'application/json');
265
+ res.end(JSON.stringify(earlyBody));
266
+ }
267
+
268
+ // Call handler
269
+ const result = await handler({ query, fetch: apiFetch, respond, crypto: cryptoHelper, env: {}, request });
270
+
271
+ // If respond() was already called, the response is already sent
272
+ if (responded) return;
273
+
274
+ // Normalize response (mirrors app-sandbox.js buildInvokeScript logic)
275
+ let status, responseBody, responseHeaders;
276
+
277
+ if (result === undefined || result === null) {
278
+ status = 204;
279
+ responseBody = null;
280
+ responseHeaders = {};
281
+ } else if (typeof result === 'object' && typeof result.status === 'number') {
282
+ status = result.status || 200;
283
+ responseBody = result.body !== undefined ? result.body : null;
284
+ responseHeaders = result.headers || {};
285
+ } else {
286
+ status = 200;
287
+ responseBody = result;
288
+ responseHeaders = { 'content-type': 'application/json' };
289
+ }
290
+
291
+ res.statusCode = status;
292
+ for (const [key, value] of Object.entries(responseHeaders)) {
293
+ res.setHeader(key, value);
294
+ }
295
+
296
+ if (responseBody === null) {
297
+ res.end();
298
+ } else {
299
+ if (!res.getHeader('content-type')) {
300
+ res.setHeader('Content-Type', 'application/json');
301
+ }
302
+ res.end(JSON.stringify(responseBody));
303
+ }
304
+ } catch (err) {
305
+ viteServer.ssrFixStacktrace(err);
306
+ console.error('[server-routes]', err);
307
+ res.statusCode = 500;
308
+ res.setHeader('Content-Type', 'application/json');
309
+ res.end(JSON.stringify({ error: err.message }));
310
+ }
311
+ };
312
+ }