@entrinsik/vite-plugin-informer 2.3.0 → 2.5.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/deploy.js +3 -2
- package/bin/init.js +55 -27
- package/bin/workspace.js +11 -5
- package/package.json +1 -1
- package/src/agent-dev.js +43 -2
- package/src/deploy.js +2 -15
- package/src/dev-dependencies.js +363 -0
- package/src/env.js +86 -0
- package/src/index.js +22 -5
- package/src/server-routes.js +80 -10
- package/src/workspace.js +2 -2
package/bin/deploy.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import dotenv from 'dotenv';
|
|
4
3
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
5
4
|
import { resolve } from 'node:path';
|
|
6
5
|
import { deploy } from '../src/deploy.js';
|
|
6
|
+
import { loadEnv, parseModeArg } from '../src/env.js';
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
const mode = parseModeArg(process.argv);
|
|
9
|
+
loadEnv({ mode });
|
|
9
10
|
|
|
10
11
|
const baseUrl = process.env.INFORMER_URL;
|
|
11
12
|
const apiKey = process.env.INFORMER_API_KEY;
|
package/bin/init.js
CHANGED
|
@@ -13,40 +13,66 @@ const cwd = process.cwd();
|
|
|
13
13
|
function generateInformerYaml() {
|
|
14
14
|
return `# Informer App Configuration
|
|
15
15
|
# =========================
|
|
16
|
-
# This file controls
|
|
17
|
-
#
|
|
16
|
+
# This file controls the data your app depends on and defines custom
|
|
17
|
+
# roles for role-based UIs.
|
|
18
18
|
#
|
|
19
|
-
# Without
|
|
19
|
+
# Without dependencies: or access:, all API access is blocked (secure
|
|
20
|
+
# by default).
|
|
20
21
|
|
|
21
22
|
# ============================================================================
|
|
22
|
-
#
|
|
23
|
+
# DEPENDENCIES (preferred)
|
|
23
24
|
# ============================================================================
|
|
24
|
-
#
|
|
25
|
+
# Typed slots that the installer binds to actual resources at deploy
|
|
26
|
+
# time. Each slot becomes a property on the handler context object —
|
|
27
|
+
# call methods on it instead of building raw API URLs.
|
|
25
28
|
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
# region: $user.custom.region
|
|
29
|
+
# Slot methods by target:
|
|
30
|
+
# dataset → context.<slot>.search(esQuery) / context.<slot>.fields()
|
|
31
|
+
# query → context.<slot>.execute(params)
|
|
32
|
+
# datasource → context.<slot>.query(payload)
|
|
33
|
+
# integration → context.<slot>.request({ method, path, body })
|
|
32
34
|
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
+
# defaultBinding must be a UUID (not a configId). Look up UUIDs via:
|
|
36
|
+
# GET /api/datasets-list → for target: dataset
|
|
37
|
+
# GET /api/queries-list → for target: query
|
|
38
|
+
# GET /api/datasources-list → for target: datasource
|
|
39
|
+
# GET /api/integrations-list → for target: integration
|
|
35
40
|
#
|
|
36
|
-
#
|
|
37
|
-
# - salesforce
|
|
41
|
+
# Example:
|
|
38
42
|
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
43
|
+
# dependencies:
|
|
44
|
+
# sales:
|
|
45
|
+
# target: dataset
|
|
46
|
+
# defaultBinding: 7d5a9b1e-0c83-4bde-9e2a-3a4b5c6d7e8f
|
|
47
|
+
# orders:
|
|
48
|
+
# target: dataset
|
|
49
|
+
# defaultBinding: 1f2e3d4c-5b6a-7980-1234-56789abcdef0
|
|
50
|
+
# options:
|
|
51
|
+
# filter:
|
|
52
|
+
# region: $user.custom.region # Row-level security
|
|
53
|
+
# monthly_summary:
|
|
54
|
+
# target: query
|
|
55
|
+
# defaultBinding: 9a8b7c6d-5e4f-3a2b-1c0d-fedcba987654
|
|
56
|
+
# salesforce:
|
|
57
|
+
# target: integration # No defaultBinding — installer picks
|
|
58
|
+
# options:
|
|
59
|
+
# headers:
|
|
60
|
+
# Authorization: Bearer $user.custom.sfToken
|
|
61
|
+
|
|
62
|
+
dependencies: {}
|
|
63
|
+
|
|
64
|
+
# ============================================================================
|
|
65
|
+
# ACCESS (raw API allowlist — for paths that don't fit the typed-slot model)
|
|
66
|
+
# ============================================================================
|
|
67
|
+
# Use access.apis for endpoints not covered by dependencies: slots,
|
|
68
|
+
# such as AI model routes or custom server endpoints.
|
|
41
69
|
#
|
|
42
|
-
#
|
|
70
|
+
# access:
|
|
71
|
+
# apis:
|
|
72
|
+
# - POST /api/models/go_everyday/_object
|
|
73
|
+
# - POST /api/models/go_everyday/_chat
|
|
43
74
|
# - POST /api/custom/endpoint
|
|
44
75
|
|
|
45
|
-
access:
|
|
46
|
-
datasets: []
|
|
47
|
-
queries: []
|
|
48
|
-
integrations: []
|
|
49
|
-
|
|
50
76
|
# ============================================================================
|
|
51
77
|
# ROLES (optional)
|
|
52
78
|
# ============================================================================
|
|
@@ -111,7 +137,8 @@ async function init() {
|
|
|
111
137
|
const pkgPath = resolve(cwd, 'package.json');
|
|
112
138
|
if (!await exists(pkgPath)) {
|
|
113
139
|
console.error('No package.json found. Run this in a Vite project directory.');
|
|
114
|
-
console.error('Create one first with: npm create vite@latest');
|
|
140
|
+
console.error('Create one first with: npm create vite@latest . -- --template react');
|
|
141
|
+
console.error('(Use --template react-ts for TypeScript, or vanilla/vue/svelte/etc.)');
|
|
115
142
|
process.exit(1);
|
|
116
143
|
}
|
|
117
144
|
|
|
@@ -121,7 +148,8 @@ async function init() {
|
|
|
121
148
|
const hasVite = pkg.devDependencies?.vite || pkg.dependencies?.vite;
|
|
122
149
|
if (!hasVite) {
|
|
123
150
|
console.error('Vite not found in dependencies.');
|
|
124
|
-
console.error('Create a Vite project first: npm create vite@latest');
|
|
151
|
+
console.error('Create a Vite project first: npm create vite@latest . -- --template react');
|
|
152
|
+
console.error('(Use --template react-ts for TypeScript, or vanilla/vue/svelte/etc.)');
|
|
125
153
|
process.exit(1);
|
|
126
154
|
}
|
|
127
155
|
|
|
@@ -198,7 +226,7 @@ INFORMER_API_KEY=your-api-key
|
|
|
198
226
|
const informerYamlPath = resolve(cwd, 'informer.yaml');
|
|
199
227
|
if (!await exists(informerYamlPath)) {
|
|
200
228
|
await writeFile(informerYamlPath, generateInformerYaml());
|
|
201
|
-
console.log('Created informer.yaml (
|
|
229
|
+
console.log('Created informer.yaml (declare your data dependencies and roles)');
|
|
202
230
|
}
|
|
203
231
|
|
|
204
232
|
// 10. Add .env to .gitignore if not present
|
|
@@ -207,7 +235,7 @@ INFORMER_API_KEY=your-api-key
|
|
|
207
235
|
console.log('\nSetup complete!\n');
|
|
208
236
|
console.log('Next steps:');
|
|
209
237
|
console.log(' 1. Update .env with your Informer credentials');
|
|
210
|
-
console.log(' 2.
|
|
238
|
+
console.log(' 2. Declare your data dependencies in informer.yaml (see comments inside)');
|
|
211
239
|
console.log(' 3. Run: npm install');
|
|
212
240
|
console.log(' 4. Run: npm run dev');
|
|
213
241
|
console.log('');
|
package/bin/workspace.js
CHANGED
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import dotenv from 'dotenv';
|
|
4
3
|
import { readFile } from 'node:fs/promises';
|
|
5
4
|
import { resolve } from 'node:path';
|
|
6
5
|
import { createClient } from '../src/client.js';
|
|
6
|
+
import { loadEnv, envWritePath, parseModeArg } from '../src/env.js';
|
|
7
7
|
import { init, migrate, reset } from '../src/workspace.js';
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
const mode = parseModeArg(process.argv);
|
|
10
|
+
loadEnv({ mode });
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
// Strip --mode <value> from argv before parsing the command
|
|
13
|
+
const args = process.argv.slice(2).filter((arg, i, arr) => arg !== '--mode' && arr[i - 1] !== '--mode');
|
|
14
|
+
const command = args[0];
|
|
12
15
|
|
|
13
16
|
if (!command || !['init', 'migrate', 'reset'].includes(command)) {
|
|
14
|
-
console.error('Usage: informer-workspace <init|migrate|reset>');
|
|
17
|
+
console.error('Usage: informer-workspace <init|migrate|reset> [--mode <name>]');
|
|
15
18
|
console.error('');
|
|
16
19
|
console.error('Commands:');
|
|
17
20
|
console.error(' init Create a dev workspace datasource and run migrations');
|
|
18
21
|
console.error(' migrate Run pending migrations against the dev workspace');
|
|
19
22
|
console.error(' reset Drop all tables and re-run all migrations');
|
|
23
|
+
console.error('');
|
|
24
|
+
console.error('Options:');
|
|
25
|
+
console.error(' --mode <name> Load .env.<name> (e.g. --mode test, --mode production)');
|
|
20
26
|
process.exit(1);
|
|
21
27
|
}
|
|
22
28
|
|
|
@@ -33,7 +39,7 @@ if (!baseUrl || (!apiKey && (!user || !pass))) {
|
|
|
33
39
|
|
|
34
40
|
const api = createClient({ baseUrl, apiKey, user, pass });
|
|
35
41
|
const migrationsDir = resolve('migrations');
|
|
36
|
-
const envPath =
|
|
42
|
+
const envPath = envWritePath({ mode });
|
|
37
43
|
|
|
38
44
|
try {
|
|
39
45
|
if (command === 'init') {
|
package/package.json
CHANGED
package/src/agent-dev.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readFile, readdir, stat, access } from 'node:fs/promises';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { parse as parseUrl } from 'node:url';
|
|
4
4
|
import yaml from 'yaml';
|
|
5
|
+
import { loadDependencies, buildDevContext, loadAppEnv, buildDevCrypto, buildDevMessaging, normalizeFetchPath } from './dev-dependencies.js';
|
|
5
6
|
const parseYaml = yaml.parse;
|
|
6
7
|
|
|
7
8
|
const MAX_STEPS = 20;
|
|
@@ -170,7 +171,12 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
|
|
|
170
171
|
// fetch() — proxies API calls to the Informer server (same as server-routes.js)
|
|
171
172
|
async function apiFetch(path, opts = {}) {
|
|
172
173
|
const method = (opts.method || 'GET').toUpperCase();
|
|
173
|
-
|
|
174
|
+
// Same strict canonicalization as prod (app-sandbox.js normalizeFetchPath).
|
|
175
|
+
const apiPath = normalizeFetchPath(path);
|
|
176
|
+
if (!apiPath) {
|
|
177
|
+
return { status: 400, body: { error: `Invalid fetch path: ${String(path).slice(0, 80)}` } };
|
|
178
|
+
}
|
|
179
|
+
const url = `${serverOrigin}${apiPath}`;
|
|
174
180
|
const fetchOpts = {
|
|
175
181
|
method,
|
|
176
182
|
headers: { Authorization: authHeader, 'Content-Type': 'application/json' }
|
|
@@ -192,6 +198,33 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
|
|
|
192
198
|
return { ok: true };
|
|
193
199
|
}
|
|
194
200
|
|
|
201
|
+
// notify/email — delivery is a console-logged no-op in dev, but required-field
|
|
202
|
+
// validation mirrors prod so a tool that passes here won't 500 in production.
|
|
203
|
+
const { notify, email } = buildDevMessaging('[agent-dev]');
|
|
204
|
+
|
|
205
|
+
// crypto helper — mirrors the prod sandbox crypto surface
|
|
206
|
+
const cryptoHelper = buildDevCrypto();
|
|
207
|
+
|
|
208
|
+
// log helper — mirrors sandbox log(message, data) + log.info/warn/error/debug
|
|
209
|
+
const logCall = (level, message, data) => {
|
|
210
|
+
const msg = typeof message === 'string' ? message : JSON.stringify(message);
|
|
211
|
+
const args = [`[app-log] [${level}] ${msg}`];
|
|
212
|
+
if (data) args.push(data);
|
|
213
|
+
console.log(...args);
|
|
214
|
+
};
|
|
215
|
+
const log = Object.assign(
|
|
216
|
+
(message, data) => logCall('info', message, data),
|
|
217
|
+
{
|
|
218
|
+
debug: (message, data) => logCall('debug', message, data),
|
|
219
|
+
info: (message, data) => logCall('info', message, data),
|
|
220
|
+
warn: (message, data) => logCall('warn', message, data),
|
|
221
|
+
error: (message, data) => logCall('error', message, data)
|
|
222
|
+
}
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
// markdown helper — passthrough in dev (production uses `marked`)
|
|
226
|
+
const markdown = (text) => text;
|
|
227
|
+
|
|
195
228
|
return async function agentDevMiddleware(req, res, next) {
|
|
196
229
|
try {
|
|
197
230
|
const parsed = parseUrl(req.url, true);
|
|
@@ -249,6 +282,14 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
|
|
|
249
282
|
const instructions = agentDef.instructions || '';
|
|
250
283
|
const agentToolNames = agentDef.tools || [];
|
|
251
284
|
|
|
285
|
+
// Build the typed dependency context + env the same way the
|
|
286
|
+
// server-routes dev middleware does, so tools using
|
|
287
|
+
// `context.<slot>.<method>(...)` and `env` work locally and match
|
|
288
|
+
// the prod sandbox bag.
|
|
289
|
+
const deps = await loadDependencies(projectRoot);
|
|
290
|
+
const context = buildDevContext({ deps, apiFetch });
|
|
291
|
+
const env = await loadAppEnv(projectRoot);
|
|
292
|
+
|
|
252
293
|
// Load tool handlers via ssrLoadModule
|
|
253
294
|
const localTools = await scanLocalTools(projectRoot);
|
|
254
295
|
const toolMap = new Map(localTools.map(t => [t.name, t]));
|
|
@@ -366,7 +407,7 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
|
|
|
366
407
|
|
|
367
408
|
if (tool) {
|
|
368
409
|
try {
|
|
369
|
-
result = await tool.handler(tc.input, { query, fetch: apiFetch, emit,
|
|
410
|
+
result = await tool.handler({ args: tc.input, run: { agentName, trigger: triggerEvent }, context, query, fetch: apiFetch, emit, notify, email, crypto: cryptoHelper, markdown, log, env });
|
|
370
411
|
} catch (err) {
|
|
371
412
|
console.error(`[agent-dev] Tool "${tc.name}" failed:`, err.message);
|
|
372
413
|
result = { error: err.message };
|
package/src/deploy.js
CHANGED
|
@@ -102,22 +102,9 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
|
|
|
102
102
|
limit: 10
|
|
103
103
|
});
|
|
104
104
|
|
|
105
|
-
// 5. Clear existing files
|
|
105
|
+
// 5. Clear existing files in one server-side transaction
|
|
106
106
|
console.log('Clearing existing files...');
|
|
107
|
-
|
|
108
|
-
if (files && Array.isArray(files)) {
|
|
109
|
-
// Delete non-directories first
|
|
110
|
-
const nonDirs = files.filter(f => !f.directory);
|
|
111
|
-
await Promise.all(nonDirs.map(f => api.del(`${entityPath}/files/${f.id}`)));
|
|
112
|
-
|
|
113
|
-
// Then delete directories in reverse order (deepest first by path length)
|
|
114
|
-
const dirs = files
|
|
115
|
-
.filter(f => f.directory)
|
|
116
|
-
.sort((a, b) => (b.path || '').length - (a.path || '').length);
|
|
117
|
-
for (const d of dirs) {
|
|
118
|
-
await api.del(`${entityPath}/files/${d.id}`);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
107
|
+
await api.post(`${entityPath}/files/_clear`);
|
|
121
108
|
|
|
122
109
|
// 6. Upload dist/ contents
|
|
123
110
|
console.log(`Uploading files from ${distDir}...`);
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { readFile, access } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import yaml from 'yaml';
|
|
5
|
+
|
|
6
|
+
const parseYaml = yaml.parse;
|
|
7
|
+
|
|
8
|
+
const RANDOM_BYTES_MAX = 1024;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Build the dev-mode `crypto` helper. Mirrors the production host dispatcher in
|
|
12
|
+
* app-sandbox.js (cryptoRef) so handlers/tools see the same crypto surface
|
|
13
|
+
* locally. Methods are synchronous in dev (prod returns promises across the
|
|
14
|
+
* isolate boundary); `await` works either way.
|
|
15
|
+
*
|
|
16
|
+
* @returns {Object} crypto helper { hmac, hash, randomUUID, randomBytes, timingSafeEqual, verifyHmac, encrypt, decrypt, verify }
|
|
17
|
+
*/
|
|
18
|
+
export function buildDevCrypto() {
|
|
19
|
+
return {
|
|
20
|
+
hmac: (algorithm, key, data, encoding) => crypto.createHmac(algorithm, key).update(data).digest(encoding || 'hex'),
|
|
21
|
+
hash: (algorithm, data, encoding) => crypto.createHash(algorithm).update(data).digest(encoding || 'hex'),
|
|
22
|
+
randomUUID: () => crypto.randomUUID(),
|
|
23
|
+
randomBytes: (length, encoding) => {
|
|
24
|
+
const n = Math.min(Math.max(parseInt(length, 10) || 0, 1), RANDOM_BYTES_MAX);
|
|
25
|
+
return crypto.randomBytes(n).toString(encoding || 'hex');
|
|
26
|
+
},
|
|
27
|
+
timingSafeEqual: (a, b) => {
|
|
28
|
+
if (a == null || b == null) {
|
|
29
|
+
throw new Error('crypto.timingSafeEqual requires two non-null values to compare');
|
|
30
|
+
}
|
|
31
|
+
const ba = Buffer.from(String(a));
|
|
32
|
+
const bb = Buffer.from(String(b));
|
|
33
|
+
if (ba.length !== bb.length) return false;
|
|
34
|
+
return crypto.timingSafeEqual(ba, bb);
|
|
35
|
+
},
|
|
36
|
+
verifyHmac: (algorithm, key, data, signature, encoding) => {
|
|
37
|
+
if (signature == null) {
|
|
38
|
+
throw new Error('crypto.verifyHmac requires a signature to compare against (got null/undefined)');
|
|
39
|
+
}
|
|
40
|
+
const expected = crypto.createHmac(algorithm, key).update(data).digest(encoding || 'hex');
|
|
41
|
+
const a = Buffer.from(String(signature));
|
|
42
|
+
const b = Buffer.from(expected);
|
|
43
|
+
if (a.length !== b.length) return false;
|
|
44
|
+
return crypto.timingSafeEqual(a, b);
|
|
45
|
+
},
|
|
46
|
+
encrypt: (plaintext, key) => {
|
|
47
|
+
const dk = crypto.createHash('sha256').update(String(key)).digest();
|
|
48
|
+
const iv = crypto.randomBytes(12);
|
|
49
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', dk, iv);
|
|
50
|
+
const ct = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]);
|
|
51
|
+
const tag = cipher.getAuthTag();
|
|
52
|
+
return `${iv.toString('base64')}:${tag.toString('base64')}:${ct.toString('base64')}`;
|
|
53
|
+
},
|
|
54
|
+
decrypt: (payload, key) => {
|
|
55
|
+
const parts = String(payload).split(':');
|
|
56
|
+
if (parts.length !== 3) {
|
|
57
|
+
throw new Error('crypto.decrypt: malformed payload (expected iv:tag:ciphertext)');
|
|
58
|
+
}
|
|
59
|
+
const dk = crypto.createHash('sha256').update(String(key)).digest();
|
|
60
|
+
const iv = Buffer.from(parts[0], 'base64');
|
|
61
|
+
const tag = Buffer.from(parts[1], 'base64');
|
|
62
|
+
const ct = Buffer.from(parts[2], 'base64');
|
|
63
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', dk, iv);
|
|
64
|
+
decipher.setAuthTag(tag);
|
|
65
|
+
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
|
|
66
|
+
},
|
|
67
|
+
verify: (algorithm, data, signature, publicKey, signatureEncoding) => {
|
|
68
|
+
if (data == null || signature == null || publicKey == null) {
|
|
69
|
+
throw new Error('crypto.verify requires data, signature, and publicKey');
|
|
70
|
+
}
|
|
71
|
+
return crypto.verify(algorithm, Buffer.from(String(data)), publicKey, Buffer.from(String(signature), signatureEncoding || 'base64'));
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Canonicalize a guest-code fetch() path the same way the prod sandbox does
|
|
78
|
+
* (see normalizeFetchPath in app-sandbox.js). Returns the normalized `/api/...`
|
|
79
|
+
* path, or null for any non-canonical input — so dev rejects the same shapes
|
|
80
|
+
* prod 400s instead of silently accepting `//api/foo` / `\api\foo`.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} urlPath
|
|
83
|
+
* @returns {string|null}
|
|
84
|
+
*/
|
|
85
|
+
export function normalizeFetchPath(urlPath) {
|
|
86
|
+
if (typeof urlPath !== 'string' || !urlPath) return null;
|
|
87
|
+
// eslint-disable-next-line no-control-regex
|
|
88
|
+
if (/[\\\x00-\x1f\s]/.test(urlPath)) return null;
|
|
89
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(urlPath)) return null;
|
|
90
|
+
let p = urlPath;
|
|
91
|
+
if (p.startsWith('/')) p = p.slice(1);
|
|
92
|
+
if (p.startsWith('api/')) p = p.slice('api/'.length);
|
|
93
|
+
if (!p || p.startsWith('/') || p.startsWith('api/')) return null;
|
|
94
|
+
if (p.includes('//')) return null;
|
|
95
|
+
return `/api/${p}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Build dev-mode notify/email shims that mirror the PROD required-field
|
|
100
|
+
* validation (app-message-utils.js) so an app that passes locally also passes in
|
|
101
|
+
* production. Delivery is still a console-logged no-op in dev.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} logPrefix - console log prefix (e.g. '[app]' or '[agent-dev]')
|
|
104
|
+
* @returns {{ notify: Function, email: Function }}
|
|
105
|
+
*/
|
|
106
|
+
export function buildDevMessaging(logPrefix = '[app]') {
|
|
107
|
+
const notify = (usernameOrArray, message) => {
|
|
108
|
+
if (Array.isArray(usernameOrArray)) {
|
|
109
|
+
for (const item of usernameOrArray) {
|
|
110
|
+
if (!item || typeof item.username !== 'string') throw new Error('notify() bulk items require a username');
|
|
111
|
+
if (!item.title) throw new Error('notify() bulk items require a title');
|
|
112
|
+
}
|
|
113
|
+
console.log(`${logPrefix} notify (bulk)`, JSON.stringify(usernameOrArray));
|
|
114
|
+
return { ids: usernameOrArray.map(() => 'dev-message'), queued: usernameOrArray.length };
|
|
115
|
+
}
|
|
116
|
+
if (!usernameOrArray || typeof usernameOrArray !== 'string') {
|
|
117
|
+
throw new Error('notify() requires a username as the first argument');
|
|
118
|
+
}
|
|
119
|
+
if (!message || !message.title) {
|
|
120
|
+
throw new Error('notify() requires a message with at least a title');
|
|
121
|
+
}
|
|
122
|
+
console.log(`${logPrefix} notify`, usernameOrArray, JSON.stringify(message));
|
|
123
|
+
return { id: 'dev-message' };
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const email = (toOrArray, message) => {
|
|
127
|
+
if (Array.isArray(toOrArray)) {
|
|
128
|
+
for (const item of toOrArray) {
|
|
129
|
+
if (!item || typeof item.to !== 'string') throw new Error('email() bulk items require a to address');
|
|
130
|
+
if (!item.subject) throw new Error('email() bulk items require a subject');
|
|
131
|
+
}
|
|
132
|
+
console.log(`${logPrefix} email (bulk)`, JSON.stringify(toOrArray));
|
|
133
|
+
return { ids: toOrArray.map(() => 'dev-message'), queued: toOrArray.length };
|
|
134
|
+
}
|
|
135
|
+
if (!toOrArray || typeof toOrArray !== 'string') {
|
|
136
|
+
throw new Error('email() requires an email address as the first argument');
|
|
137
|
+
}
|
|
138
|
+
if (!message || !message.subject) {
|
|
139
|
+
throw new Error('email() requires a message with at least a subject');
|
|
140
|
+
}
|
|
141
|
+
console.log(`${logPrefix} email`, toOrArray, JSON.stringify(message));
|
|
142
|
+
return { id: 'dev-message' };
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return { notify, email };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Mirrors the server-side patterns in
|
|
149
|
+
// packages/informer-server/modules/app/routes/deploy.js so dev and deploy
|
|
150
|
+
// reject the same shapes with the same wording. Keep these in lockstep.
|
|
151
|
+
const DEPENDENCY_NAME_PATTERN = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$/;
|
|
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']);
|
|
154
|
+
const VALID_RUN_AS = new Set(['user', 'owner']);
|
|
155
|
+
|
|
156
|
+
const METHOD_SURFACE = {
|
|
157
|
+
dataset: ['search', 'fields'],
|
|
158
|
+
query: ['execute'],
|
|
159
|
+
datasource: ['query'],
|
|
160
|
+
integration: ['request']
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Load the `dependencies:` map from informer.yaml. Returns `{}` if the file
|
|
165
|
+
* is missing or the section is absent — both are valid (an app may have no
|
|
166
|
+
* declared dependencies).
|
|
167
|
+
*
|
|
168
|
+
* @param {string} projectRoot
|
|
169
|
+
* @returns {Promise<Object>} The raw dependencies object as authored
|
|
170
|
+
*/
|
|
171
|
+
export async function loadDependencies(projectRoot) {
|
|
172
|
+
const yamlPath = join(projectRoot, 'informer.yaml');
|
|
173
|
+
try {
|
|
174
|
+
await access(yamlPath);
|
|
175
|
+
} catch {
|
|
176
|
+
return {};
|
|
177
|
+
}
|
|
178
|
+
const content = await readFile(yamlPath, 'utf8');
|
|
179
|
+
const parsed = parseYaml(content);
|
|
180
|
+
if (!parsed || typeof parsed !== 'object') return {};
|
|
181
|
+
return (parsed.dependencies && typeof parsed.dependencies === 'object') ? parsed.dependencies : {};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Load the app `env:` map from informer.yaml. In production the sandbox injects
|
|
186
|
+
* `app.defn.env`; dev has no app model, so the YAML's top-level `env:` block is
|
|
187
|
+
* the closest stand-in. Returns `{}` when missing so handlers/tools see the same
|
|
188
|
+
* empty object rather than undefined.
|
|
189
|
+
*
|
|
190
|
+
* @param {string} projectRoot
|
|
191
|
+
* @returns {Promise<Object>} The raw env object as authored
|
|
192
|
+
*/
|
|
193
|
+
export async function loadAppEnv(projectRoot) {
|
|
194
|
+
const yamlPath = join(projectRoot, 'informer.yaml');
|
|
195
|
+
try {
|
|
196
|
+
await access(yamlPath);
|
|
197
|
+
} catch {
|
|
198
|
+
return {};
|
|
199
|
+
}
|
|
200
|
+
const content = await readFile(yamlPath, 'utf8');
|
|
201
|
+
const parsed = parseYaml(content);
|
|
202
|
+
if (!parsed || typeof parsed !== 'object') return {};
|
|
203
|
+
return (parsed.env && typeof parsed.env === 'object') ? parsed.env : {};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Validate the shape of a parsed `dependencies:` block. Returns an array of
|
|
208
|
+
* human-readable error strings (empty when valid). Wording mirrors deploy.js
|
|
209
|
+
* so devs see the same message at boot and at deploy.
|
|
210
|
+
*
|
|
211
|
+
* Skips "is the resource actually readable?" — that requires a network call
|
|
212
|
+
* against the configured server and would slow startup. Shape validation is
|
|
213
|
+
* the cheap win; the deploy path still does the read check.
|
|
214
|
+
*
|
|
215
|
+
* @param {Object} deps - The raw `dependencies:` object
|
|
216
|
+
* @returns {string[]} Error messages, one per problem
|
|
217
|
+
*/
|
|
218
|
+
export function validateDependencies(deps) {
|
|
219
|
+
const errors = [];
|
|
220
|
+
for (const [name, decl] of Object.entries(deps || {})) {
|
|
221
|
+
if (!DEPENDENCY_NAME_PATTERN.test(name)) {
|
|
222
|
+
errors.push(`dependency "${name}": invalid name (must be lowercase dot-segmented, eg. "orders.list")`);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (!decl || typeof decl !== 'object') {
|
|
226
|
+
errors.push(`dependency "${name}": must be an object`);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (!decl.target || typeof decl.target !== 'string') {
|
|
230
|
+
errors.push(`dependency "${name}": missing required "target" field`);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (!VALID_TARGETS.has(decl.target)) {
|
|
234
|
+
errors.push(`dependency "${name}": unknown target "${decl.target}" (expected one of: ${[...VALID_TARGETS].join(', ')})`);
|
|
235
|
+
}
|
|
236
|
+
const runAs = decl.runAs || 'user';
|
|
237
|
+
if (!VALID_RUN_AS.has(runAs)) {
|
|
238
|
+
errors.push(`dependency "${name}": runAs must be 'user' or 'owner' (got "${runAs}")`);
|
|
239
|
+
}
|
|
240
|
+
if (decl.defaultBinding != null) {
|
|
241
|
+
if (typeof decl.defaultBinding !== 'string') {
|
|
242
|
+
errors.push(`dependency "${name}": defaultBinding must be a string UUID`);
|
|
243
|
+
} else if (!UUID_PATTERN.test(decl.defaultBinding)) {
|
|
244
|
+
errors.push(`dependency "${name}": defaultBinding must be a UUID (got "${decl.defaultBinding}")`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return errors;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Build the dev-mode `context` object passed to server route handlers, so
|
|
253
|
+
* `await context.myDep.method(...)` works the same locally as it does after
|
|
254
|
+
* deploy. Mirrors the production sandbox's typed-proxy DI context built in
|
|
255
|
+
* `app-sandbox.js`.
|
|
256
|
+
*
|
|
257
|
+
* Dev-mode differences from production:
|
|
258
|
+
* - There is no installer step locally, so each dep is auto-bound to its
|
|
259
|
+
* manifest `defaultBinding` UUID. Deps without one return an unbound
|
|
260
|
+
* proxy whose methods throw a pointed message — same shape as the
|
|
261
|
+
* prod `makeUnboundProxy` behavior, just with a dev-flavored hint.
|
|
262
|
+
* - `runAs` is informational only. Every dev call goes through the
|
|
263
|
+
* vite plugin's auth header (the same identity that handles /api proxy
|
|
264
|
+
* requests), regardless of whether the manifest declares user or owner.
|
|
265
|
+
* - Driver option-merging (dataset filters, query default parameters,
|
|
266
|
+
* integration paths/headers) is not replicated here. Dev is a thin
|
|
267
|
+
* pass-through — exotic prod semantics surface as integration-test work,
|
|
268
|
+
* not as silent dev parity.
|
|
269
|
+
*
|
|
270
|
+
* @param {Object} args
|
|
271
|
+
* @param {Object} args.deps - The raw `dependencies:` object
|
|
272
|
+
* @param {Function} args.apiFetch - The dev-server fetch helper
|
|
273
|
+
* (path, { method, body }) => { status, body }
|
|
274
|
+
* @returns {Object} An object keyed by dependency name, values are typed
|
|
275
|
+
* proxies with methods matching the target's production method surface.
|
|
276
|
+
*/
|
|
277
|
+
export function buildDevContext({ deps, apiFetch }) {
|
|
278
|
+
const context = {};
|
|
279
|
+
for (const [name, decl] of Object.entries(deps || {})) {
|
|
280
|
+
if (!decl || typeof decl !== 'object') continue;
|
|
281
|
+
const target = decl.target;
|
|
282
|
+
if (!VALID_TARGETS.has(target)) continue;
|
|
283
|
+
|
|
284
|
+
const targetId = (typeof decl.defaultBinding === 'string' && UUID_PATTERN.test(decl.defaultBinding))
|
|
285
|
+
? decl.defaultBinding
|
|
286
|
+
: null;
|
|
287
|
+
|
|
288
|
+
context[name] = targetId
|
|
289
|
+
? makeDevProxy({ name, target, targetId, apiFetch })
|
|
290
|
+
: makeUnboundDevProxy({ name, target });
|
|
291
|
+
}
|
|
292
|
+
return context;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function makeDevProxy({ name, target, targetId, apiFetch }) {
|
|
296
|
+
switch (target) {
|
|
297
|
+
case 'dataset':
|
|
298
|
+
return {
|
|
299
|
+
async search(payload) {
|
|
300
|
+
return await devCall(apiFetch, 'POST', `datasets/${targetId}/_search`, payload || {}, name, 'dataset');
|
|
301
|
+
},
|
|
302
|
+
async fields() {
|
|
303
|
+
return await devCall(apiFetch, 'GET', `datasets/${targetId}/fields`, null, name, 'dataset');
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
case 'query':
|
|
307
|
+
return {
|
|
308
|
+
async execute(payload) {
|
|
309
|
+
return await devCall(apiFetch, 'POST', `queries/${targetId}/_execute`, payload || {}, name, 'query');
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
case 'datasource':
|
|
313
|
+
return {
|
|
314
|
+
async query(payload) {
|
|
315
|
+
return await devCall(apiFetch, 'POST', `datasources/${targetId}/_query`, payload || {}, name, 'datasource');
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
case 'integration':
|
|
319
|
+
return {
|
|
320
|
+
async request(payload) {
|
|
321
|
+
return await devCall(apiFetch, 'POST', `integrations/${targetId}/request`, payload || {}, name, 'integration');
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
default:
|
|
325
|
+
return {};
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Build an unbound dev proxy whose every method throws a clear error pointing
|
|
331
|
+
* at the missing `defaultBinding`. The method-surface keys are populated so
|
|
332
|
+
* a typo on the method name throws TypeError immediately, not a silent
|
|
333
|
+
* `undefined is not a function` later — matching the prod unbound-proxy
|
|
334
|
+
* contract.
|
|
335
|
+
*/
|
|
336
|
+
function makeUnboundDevProxy({ name, target }) {
|
|
337
|
+
const methods = METHOD_SURFACE[target] || [];
|
|
338
|
+
const proxy = {};
|
|
339
|
+
for (const method of methods) {
|
|
340
|
+
proxy[method] = async () => {
|
|
341
|
+
throw new Error(
|
|
342
|
+
`Dependency "${name}" is not bound in dev — add \`defaultBinding: <uuid>\` to its entry in informer.yaml`
|
|
343
|
+
);
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
return proxy;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function devCall(apiFetch, method, path, body, depName, resourceType) {
|
|
350
|
+
const opts = { method };
|
|
351
|
+
if (body !== null && body !== undefined) opts.body = body;
|
|
352
|
+
const { status, body: responseBody } = await apiFetch(path, opts);
|
|
353
|
+
if (status >= 400) {
|
|
354
|
+
const message = responseBody && typeof responseBody === 'object' && responseBody.message
|
|
355
|
+
? responseBody.message
|
|
356
|
+
: String(status);
|
|
357
|
+
const err = new Error(`Dependency "${depName}" (${resourceType}) call failed: ${message}`);
|
|
358
|
+
err.status = status;
|
|
359
|
+
err.body = responseBody;
|
|
360
|
+
throw err;
|
|
361
|
+
}
|
|
362
|
+
return responseBody;
|
|
363
|
+
}
|
package/src/env.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import dotenv from 'dotenv';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { resolve, dirname, parse as parsePath } from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Build an ordered list of .env file paths for dotenv to load.
|
|
7
|
+
*
|
|
8
|
+
* Priority (first match wins in dotenv):
|
|
9
|
+
* 1. <cwd>/.env.<mode> (mode-specific, e.g. .env.test)
|
|
10
|
+
* 2. <cwd>/.env (local defaults)
|
|
11
|
+
* 3. <parent>/.env.<mode> (walk up for monorepo shared config)
|
|
12
|
+
* 4. <parent>/.env
|
|
13
|
+
* ... continues up to first parent that contains a .env file
|
|
14
|
+
*
|
|
15
|
+
* When mode is 'development' (Vite default), mode-specific files are skipped
|
|
16
|
+
* since .env already serves that purpose.
|
|
17
|
+
*
|
|
18
|
+
* @param {{ mode?: string, cwd?: string }} options
|
|
19
|
+
* @returns {string[]} Ordered file paths (may include non-existent files — dotenv ignores those)
|
|
20
|
+
*/
|
|
21
|
+
export function buildEnvPaths({ mode, cwd } = {}) {
|
|
22
|
+
const dir = cwd || process.cwd();
|
|
23
|
+
const useMode = mode && mode !== 'development';
|
|
24
|
+
|
|
25
|
+
// Local project .env files (highest priority)
|
|
26
|
+
const paths = [];
|
|
27
|
+
if (useMode) paths.push(resolve(dir, `.env.${mode}`));
|
|
28
|
+
paths.push(resolve(dir, '.env'));
|
|
29
|
+
|
|
30
|
+
// Walk up to parent directories for monorepo fallback
|
|
31
|
+
let current = dirname(dir);
|
|
32
|
+
const { root } = parsePath(dir);
|
|
33
|
+
|
|
34
|
+
while (current !== root) {
|
|
35
|
+
if (useMode) paths.push(resolve(current, `.env.${mode}`));
|
|
36
|
+
paths.push(resolve(current, '.env'));
|
|
37
|
+
|
|
38
|
+
// Stop at the first ancestor that has any .env file
|
|
39
|
+
const hasEnv = useMode
|
|
40
|
+
? existsSync(resolve(current, `.env.${mode}`)) || existsSync(resolve(current, '.env'))
|
|
41
|
+
: existsSync(resolve(current, '.env'));
|
|
42
|
+
|
|
43
|
+
if (hasEnv) break;
|
|
44
|
+
|
|
45
|
+
current = dirname(current);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return paths;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Load environment variables using mode-aware, parent-walking dotenv paths.
|
|
53
|
+
*
|
|
54
|
+
* @param {{ mode?: string, cwd?: string }} options
|
|
55
|
+
*/
|
|
56
|
+
export function loadEnv({ mode, cwd } = {}) {
|
|
57
|
+
const paths = buildEnvPaths({ mode, cwd });
|
|
58
|
+
dotenv.config({ path: paths });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Determine the .env file path to write workspace IDs into.
|
|
63
|
+
* Writes to .env.<mode> when a non-default mode is active, otherwise .env.
|
|
64
|
+
*
|
|
65
|
+
* @param {{ mode?: string, cwd?: string }} options
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
export function envWritePath({ mode, cwd } = {}) {
|
|
69
|
+
const dir = cwd || process.cwd();
|
|
70
|
+
const useMode = mode && mode !== 'development';
|
|
71
|
+
return resolve(dir, useMode ? `.env.${mode}` : '.env');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Parse --mode <name> from a process.argv array.
|
|
76
|
+
*
|
|
77
|
+
* @param {string[]} argv
|
|
78
|
+
* @returns {string|null}
|
|
79
|
+
*/
|
|
80
|
+
export function parseModeArg(argv) {
|
|
81
|
+
const idx = argv.indexOf('--mode');
|
|
82
|
+
if (idx !== -1 && idx + 1 < argv.length) {
|
|
83
|
+
return argv[idx + 1];
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import dotenv from 'dotenv';
|
|
2
1
|
import { existsSync } from 'node:fs';
|
|
3
2
|
import { readFile } from 'node:fs/promises';
|
|
4
3
|
import { resolve } from 'node:path';
|
|
5
4
|
import { createClient } from './client.js';
|
|
5
|
+
import { loadDependencies, validateDependencies } from './dev-dependencies.js';
|
|
6
|
+
import { loadEnv, envWritePath } from './env.js';
|
|
6
7
|
import { createMiddleware as createServerRoutes } from './server-routes.js';
|
|
7
8
|
import { createAgentMiddleware } from './agent-dev.js';
|
|
8
9
|
import { init, migrate } from './workspace.js';
|
|
@@ -23,12 +24,14 @@ export default function informer(options = {}) {
|
|
|
23
24
|
let authHeader = null;
|
|
24
25
|
let serverOrigin = null;
|
|
25
26
|
let devWorkspaceId = null;
|
|
27
|
+
let activeMode = null;
|
|
26
28
|
|
|
27
29
|
return {
|
|
28
30
|
name: 'vite-plugin-informer',
|
|
29
31
|
|
|
30
|
-
config(_, { command }) {
|
|
31
|
-
|
|
32
|
+
config(_, { command, mode }) {
|
|
33
|
+
activeMode = mode;
|
|
34
|
+
loadEnv({ mode });
|
|
32
35
|
|
|
33
36
|
isDev = command === 'serve';
|
|
34
37
|
|
|
@@ -57,7 +60,8 @@ export default function informer(options = {}) {
|
|
|
57
60
|
changeOrigin: true,
|
|
58
61
|
headers: {
|
|
59
62
|
Authorization: authHeader
|
|
60
|
-
}
|
|
63
|
+
},
|
|
64
|
+
...options.proxy
|
|
61
65
|
}
|
|
62
66
|
}
|
|
63
67
|
};
|
|
@@ -106,7 +110,7 @@ export default function informer(options = {}) {
|
|
|
106
110
|
api,
|
|
107
111
|
slug,
|
|
108
112
|
migrationsDir,
|
|
109
|
-
envPath:
|
|
113
|
+
envPath: envWritePath({ mode: activeMode })
|
|
110
114
|
});
|
|
111
115
|
devWorkspaceId = result.workspaceId;
|
|
112
116
|
}
|
|
@@ -116,6 +120,19 @@ export default function informer(options = {}) {
|
|
|
116
120
|
}
|
|
117
121
|
}
|
|
118
122
|
|
|
123
|
+
// Surface manifest-level dependency declaration errors at boot,
|
|
124
|
+
// not at `npx informer publish` time. Matches the deploy.js
|
|
125
|
+
// validation so devs see the same wording pre-deploy.
|
|
126
|
+
try {
|
|
127
|
+
const deps = await loadDependencies(projectRoot);
|
|
128
|
+
const errors = validateDependencies(deps);
|
|
129
|
+
for (const message of errors) {
|
|
130
|
+
console.error(`[informer] informer.yaml: ${message}`);
|
|
131
|
+
}
|
|
132
|
+
} catch (err) {
|
|
133
|
+
console.warn(`[informer] Could not validate informer.yaml dependencies: ${err.message}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
119
136
|
// Mount server-side route handlers if a server/ directory exists
|
|
120
137
|
const serverDir = resolve(projectRoot, 'server');
|
|
121
138
|
|
package/src/server-routes.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import { createHmac } from 'node:crypto';
|
|
2
1
|
import { readdir, stat } from 'node:fs/promises';
|
|
3
2
|
import { join, relative, posix } from 'node:path';
|
|
4
3
|
import { parse as parseUrl } from 'node:url';
|
|
4
|
+
import { loadDependencies, buildDevContext, loadAppEnv, buildDevCrypto, buildDevMessaging, normalizeFetchPath } from './dev-dependencies.js';
|
|
5
5
|
|
|
6
6
|
const VALID_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
7
7
|
|
|
8
|
+
// Strict base64 — see view-api.js for the rationale. Mirror kept identical
|
|
9
|
+
// to keep dev and prod behavior aligned.
|
|
10
|
+
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
11
|
+
|
|
8
12
|
/**
|
|
9
13
|
* Convert a file path under server/ to a route path.
|
|
10
14
|
*
|
|
@@ -171,7 +175,13 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
|
|
|
171
175
|
// fetch() implementation — proxies API calls to the Informer server
|
|
172
176
|
async function apiFetch(path, opts = {}) {
|
|
173
177
|
const method = (opts.method || 'GET').toUpperCase();
|
|
174
|
-
|
|
178
|
+
// Same strict canonicalization as prod (app-sandbox.js normalizeFetchPath);
|
|
179
|
+
// reject non-canonical shapes here instead of silently accepting them.
|
|
180
|
+
const apiPath = normalizeFetchPath(path);
|
|
181
|
+
if (!apiPath) {
|
|
182
|
+
return { status: 400, body: { error: `Invalid fetch path: ${String(path).slice(0, 80)}` } };
|
|
183
|
+
}
|
|
184
|
+
const url = `${serverOrigin}${apiPath}`;
|
|
175
185
|
const fetchOpts = {
|
|
176
186
|
method,
|
|
177
187
|
headers: { Authorization: authHeader, 'Content-Type': 'application/json' }
|
|
@@ -230,13 +240,40 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
|
|
|
230
240
|
try { body = JSON.parse(rawBody); } catch { body = rawBody; }
|
|
231
241
|
}
|
|
232
242
|
|
|
233
|
-
// crypto helper — mirrors the sandbox
|
|
234
|
-
const cryptoHelper =
|
|
235
|
-
|
|
236
|
-
|
|
243
|
+
// crypto helper — mirrors the prod sandbox crypto surface
|
|
244
|
+
const cryptoHelper = buildDevCrypto();
|
|
245
|
+
|
|
246
|
+
// log helper — mirrors sandbox log(message, data) + log.info/warn/error/debug
|
|
247
|
+
const logCall = (level, message, data) => {
|
|
248
|
+
const msg = typeof message === 'string' ? message : JSON.stringify(message);
|
|
249
|
+
const args = [`[app-log] [${level}] ${msg}`];
|
|
250
|
+
if (data) args.push(data);
|
|
251
|
+
console.log(...args);
|
|
252
|
+
};
|
|
253
|
+
const log = Object.assign(
|
|
254
|
+
(message, data) => logCall('info', message, data),
|
|
255
|
+
{
|
|
256
|
+
debug: (message, data) => logCall('debug', message, data),
|
|
257
|
+
info: (message, data) => logCall('info', message, data),
|
|
258
|
+
warn: (message, data) => logCall('warn', message, data),
|
|
259
|
+
error: (message, data) => logCall('error', message, data)
|
|
237
260
|
}
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
// markdown helper — passthrough in dev (production uses `marked`)
|
|
264
|
+
const markdown = (text) => text;
|
|
265
|
+
|
|
266
|
+
// emit helper — no-op in dev (logs to console)
|
|
267
|
+
const emit = (event, payload) => {
|
|
268
|
+
console.log(`[app-event] emit("${event}",`, JSON.stringify(payload), ')');
|
|
269
|
+
return { ok: true };
|
|
238
270
|
};
|
|
239
271
|
|
|
272
|
+
// notify/email — delivery is a console-logged no-op in dev, but the
|
|
273
|
+
// required-field validation mirrors prod so an app that passes here
|
|
274
|
+
// won't 500 in production.
|
|
275
|
+
const { notify, email } = buildDevMessaging('[app]');
|
|
276
|
+
|
|
240
277
|
// Build request context
|
|
241
278
|
const request = {
|
|
242
279
|
method: req.method.toUpperCase(),
|
|
@@ -265,20 +302,39 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
|
|
|
265
302
|
res.end(JSON.stringify(earlyBody));
|
|
266
303
|
}
|
|
267
304
|
|
|
268
|
-
//
|
|
269
|
-
|
|
305
|
+
// Build the dependency-injection context the same way the prod
|
|
306
|
+
// sandbox does, so handlers using `await context.myDep.method(...)`
|
|
307
|
+
// work locally. Loaded per request so edits to informer.yaml take
|
|
308
|
+
// effect without a dev-server restart.
|
|
309
|
+
const deps = await loadDependencies(projectRoot);
|
|
310
|
+
const context = buildDevContext({ deps, apiFetch });
|
|
311
|
+
const env = await loadAppEnv(projectRoot);
|
|
312
|
+
|
|
313
|
+
// Call handler — bag mirrors the prod sandbox (see app-sandbox.js).
|
|
314
|
+
const result = await handler({ request, context, query, fetch: apiFetch, respond, emit, notify, email, crypto: cryptoHelper, markdown, log, env });
|
|
270
315
|
|
|
271
316
|
// If respond() was already called, the response is already sent
|
|
272
317
|
if (responded) return;
|
|
273
318
|
|
|
274
|
-
// Normalize response (mirrors app-sandbox.js buildInvokeScript logic)
|
|
275
|
-
|
|
319
|
+
// Normalize response (mirrors app-sandbox.js buildInvokeScript logic).
|
|
320
|
+
// The encoding allow-list and contract checks are enforced inside the
|
|
321
|
+
// ivm in production; replicate them here so handlers see the same
|
|
322
|
+
// errors in dev (where there's no isolate to attribute the throw to).
|
|
323
|
+
// If you change this, mirror it in modules/app/routes/view-api.js.
|
|
324
|
+
let status, responseBody, responseHeaders, encoding;
|
|
276
325
|
|
|
277
326
|
if (result === undefined || result === null) {
|
|
278
327
|
status = 204;
|
|
279
328
|
responseBody = null;
|
|
280
329
|
responseHeaders = {};
|
|
281
330
|
} else if (typeof result === 'object' && typeof result.status === 'number') {
|
|
331
|
+
encoding = typeof result.encoding === 'string' ? result.encoding : null;
|
|
332
|
+
if (encoding !== null && encoding !== 'base64') {
|
|
333
|
+
throw new Error(`Unknown response encoding: ${JSON.stringify(encoding)} (expected 'base64' or omitted)`);
|
|
334
|
+
}
|
|
335
|
+
if (encoding === 'base64' && typeof result.body !== 'string') {
|
|
336
|
+
throw new Error(`encoding: 'base64' requires body to be a base64-encoded string, got ${typeof result.body}`);
|
|
337
|
+
}
|
|
282
338
|
status = result.status || 200;
|
|
283
339
|
responseBody = result.body !== undefined ? result.body : null;
|
|
284
340
|
responseHeaders = result.headers || {};
|
|
@@ -295,6 +351,20 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
|
|
|
295
351
|
|
|
296
352
|
if (responseBody === null) {
|
|
297
353
|
res.end();
|
|
354
|
+
} else if (encoding === 'base64' && typeof responseBody === 'string') {
|
|
355
|
+
if (!BASE64_RE.test(responseBody)) {
|
|
356
|
+
throw new Error('Handler returned malformed base64 body');
|
|
357
|
+
}
|
|
358
|
+
res.end(Buffer.from(responseBody, 'base64'));
|
|
359
|
+
} else if (typeof responseBody === 'string') {
|
|
360
|
+
// Pre-PR the dev middleware always JSON.stringify'd the body, so
|
|
361
|
+
// a handler returning { body: 'hello' } emitted "hello" (with
|
|
362
|
+
// quotes) — diverging from prod which passed strings verbatim.
|
|
363
|
+
// This branch fixes that parity.
|
|
364
|
+
if (!res.getHeader('content-type')) {
|
|
365
|
+
res.setHeader('Content-Type', 'application/json');
|
|
366
|
+
}
|
|
367
|
+
res.end(responseBody);
|
|
298
368
|
} else {
|
|
299
369
|
if (!res.getHeader('content-type')) {
|
|
300
370
|
res.setHeader('Content-Type', 'application/json');
|
package/src/workspace.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readdir, readFile, writeFile, access } from 'node:fs/promises';
|
|
2
|
-
import { join } from 'node:path';
|
|
2
|
+
import { join, basename } from 'node:path';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Execute raw SQL against a workspace datasource via the _sql route.
|
|
@@ -122,7 +122,7 @@ export async function init({ api, slug, migrationsDir, envPath }) {
|
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
await writeFile(envPath, envContent);
|
|
125
|
-
console.log(`Saved INFORMER_DEV_WORKSPACE=${workspaceId} to
|
|
125
|
+
console.log(`Saved INFORMER_DEV_WORKSPACE=${workspaceId} to ${basename(envPath)}`);
|
|
126
126
|
|
|
127
127
|
return { workspaceId, ...result };
|
|
128
128
|
}
|