@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entrinsik/vite-plugin-informer",
3
- "version": "2.1.0",
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",
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. Run migrations (if the server supports it)
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
- const result = await api.post(`${entityPath}/_migrate`);
195
- if (result && result.migrated && result.migrated.length > 0) {
196
- console.log(`Ran ${result.migrated.length} migration(s): ${result.migrated.join(', ')}`);
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 _migrate yet — ignore
231
+ // Server may not support _deploy yet — ignore
200
232
  }
201
233
  }
202
234
 
203
- // 9. Print URL and return UUID for saving
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
- * - Rewrites /api/_query to the dev workspace's _sql route (if configured)
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 || !devWorkspaceId || !serverOrigin) return;
69
+ async configureServer(server) {
70
+ if (!isDev || !serverOrigin) return;
65
71
 
66
- // Intercept POST /api/_query and proxy to the dev workspace's _sql route.
67
- // This middleware runs before the generic /api proxy, so the rewrite takes effect.
68
- server.middlewares.use('/api/_query', async (req, res, next) => {
69
- if (req.method !== 'POST') return next();
72
+ const projectRoot = process.cwd();
73
+ const migrationsDir = resolve(projectRoot, 'migrations');
70
74
 
71
- // Read request body
72
- const chunks = [];
73
- for await (const chunk of req) {
74
- chunks.push(chunk);
75
- }
76
- const body = Buffer.concat(chunks).toString('utf8');
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
- const upstream = await fetch(url, {
82
- method: 'POST',
83
- headers: {
84
- 'Content-Type': 'application/json',
85
- Authorization: authHeader
86
- },
87
- body
88
- });
89
-
90
- res.statusCode = upstream.status;
91
- res.setHeader('Content-Type', 'application/json');
92
- const text = await upstream.text();
93
- res.end(text);
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
- res.statusCode = 502;
96
- res.setHeader('Content-Type', 'application/json');
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
+ }