@entrinsik/vite-plugin-informer 2.1.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/package.json +1 -1
- package/src/deploy.js +39 -7
- package/src/index.js +64 -32
- package/src/server-routes.js +284 -0
package/package.json
CHANGED
package/src/deploy.js
CHANGED
|
@@ -188,20 +188,52 @@ export async function deploy({ baseUrl, apiKey, user, pass, distDir, name, descr
|
|
|
188
188
|
// No migrations directory, skip
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
-
// 9.
|
|
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
|
|
192
215
|
if (apiPrefix === 'apps') {
|
|
193
216
|
try {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
+
}
|
|
197
229
|
}
|
|
198
230
|
} catch {
|
|
199
|
-
// Server may not support
|
|
231
|
+
// Server may not support _deploy yet — ignore
|
|
200
232
|
}
|
|
201
233
|
}
|
|
202
234
|
|
|
203
|
-
//
|
|
204
|
-
const totalFiles = entries.length + configCount + migrationsCount;
|
|
235
|
+
// 11. Print URL and return UUID for saving
|
|
236
|
+
const totalFiles = entries.length + configCount + migrationsCount + serverCount;
|
|
205
237
|
const base = baseUrl.replace(/\/+$/, '');
|
|
206
238
|
const entityUrl = apiPrefix === 'apps'
|
|
207
239
|
? `${base}/api/apps/${naturalId}/view`
|
package/src/index.js
CHANGED
|
@@ -1,10 +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
|
|
7
|
-
* -
|
|
13
|
+
* - Runs server/ route handlers locally via ssrLoadModule (if server/ dir exists)
|
|
8
14
|
* - Injects window.__INFORMER__ context mock in dev mode
|
|
9
15
|
* - Sets base to './' so built assets use relative paths
|
|
10
16
|
*
|
|
@@ -60,43 +66,69 @@ export default function informer(options = {}) {
|
|
|
60
66
|
return cfg;
|
|
61
67
|
},
|
|
62
68
|
|
|
63
|
-
configureServer(server) {
|
|
64
|
-
if (!isDev || !
|
|
69
|
+
async configureServer(server) {
|
|
70
|
+
if (!isDev || !serverOrigin) return;
|
|
65
71
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
server.middlewares.use('/api/_query', async (req, res, next) => {
|
|
69
|
-
if (req.method !== 'POST') return next();
|
|
72
|
+
const projectRoot = process.cwd();
|
|
73
|
+
const migrationsDir = resolve(projectRoot, 'migrations');
|
|
70
74
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
+
});
|
|
77
83
|
|
|
78
|
-
// Forward to workspace _sql endpoint
|
|
79
|
-
const url = `${serverOrigin}/api/datasources/${devWorkspaceId}/_sql`;
|
|
80
84
|
try {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
+
}
|
|
94
112
|
} catch (err) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
res.end(JSON.stringify({ error: err.message }));
|
|
113
|
+
console.warn(`[informer] Workspace setup failed: ${err.message}`);
|
|
114
|
+
console.warn('[informer] query() will not work. Run: npx informer-workspace init');
|
|
98
115
|
}
|
|
99
|
-
}
|
|
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
|
+
|
|
100
132
|
},
|
|
101
133
|
|
|
102
134
|
transformIndexHtml: {
|
|
@@ -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
|
+
}
|