@entrinsik/vite-plugin-informer 2.0.0 → 2.2.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 +7 -1
- package/bin/workspace.js +87 -0
- package/package.json +4 -2
- package/src/deploy.js +68 -4
- package/src/index.js +81 -2
- package/src/server-routes.js +284 -0
- package/src/workspace.js +159 -0
package/bin/init.js
CHANGED
|
@@ -232,12 +232,18 @@ async function init() {
|
|
|
232
232
|
console.log('Added @entrinsik/vite-plugin-informer to devDependencies');
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
-
// 6. Add
|
|
235
|
+
// 6. Add scripts if not present
|
|
236
236
|
if (!pkg.scripts) pkg.scripts = {};
|
|
237
237
|
if (!pkg.scripts.deploy) {
|
|
238
238
|
pkg.scripts.deploy = 'npm run build && informer-deploy';
|
|
239
239
|
console.log('Added "deploy" script to package.json');
|
|
240
240
|
}
|
|
241
|
+
if (!pkg.scripts['workspace:init']) {
|
|
242
|
+
pkg.scripts['workspace:init'] = 'informer-workspace init';
|
|
243
|
+
pkg.scripts['workspace:migrate'] = 'informer-workspace migrate';
|
|
244
|
+
pkg.scripts['workspace:reset'] = 'informer-workspace reset';
|
|
245
|
+
console.log('Added workspace scripts to package.json');
|
|
246
|
+
}
|
|
241
247
|
|
|
242
248
|
await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
243
249
|
console.log(`Updated package.json with informer.name="${reportName}"`);
|
package/bin/workspace.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import dotenv from 'dotenv';
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
import { createClient } from '../src/client.js';
|
|
7
|
+
import { init, migrate, reset } from '../src/workspace.js';
|
|
8
|
+
|
|
9
|
+
dotenv.config();
|
|
10
|
+
|
|
11
|
+
const command = process.argv[2];
|
|
12
|
+
|
|
13
|
+
if (!command || !['init', 'migrate', 'reset'].includes(command)) {
|
|
14
|
+
console.error('Usage: informer-workspace <init|migrate|reset>');
|
|
15
|
+
console.error('');
|
|
16
|
+
console.error('Commands:');
|
|
17
|
+
console.error(' init Create a dev workspace datasource and run migrations');
|
|
18
|
+
console.error(' migrate Run pending migrations against the dev workspace');
|
|
19
|
+
console.error(' reset Drop all tables and re-run all migrations');
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const baseUrl = process.env.INFORMER_URL;
|
|
24
|
+
const apiKey = process.env.INFORMER_API_KEY;
|
|
25
|
+
const user = process.env.INFORMER_USER;
|
|
26
|
+
const pass = process.env.INFORMER_PASS;
|
|
27
|
+
|
|
28
|
+
if (!baseUrl || (!apiKey && (!user || !pass))) {
|
|
29
|
+
console.error('Missing required environment variables.');
|
|
30
|
+
console.error('Set INFORMER_URL and either INFORMER_API_KEY or INFORMER_USER/INFORMER_PASS in .env');
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const api = createClient({ baseUrl, apiKey, user, pass });
|
|
35
|
+
const migrationsDir = resolve('migrations');
|
|
36
|
+
const envPath = resolve('.env');
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
if (command === 'init') {
|
|
40
|
+
// Check if already initialized
|
|
41
|
+
if (process.env.INFORMER_DEV_WORKSPACE) {
|
|
42
|
+
console.error(`Workspace already initialized: ${process.env.INFORMER_DEV_WORKSPACE}`);
|
|
43
|
+
console.error('Run workspace:reset to start fresh, or remove INFORMER_DEV_WORKSPACE from .env to re-initialize.');
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Read slug from package.json
|
|
48
|
+
const pkgPath = resolve('package.json');
|
|
49
|
+
let pkg;
|
|
50
|
+
try {
|
|
51
|
+
pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
|
|
52
|
+
} catch {
|
|
53
|
+
console.error('Could not read package.json in current directory.');
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let slug = pkg.name;
|
|
58
|
+
if (slug && slug.startsWith('@') && slug.includes('/')) {
|
|
59
|
+
slug = slug.split('/')[1];
|
|
60
|
+
}
|
|
61
|
+
if (!slug) {
|
|
62
|
+
console.error('package.json must have a "name" field.');
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
await init({ api, slug, migrationsDir, envPath });
|
|
67
|
+
|
|
68
|
+
} else if (command === 'migrate') {
|
|
69
|
+
const workspaceId = process.env.INFORMER_DEV_WORKSPACE;
|
|
70
|
+
if (!workspaceId) {
|
|
71
|
+
console.error('No dev workspace found. Run workspace:init first.');
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
await migrate({ api, workspaceId, migrationsDir });
|
|
75
|
+
|
|
76
|
+
} else if (command === 'reset') {
|
|
77
|
+
const workspaceId = process.env.INFORMER_DEV_WORKSPACE;
|
|
78
|
+
if (!workspaceId) {
|
|
79
|
+
console.error('No dev workspace found. Run workspace:init first.');
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
await reset({ api, workspaceId, migrationsDir });
|
|
83
|
+
}
|
|
84
|
+
} catch (err) {
|
|
85
|
+
console.error(`workspace:${command} failed:`, err.message);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entrinsik/vite-plugin-informer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Vite plugin and publish tool for local Magic Report development",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -23,11 +23,13 @@
|
|
|
23
23
|
"types": "./index.d.ts",
|
|
24
24
|
"default": "./src/index.js"
|
|
25
25
|
},
|
|
26
|
-
"./deploy": "./src/deploy.js"
|
|
26
|
+
"./deploy": "./src/deploy.js",
|
|
27
|
+
"./workspace": "./src/workspace.js"
|
|
27
28
|
},
|
|
28
29
|
"bin": {
|
|
29
30
|
"informer-deploy": "./bin/deploy.js",
|
|
30
31
|
"informer-init": "./bin/init.js",
|
|
32
|
+
"informer-workspace": "./bin/workspace.js",
|
|
31
33
|
"create-magic-report": "./bin/init.js"
|
|
32
34
|
},
|
|
33
35
|
"files": [
|
package/src/deploy.js
CHANGED
|
@@ -11,7 +11,7 @@ const TEXT_EXTENSIONS = new Set([
|
|
|
11
11
|
const CHUNK_THRESHOLD = 512 * 1024; // 512KB
|
|
12
12
|
|
|
13
13
|
// Config files to upload from project root (if they exist)
|
|
14
|
-
const ROOT_CONFIG_FILES = ['data-access.yaml'];
|
|
14
|
+
const ROOT_CONFIG_FILES = ['informer.yaml', 'data-access.yaml'];
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Deploy a built Vite project to Informer as an App (or legacy Magic Report).
|
|
@@ -59,7 +59,7 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
|
|
|
59
59
|
|
|
60
60
|
try {
|
|
61
61
|
console.log(`Creating new app "${name}"...`);
|
|
62
|
-
entity = await api.post('apps', { ...payload, type: '
|
|
62
|
+
entity = await api.post('apps', { ...payload, type: 'app' });
|
|
63
63
|
apiPrefix = 'apps';
|
|
64
64
|
} catch (e) {
|
|
65
65
|
// If apps endpoint doesn't exist (404), fall back to legacy reports
|
|
@@ -168,8 +168,72 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
|
|
|
168
168
|
}
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
// 8.
|
|
172
|
-
const
|
|
171
|
+
// 8. Upload migrations/ directory from project root (if it exists)
|
|
172
|
+
const migrationsDir = join(projectRoot, 'migrations');
|
|
173
|
+
let migrationsCount = 0;
|
|
174
|
+
try {
|
|
175
|
+
await access(migrationsDir);
|
|
176
|
+
const migrationFiles = await walkDir(migrationsDir);
|
|
177
|
+
for (const filePath of migrationFiles) {
|
|
178
|
+
const relPath = posix.normalize(relative(projectRoot, filePath).split('\\').join('/'));
|
|
179
|
+
const content = await readFile(filePath, 'utf8');
|
|
180
|
+
await api.put(`${entityPath}/contents/${relPath}`, {
|
|
181
|
+
content,
|
|
182
|
+
encoding: 'utf8'
|
|
183
|
+
});
|
|
184
|
+
console.log(` ${relPath} (from project root)`);
|
|
185
|
+
migrationsCount++;
|
|
186
|
+
}
|
|
187
|
+
} catch {
|
|
188
|
+
// No migrations directory, skip
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 9. Upload server/ directory from project root (if it exists)
|
|
192
|
+
const serverDir = join(projectRoot, 'server');
|
|
193
|
+
let serverCount = 0;
|
|
194
|
+
try {
|
|
195
|
+
await access(serverDir);
|
|
196
|
+
const serverFiles = await walkDir(serverDir);
|
|
197
|
+
for (const filePath of serverFiles) {
|
|
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
|
+
serverCount++;
|
|
209
|
+
}
|
|
210
|
+
} catch {
|
|
211
|
+
// No server directory, skip
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// 10. Deploy: run migrations + scan/bundle server routes
|
|
215
|
+
if (apiPrefix === 'apps') {
|
|
216
|
+
try {
|
|
217
|
+
console.log('Deploying...');
|
|
218
|
+
const result = await api.post(`${entityPath}/_deploy`);
|
|
219
|
+
if (result) {
|
|
220
|
+
if (result.migrated && result.migrated.length > 0) {
|
|
221
|
+
console.log(` Ran ${result.migrated.length} migration(s): ${result.migrated.join(', ')}`);
|
|
222
|
+
}
|
|
223
|
+
if (result.routes && result.routes.length > 0) {
|
|
224
|
+
console.log(` Registered ${result.routes.length} server route(s):`);
|
|
225
|
+
for (const r of result.routes) {
|
|
226
|
+
console.log(` ${r}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
// Server may not support _deploy yet — ignore
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 11. Print URL and return UUID for saving
|
|
236
|
+
const totalFiles = entries.length + configCount + migrationsCount + serverCount;
|
|
173
237
|
const base = baseUrl.replace(/\/+$/, '');
|
|
174
238
|
const entityUrl = apiPrefix === 'apps'
|
|
175
239
|
? `${base}/api/apps/${naturalId}/view`
|
package/src/index.js
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
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 { init, migrate } from './workspace.js';
|
|
2
8
|
|
|
3
9
|
/**
|
|
4
10
|
* Vite plugin for local Magic Report development.
|
|
5
11
|
*
|
|
6
12
|
* - Proxies /api requests to the Informer server with Basic auth
|
|
13
|
+
* - Runs server/ route handlers locally via ssrLoadModule (if server/ dir exists)
|
|
7
14
|
* - Injects window.__INFORMER__ context mock in dev mode
|
|
8
15
|
* - Sets base to './' so built assets use relative paths
|
|
9
16
|
*
|
|
@@ -12,6 +19,9 @@ import dotenv from 'dotenv';
|
|
|
12
19
|
*/
|
|
13
20
|
export default function informer(options = {}) {
|
|
14
21
|
let isDev = false;
|
|
22
|
+
let authHeader = null;
|
|
23
|
+
let serverOrigin = null;
|
|
24
|
+
let devWorkspaceId = null;
|
|
15
25
|
|
|
16
26
|
return {
|
|
17
27
|
name: 'vite-plugin-informer',
|
|
@@ -31,8 +41,11 @@ export default function informer(options = {}) {
|
|
|
31
41
|
const user = process.env.INFORMER_USER;
|
|
32
42
|
const pass = process.env.INFORMER_PASS;
|
|
33
43
|
|
|
44
|
+
devWorkspaceId = process.env.INFORMER_DEV_WORKSPACE || null;
|
|
45
|
+
|
|
34
46
|
if (baseUrl) {
|
|
35
|
-
|
|
47
|
+
serverOrigin = baseUrl.replace(/\/+$/, '');
|
|
48
|
+
authHeader = apiKey
|
|
36
49
|
? 'Bearer ' + apiKey
|
|
37
50
|
: 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
|
|
38
51
|
|
|
@@ -42,7 +55,7 @@ export default function informer(options = {}) {
|
|
|
42
55
|
target: baseUrl,
|
|
43
56
|
changeOrigin: true,
|
|
44
57
|
headers: {
|
|
45
|
-
Authorization:
|
|
58
|
+
Authorization: authHeader
|
|
46
59
|
}
|
|
47
60
|
}
|
|
48
61
|
}
|
|
@@ -53,6 +66,71 @@ export default function informer(options = {}) {
|
|
|
53
66
|
return cfg;
|
|
54
67
|
},
|
|
55
68
|
|
|
69
|
+
async configureServer(server) {
|
|
70
|
+
if (!isDev || !serverOrigin) return;
|
|
71
|
+
|
|
72
|
+
const projectRoot = process.cwd();
|
|
73
|
+
const migrationsDir = resolve(projectRoot, 'migrations');
|
|
74
|
+
|
|
75
|
+
// Auto-provision workspace if migrations/ exists
|
|
76
|
+
if (existsSync(migrationsDir)) {
|
|
77
|
+
const api = createClient({
|
|
78
|
+
baseUrl: serverOrigin,
|
|
79
|
+
apiKey: process.env.INFORMER_API_KEY,
|
|
80
|
+
user: process.env.INFORMER_USER,
|
|
81
|
+
pass: process.env.INFORMER_PASS
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
// Verify existing workspace or create a new one
|
|
86
|
+
let needsInit = !devWorkspaceId;
|
|
87
|
+
if (devWorkspaceId) {
|
|
88
|
+
const ds = await api.get(`datasources/${devWorkspaceId}`);
|
|
89
|
+
if (ds) {
|
|
90
|
+
await migrate({ api, workspaceId: devWorkspaceId, migrationsDir });
|
|
91
|
+
} else {
|
|
92
|
+
console.warn(`[informer] Workspace ${devWorkspaceId} not found on server. Re-creating...`);
|
|
93
|
+
needsInit = true;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (needsInit) {
|
|
98
|
+
const pkg = JSON.parse(await readFile(resolve(projectRoot, 'package.json'), 'utf8'));
|
|
99
|
+
let slug = pkg.name;
|
|
100
|
+
if (slug && slug.startsWith('@') && slug.includes('/')) {
|
|
101
|
+
slug = slug.split('/')[1];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const result = await init({
|
|
105
|
+
api,
|
|
106
|
+
slug,
|
|
107
|
+
migrationsDir,
|
|
108
|
+
envPath: resolve(projectRoot, '.env')
|
|
109
|
+
});
|
|
110
|
+
devWorkspaceId = result.workspaceId;
|
|
111
|
+
}
|
|
112
|
+
} catch (err) {
|
|
113
|
+
console.warn(`[informer] Workspace setup failed: ${err.message}`);
|
|
114
|
+
console.warn('[informer] query() will not work. Run: npx informer-workspace init');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Mount server-side route handlers if a server/ directory exists
|
|
119
|
+
const serverDir = resolve(projectRoot, 'server');
|
|
120
|
+
|
|
121
|
+
if (existsSync(serverDir)) {
|
|
122
|
+
const serverRoutes = createServerRoutes(server, {
|
|
123
|
+
serverOrigin,
|
|
124
|
+
authHeader,
|
|
125
|
+
devWorkspaceId,
|
|
126
|
+
projectRoot,
|
|
127
|
+
roles: (options.mock && options.mock.roles) || []
|
|
128
|
+
});
|
|
129
|
+
server.middlewares.use('/api/_server', serverRoutes);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
},
|
|
133
|
+
|
|
56
134
|
transformIndexHtml: {
|
|
57
135
|
order: 'pre',
|
|
58
136
|
handler(html) {
|
|
@@ -64,6 +142,7 @@ export default function informer(options = {}) {
|
|
|
64
142
|
name: 'Local Development'
|
|
65
143
|
},
|
|
66
144
|
theme: 'light',
|
|
145
|
+
roles: [],
|
|
67
146
|
...options.mock
|
|
68
147
|
};
|
|
69
148
|
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { join, relative, posix } from 'node:path';
|
|
3
|
+
import { parse as parseUrl } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const VALID_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Convert a file path under server/ to a route path.
|
|
9
|
+
*
|
|
10
|
+
* Examples:
|
|
11
|
+
* server/orders/index.js -> /orders
|
|
12
|
+
* server/orders/[id].js -> /orders/:id
|
|
13
|
+
* server/orders/[id]/approve.js -> /orders/:id/approve
|
|
14
|
+
* server/index.js -> /
|
|
15
|
+
*/
|
|
16
|
+
function filePathToRoute(filePath) {
|
|
17
|
+
let route = filePath
|
|
18
|
+
.replace(/^server\//, '')
|
|
19
|
+
.replace(/\.js$/, '');
|
|
20
|
+
|
|
21
|
+
route = route.replace(/\[([^\]]+)\]/g, ':$1');
|
|
22
|
+
|
|
23
|
+
route = route.replace(/\/index$/, '');
|
|
24
|
+
|
|
25
|
+
if (route === 'index' || route === '') {
|
|
26
|
+
return '/';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!route.startsWith('/')) {
|
|
30
|
+
route = '/' + route;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (route.length > 1 && route.endsWith('/')) {
|
|
34
|
+
route = route.slice(0, -1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return route;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Match an incoming request against a route table.
|
|
42
|
+
*
|
|
43
|
+
* @param {Array<{ method: string, path: string }>} routes
|
|
44
|
+
* @param {string} method - HTTP method
|
|
45
|
+
* @param {string} requestPath - e.g. "/orders/123"
|
|
46
|
+
* @returns {{ route: Object, params: Object }|null}
|
|
47
|
+
*/
|
|
48
|
+
function matchRoute(routes, method, requestPath) {
|
|
49
|
+
const upperMethod = method.toUpperCase();
|
|
50
|
+
const candidates = routes.filter(r => r.method === upperMethod);
|
|
51
|
+
|
|
52
|
+
const normalizedPath = requestPath.length > 1 && requestPath.endsWith('/')
|
|
53
|
+
? requestPath.slice(0, -1)
|
|
54
|
+
: requestPath;
|
|
55
|
+
|
|
56
|
+
const requestSegments = normalizedPath.split('/').filter(Boolean);
|
|
57
|
+
|
|
58
|
+
let bestMatch = null;
|
|
59
|
+
let bestScore = -1;
|
|
60
|
+
|
|
61
|
+
for (const route of candidates) {
|
|
62
|
+
const routeSegments = route.path.split('/').filter(Boolean);
|
|
63
|
+
|
|
64
|
+
if (routeSegments.length !== requestSegments.length) continue;
|
|
65
|
+
|
|
66
|
+
const params = {};
|
|
67
|
+
let score = 0;
|
|
68
|
+
let matched = true;
|
|
69
|
+
|
|
70
|
+
for (let i = 0; i < routeSegments.length; i++) {
|
|
71
|
+
const routeSeg = routeSegments[i];
|
|
72
|
+
const reqSeg = requestSegments[i];
|
|
73
|
+
|
|
74
|
+
if (routeSeg.startsWith(':')) {
|
|
75
|
+
params[routeSeg.slice(1)] = decodeURIComponent(reqSeg);
|
|
76
|
+
} else if (routeSeg === reqSeg) {
|
|
77
|
+
score++;
|
|
78
|
+
} else {
|
|
79
|
+
matched = false;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (matched && score > bestScore) {
|
|
85
|
+
bestMatch = { route, params };
|
|
86
|
+
bestScore = score;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (requestSegments.length === 0) {
|
|
91
|
+
const rootRoute = candidates.find(r => r.path === '/');
|
|
92
|
+
if (rootRoute) {
|
|
93
|
+
return { route: rootRoute, params: {} };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return bestMatch;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Recursively scan the server/ directory for .js handler files.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} serverDir - Absolute path to server/ directory
|
|
104
|
+
* @returns {Promise<Array<{ path: string, filePath: string }>>}
|
|
105
|
+
*/
|
|
106
|
+
async function scanRoutes(serverDir) {
|
|
107
|
+
const files = await walkJsFiles(serverDir, 'server');
|
|
108
|
+
return files.map(({ relPath, absPath }) => ({
|
|
109
|
+
path: filePathToRoute(relPath),
|
|
110
|
+
filePath: absPath
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function walkJsFiles(dir, basePath) {
|
|
115
|
+
const results = [];
|
|
116
|
+
let items;
|
|
117
|
+
try {
|
|
118
|
+
items = await readdir(dir);
|
|
119
|
+
} catch {
|
|
120
|
+
return results;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const item of items) {
|
|
124
|
+
const full = join(dir, item);
|
|
125
|
+
const childPath = `${basePath}/${item}`;
|
|
126
|
+
const s = await stat(full);
|
|
127
|
+
|
|
128
|
+
if (s.isDirectory()) {
|
|
129
|
+
results.push(...await walkJsFiles(full, childPath));
|
|
130
|
+
} else if (item.endsWith('.js')) {
|
|
131
|
+
results.push({ relPath: childPath, absPath: full });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return results;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Create Connect middleware for dev-mode server route execution.
|
|
140
|
+
*
|
|
141
|
+
* @param {Object} viteServer - Vite dev server instance
|
|
142
|
+
* @param {{ serverOrigin: string, authHeader: string, devWorkspaceId: string|null, projectRoot: string }} opts
|
|
143
|
+
* @returns {Function} Connect middleware
|
|
144
|
+
*/
|
|
145
|
+
export function createMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, roles }) {
|
|
146
|
+
const serverDir = join(projectRoot, 'server');
|
|
147
|
+
|
|
148
|
+
// query() implementation — proxies to the workspace _sql endpoint
|
|
149
|
+
async function query(sql, params) {
|
|
150
|
+
if (!devWorkspaceId) {
|
|
151
|
+
throw new Error('query() requires INFORMER_DEV_WORKSPACE. Run: npx informer-workspace init');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const resp = await globalThis.fetch(`${serverOrigin}/api/datasources/${devWorkspaceId}/_sql`, {
|
|
155
|
+
method: 'POST',
|
|
156
|
+
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
|
|
157
|
+
body: JSON.stringify({ sql, params: params || [] })
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
if (!resp.ok) {
|
|
161
|
+
const err = await resp.json().catch(() => ({}));
|
|
162
|
+
const detail = err.message || resp.statusText;
|
|
163
|
+
throw new Error(`query() failed: ${resp.status} ${detail} (${serverOrigin}/api/datasources/${devWorkspaceId}/_sql)`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const data = await resp.json();
|
|
167
|
+
return data.rows;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// fetch() implementation — proxies API calls to the Informer server
|
|
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
|
+
return async function serverRoutesMiddleware(req, res, next) {
|
|
190
|
+
try {
|
|
191
|
+
// The URL has already had /api/_server stripped by Vite's middleware.use()
|
|
192
|
+
const parsed = parseUrl(req.url, true);
|
|
193
|
+
const routePath = parsed.pathname || '/';
|
|
194
|
+
|
|
195
|
+
// Scan server/ directory for handler files
|
|
196
|
+
const scannedRoutes = await scanRoutes(serverDir);
|
|
197
|
+
|
|
198
|
+
// Build route table with all valid methods per file
|
|
199
|
+
const routeTable = [];
|
|
200
|
+
for (const scanned of scannedRoutes) {
|
|
201
|
+
for (const method of VALID_METHODS) {
|
|
202
|
+
routeTable.push({ method, path: scanned.path, filePath: scanned.filePath });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Match request against route table
|
|
207
|
+
const match = matchRoute(routeTable, req.method, routePath);
|
|
208
|
+
if (!match) return next();
|
|
209
|
+
|
|
210
|
+
// Load handler via Vite's ssrLoadModule (ESM + HMR)
|
|
211
|
+
const mod = await viteServer.ssrLoadModule(match.route.filePath);
|
|
212
|
+
const handler = mod[req.method.toUpperCase()];
|
|
213
|
+
|
|
214
|
+
if (typeof handler !== 'function') {
|
|
215
|
+
res.statusCode = 404;
|
|
216
|
+
res.setHeader('Content-Type', 'application/json');
|
|
217
|
+
res.end(JSON.stringify({ error: 'Method not found' }));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Read request body
|
|
222
|
+
const chunks = [];
|
|
223
|
+
for await (const chunk of req) {
|
|
224
|
+
chunks.push(chunk);
|
|
225
|
+
}
|
|
226
|
+
const rawBody = Buffer.concat(chunks).toString('utf8');
|
|
227
|
+
let body = null;
|
|
228
|
+
if (rawBody) {
|
|
229
|
+
try { body = JSON.parse(rawBody); } catch { body = rawBody; }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Build request context
|
|
233
|
+
const request = {
|
|
234
|
+
method: req.method.toUpperCase(),
|
|
235
|
+
path: routePath,
|
|
236
|
+
params: match.params,
|
|
237
|
+
query: parsed.query || {},
|
|
238
|
+
body,
|
|
239
|
+
headers: req.headers,
|
|
240
|
+
roles: roles || []
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// Call handler
|
|
244
|
+
const result = await handler({ query, fetch: apiFetch, env: {}, request });
|
|
245
|
+
|
|
246
|
+
// Normalize response (mirrors app-sandbox.js buildInvokeScript logic)
|
|
247
|
+
let status, responseBody, responseHeaders;
|
|
248
|
+
|
|
249
|
+
if (result === undefined || result === null) {
|
|
250
|
+
status = 204;
|
|
251
|
+
responseBody = null;
|
|
252
|
+
responseHeaders = {};
|
|
253
|
+
} else if (typeof result === 'object' && typeof result.status === 'number') {
|
|
254
|
+
status = result.status || 200;
|
|
255
|
+
responseBody = result.body !== undefined ? result.body : null;
|
|
256
|
+
responseHeaders = result.headers || {};
|
|
257
|
+
} else {
|
|
258
|
+
status = 200;
|
|
259
|
+
responseBody = result;
|
|
260
|
+
responseHeaders = { 'content-type': 'application/json' };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
res.statusCode = status;
|
|
264
|
+
for (const [key, value] of Object.entries(responseHeaders)) {
|
|
265
|
+
res.setHeader(key, value);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (responseBody === null) {
|
|
269
|
+
res.end();
|
|
270
|
+
} else {
|
|
271
|
+
if (!res.getHeader('content-type')) {
|
|
272
|
+
res.setHeader('Content-Type', 'application/json');
|
|
273
|
+
}
|
|
274
|
+
res.end(JSON.stringify(responseBody));
|
|
275
|
+
}
|
|
276
|
+
} catch (err) {
|
|
277
|
+
viteServer.ssrFixStacktrace(err);
|
|
278
|
+
console.error('[server-routes]', err);
|
|
279
|
+
res.statusCode = 500;
|
|
280
|
+
res.setHeader('Content-Type', 'application/json');
|
|
281
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
}
|
package/src/workspace.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { readdir, readFile, writeFile, access } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Execute raw SQL against a workspace datasource via the _sql route.
|
|
6
|
+
*/
|
|
7
|
+
async function execSql(api, workspaceId, sql, params = []) {
|
|
8
|
+
return api.post(`datasources/${workspaceId}/_sql`, { sql, params });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Ensure the _migrations tracking table exists in the workspace.
|
|
13
|
+
*/
|
|
14
|
+
async function ensureMigrationsTable(api, workspaceId) {
|
|
15
|
+
await execSql(api, workspaceId, `
|
|
16
|
+
CREATE TABLE IF NOT EXISTS _migrations (
|
|
17
|
+
name TEXT PRIMARY KEY,
|
|
18
|
+
executed_at TIMESTAMPTZ DEFAULT NOW()
|
|
19
|
+
)
|
|
20
|
+
`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Read local migration files sorted alphabetically.
|
|
25
|
+
* @returns {Promise<string[]>} Sorted filenames (e.g., ['001-create-orders.sql', '002-add-index.sql'])
|
|
26
|
+
*/
|
|
27
|
+
async function readMigrationFiles(migrationsDir) {
|
|
28
|
+
try {
|
|
29
|
+
await access(migrationsDir);
|
|
30
|
+
} catch {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const entries = await readdir(migrationsDir);
|
|
35
|
+
return entries
|
|
36
|
+
.filter(f => f.endsWith('.sql'))
|
|
37
|
+
.sort();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get the set of already-executed migration names.
|
|
42
|
+
*/
|
|
43
|
+
async function getCompletedMigrations(api, workspaceId) {
|
|
44
|
+
const result = await execSql(api, workspaceId, 'SELECT name FROM _migrations');
|
|
45
|
+
return new Set(result.rows.map(r => r.name));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Run pending migrations against a workspace.
|
|
50
|
+
*/
|
|
51
|
+
async function runMigrations(api, workspaceId, migrationsDir) {
|
|
52
|
+
await ensureMigrationsTable(api, workspaceId);
|
|
53
|
+
|
|
54
|
+
const files = await readMigrationFiles(migrationsDir);
|
|
55
|
+
if (files.length === 0) {
|
|
56
|
+
console.log('No migration files found in migrations/');
|
|
57
|
+
return { migrated: [], total: 0 };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const completed = await getCompletedMigrations(api, workspaceId);
|
|
61
|
+
const pending = files.filter(f => !completed.has(f));
|
|
62
|
+
|
|
63
|
+
if (pending.length === 0) {
|
|
64
|
+
console.log(`All ${files.length} migration(s) already applied.`);
|
|
65
|
+
return { migrated: [], total: files.length };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const migrated = [];
|
|
69
|
+
for (const file of pending) {
|
|
70
|
+
const sql = await readFile(join(migrationsDir, file), 'utf8');
|
|
71
|
+
console.log(` Running ${file}...`);
|
|
72
|
+
await execSql(api, workspaceId, sql);
|
|
73
|
+
await execSql(api, workspaceId, 'INSERT INTO _migrations (name) VALUES ($1)', [file]);
|
|
74
|
+
migrated.push(file);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
console.log(`Applied ${migrated.length} migration(s). Total: ${files.length}`);
|
|
78
|
+
return { migrated, total: files.length };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Initialize a dev workspace datasource.
|
|
83
|
+
*
|
|
84
|
+
* Creates a new workspace datasource named "{slug}-dev", runs all
|
|
85
|
+
* migrations, and saves the workspace ID to .env.
|
|
86
|
+
*/
|
|
87
|
+
export async function init({ api, slug, migrationsDir, envPath }) {
|
|
88
|
+
const name = `${slug}-dev`;
|
|
89
|
+
console.log(`Creating workspace datasource "${name}"...`);
|
|
90
|
+
|
|
91
|
+
const datasource = await api.post('datasources', {
|
|
92
|
+
name,
|
|
93
|
+
type: 'workspace'
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
if (!datasource || !datasource.naturalId) {
|
|
97
|
+
throw new Error('Failed to create workspace datasource');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const workspaceId = datasource.naturalId;
|
|
101
|
+
console.log(`Created: ${workspaceId}`);
|
|
102
|
+
|
|
103
|
+
// Run all migrations
|
|
104
|
+
const result = await runMigrations(api, workspaceId, migrationsDir);
|
|
105
|
+
|
|
106
|
+
// Save workspace ID to .env
|
|
107
|
+
let envContent = '';
|
|
108
|
+
try {
|
|
109
|
+
envContent = await readFile(envPath, 'utf8');
|
|
110
|
+
} catch {
|
|
111
|
+
// .env doesn't exist yet
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (envContent.includes('INFORMER_DEV_WORKSPACE=')) {
|
|
115
|
+
envContent = envContent.replace(
|
|
116
|
+
/INFORMER_DEV_WORKSPACE=.*/,
|
|
117
|
+
`INFORMER_DEV_WORKSPACE=${workspaceId}`
|
|
118
|
+
);
|
|
119
|
+
} else {
|
|
120
|
+
const nl = envContent.length > 0 && !envContent.endsWith('\n') ? '\n' : '';
|
|
121
|
+
envContent += `${nl}\n# Dev workspace (created by workspace:init)\nINFORMER_DEV_WORKSPACE=${workspaceId}\n`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
await writeFile(envPath, envContent);
|
|
125
|
+
console.log(`Saved INFORMER_DEV_WORKSPACE=${workspaceId} to .env`);
|
|
126
|
+
|
|
127
|
+
return { workspaceId, ...result };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Run pending migrations against the dev workspace.
|
|
132
|
+
*/
|
|
133
|
+
export async function migrate({ api, workspaceId, migrationsDir }) {
|
|
134
|
+
console.log(`Migrating workspace ${workspaceId}...`);
|
|
135
|
+
return runMigrations(api, workspaceId, migrationsDir);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Reset the dev workspace: drop all tables, then re-run all migrations.
|
|
140
|
+
*/
|
|
141
|
+
export async function reset({ api, workspaceId, migrationsDir }) {
|
|
142
|
+
console.log(`Resetting workspace ${workspaceId}...`);
|
|
143
|
+
|
|
144
|
+
// Drop all tables in the workspace schema
|
|
145
|
+
await execSql(api, workspaceId, `
|
|
146
|
+
DO $$
|
|
147
|
+
DECLARE r RECORD;
|
|
148
|
+
BEGIN
|
|
149
|
+
FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = current_schema()
|
|
150
|
+
LOOP
|
|
151
|
+
EXECUTE 'DROP TABLE IF EXISTS "' || r.tablename || '" CASCADE';
|
|
152
|
+
END LOOP;
|
|
153
|
+
END $$
|
|
154
|
+
`);
|
|
155
|
+
console.log('Dropped all tables.');
|
|
156
|
+
|
|
157
|
+
// Re-run all migrations from scratch
|
|
158
|
+
return runMigrations(api, workspaceId, migrationsDir);
|
|
159
|
+
}
|