@malloy-publisher/server 0.0.204 → 0.0.206
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/build.ts +10 -1
- package/dist/app/api-doc.yaml +494 -397
- package/dist/app/assets/{EnvironmentPage-CX06cjOF.js → EnvironmentPage-BYwBeC2F.js} +1 -1
- package/dist/app/assets/HomePage-ivu4vdpj.js +1 -0
- package/dist/app/assets/{MainPage-nUJ9YatG.js → MainPage-B2DnHEDU.js} +2 -2
- package/dist/app/assets/MaterializationsPage-BZEuwF9P.js +1 -0
- package/dist/app/assets/ModelPage-Dpu3bfPg.js +1 -0
- package/dist/app/assets/{PackagePage-BaEVdEAG.js → PackagePage-B8PwcRHt.js} +1 -1
- package/dist/app/assets/{RouteError-BShQjZio.js → RouteError-BhbywAeC.js} +1 -1
- package/dist/app/assets/{WorkbookPage-CBn6ZjJW.js → WorkbookPage-C-JXsJG0.js} +1 -1
- package/dist/app/assets/{core-DECXYL4E.es-OaRfXwuQ.js → core-pPlPr7jK.es-CNEOlxKB.js} +1 -1
- package/dist/app/assets/{index-BLfPC1gy.js → index-BHEm8Egc.js} +1 -1
- package/dist/app/assets/{index-Dy3YhAZQ.js → index-BsvDrV14.js} +1 -1
- package/dist/app/assets/{index-DqiJ0bWp.js → index-ChR1fKR2.js} +134 -134
- package/dist/app/assets/{index.umd-DAN9K8yC.js → index.umd-BVLPYNuj.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.mjs +18 -8
- package/dist/package_load_worker.mjs +19 -2
- package/dist/runtime/publisher.js +318 -0
- package/dist/server.mjs +1703 -1443
- package/package.json +5 -4
- package/scripts/bake-duckdb-extensions.js +104 -0
- package/src/constants.ts +12 -0
- package/src/controller/materialization.controller.ts +79 -35
- package/src/controller/package.controller.spec.ts +179 -0
- package/src/controller/package.controller.ts +60 -73
- package/src/controller/watch-mode.controller.ts +176 -46
- package/src/dto/package.dto.ts +16 -1
- package/src/errors.spec.ts +33 -0
- package/src/errors.ts +18 -0
- package/src/health.spec.ts +34 -1
- package/src/instrumentation.ts +33 -17
- package/src/materialization_metrics.ts +66 -0
- package/src/mcp/error_messages.spec.ts +35 -0
- package/src/mcp/error_messages.ts +14 -1
- package/src/mcp/handler_utils.ts +12 -0
- package/src/package_load/package_load_pool.ts +7 -1
- package/src/package_load/package_load_worker.ts +44 -4
- package/src/package_load/protocol.ts +7 -1
- package/src/runtime/publisher.js +318 -0
- package/src/server-old.ts +7 -149
- package/src/server.ts +488 -190
- package/src/service/authorize_integration.spec.ts +163 -2
- package/src/service/compile_authorize.spec.ts +85 -0
- package/src/service/environment.ts +270 -12
- package/src/service/environment_store.spec.ts +0 -81
- package/src/service/environment_store.ts +142 -34
- package/src/service/explore_visibility.spec.ts +434 -0
- package/src/service/exports_probe.spec.ts +107 -0
- package/src/service/manifest_loader.spec.ts +99 -0
- package/src/service/manifest_loader.ts +95 -0
- package/src/service/materialization_service.spec.ts +324 -512
- package/src/service/materialization_service.ts +816 -656
- package/src/service/model.ts +444 -13
- package/src/service/package.spec.ts +14 -2
- package/src/service/package.ts +271 -20
- package/src/service/package_rollback.spec.ts +190 -0
- package/src/service/package_worker_path.spec.ts +223 -0
- package/src/service/query_boundary.spec.ts +470 -0
- package/src/storage/DatabaseInterface.ts +35 -57
- package/src/storage/StorageManager.mock.ts +0 -9
- package/src/storage/StorageManager.ts +7 -290
- package/src/storage/duckdb/DuckDBConnection.ts +70 -124
- package/src/storage/duckdb/DuckDBRepository.ts +2 -35
- package/src/storage/duckdb/MaterializationRepository.ts +52 -27
- package/src/storage/duckdb/schema.ts +4 -20
- package/tests/fixtures/authorize-compile/model.malloy +9 -0
- package/tests/fixtures/authorize-compile/publisher.json +4 -0
- package/tests/fixtures/html-pages-nopublic/model.malloy +1 -0
- package/tests/fixtures/html-pages-nopublic/publisher.json +5 -0
- package/tests/fixtures/html-pages-test/data.csv +3 -0
- package/tests/fixtures/html-pages-test/public/assets/app.css +3 -0
- package/tests/fixtures/html-pages-test/public/data.json +1 -0
- package/tests/fixtures/html-pages-test/public/index.html +9 -0
- package/tests/fixtures/html-pages-test/public/sub/page2.html +9 -0
- package/tests/fixtures/html-pages-test/publisher.json +5 -0
- package/tests/fixtures/html-pages-test/report.malloy +1 -0
- package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +92 -0
- package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +138 -0
- package/tests/integration/html_pages/html_pages.integration.spec.ts +378 -0
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +175 -0
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +277 -266
- package/tests/integration/watch-mode/watch_mode.integration.spec.ts +421 -0
- package/tests/unit/duckdb/attached_databases.test.ts +111 -0
- package/tests/unit/duckdb/duckdb_connection.test.ts +181 -0
- package/tests/unit/duckdb/legacy_schema_migration.test.ts +7 -4
- package/tests/unit/duckdb/repositories.test.ts +208 -0
- package/dist/app/assets/HomePage-CNFt_eUU.js +0 -1
- package/dist/app/assets/MaterializationsPage-B5goxVXW.js +0 -1
- package/dist/app/assets/ModelPage-Ba7Xh4lL.js +0 -1
- package/src/controller/manifest.controller.ts +0 -38
- package/src/service/manifest_service.spec.ts +0 -206
- package/src/service/manifest_service.ts +0 -117
- package/src/service/materialized_table_gc.spec.ts +0 -384
- package/src/service/materialized_table_gc.ts +0 -231
- package/src/storage/duckdb/DuckDBManifestStore.ts +0 -70
- package/src/storage/duckdb/ManifestRepository.ts +0 -120
- package/src/storage/duckdb/manifest_store.spec.ts +0 -133
- package/src/storage/ducklake/DuckLakeManifestStore.ts +0 -155
- package/tests/unit/storage/StorageManager.test.ts +0 -166
package/src/server.ts
CHANGED
|
@@ -8,7 +8,6 @@ import * as http from "http";
|
|
|
8
8
|
import { createProxyMiddleware } from "http-proxy-middleware";
|
|
9
9
|
import { AddressInfo } from "net";
|
|
10
10
|
import * as path from "path";
|
|
11
|
-
import { ParsedQs } from "qs";
|
|
12
11
|
import { fileURLToPath } from "url";
|
|
13
12
|
import { CompileController } from "./controller/compile.controller";
|
|
14
13
|
import { ConnectionController } from "./controller/connection.controller";
|
|
@@ -38,15 +37,14 @@ import { getMemoryGovernorConfig } from "./config";
|
|
|
38
37
|
import { setFilterDeprecationHeaders } from "./filter_deprecation";
|
|
39
38
|
import { checkHeapConfiguration } from "./heap_check";
|
|
40
39
|
import { queryConcurrency } from "./query_concurrency";
|
|
41
|
-
import { ManifestController } from "./controller/manifest.controller";
|
|
42
40
|
import { MaterializationController } from "./controller/materialization.controller";
|
|
43
41
|
import { initializeMcpServer } from "./mcp/server";
|
|
44
42
|
import { registerLegacyRoutes } from "./server-old";
|
|
45
43
|
import { EnvironmentStore } from "./service/environment_store";
|
|
46
|
-
import { ManifestService } from "./service/manifest_service";
|
|
47
44
|
import { MaterializationService } from "./service/materialization_service";
|
|
48
45
|
import { normalizeQueryArray } from "./query_param_utils";
|
|
49
46
|
import { PackageMemoryGovernor } from "./service/package_memory_governor";
|
|
47
|
+
import { assertSafePackageName, safeJoinUnderRoot } from "./path_safety";
|
|
50
48
|
|
|
51
49
|
export { normalizeQueryArray } from "./query_param_utils";
|
|
52
50
|
|
|
@@ -85,6 +83,14 @@ function parseArgs() {
|
|
|
85
83
|
i++;
|
|
86
84
|
} else if (arg === "--init") {
|
|
87
85
|
process.env.INITIALIZE_STORAGE = "true";
|
|
86
|
+
} else if (arg === "--watch-env" && args[i + 1]) {
|
|
87
|
+
// Append (don't overwrite) so multiple --watch-env flags compose
|
|
88
|
+
// and so an explicit env var pre-set still wins.
|
|
89
|
+
const existing = process.env.PUBLISHER_WATCH || "";
|
|
90
|
+
process.env.PUBLISHER_WATCH = existing
|
|
91
|
+
? `${existing},${args[i + 1]}`
|
|
92
|
+
: args[i + 1];
|
|
93
|
+
i++;
|
|
88
94
|
} else if (arg === "--help" || arg === "-h") {
|
|
89
95
|
console.log("Malloy Publisher Server");
|
|
90
96
|
console.log("");
|
|
@@ -115,6 +121,21 @@ function parseArgs() {
|
|
|
115
121
|
console.log(
|
|
116
122
|
" --init Initialize the storage (default: false)",
|
|
117
123
|
);
|
|
124
|
+
console.log(
|
|
125
|
+
" --watch-env <name> Enable dev-mode watch for the named environment.",
|
|
126
|
+
);
|
|
127
|
+
console.log(
|
|
128
|
+
" Mounts local-dir packages in-place (symlink, not",
|
|
129
|
+
);
|
|
130
|
+
console.log(
|
|
131
|
+
" copy) so source-edit live reload works. A comma-",
|
|
132
|
+
);
|
|
133
|
+
console.log(
|
|
134
|
+
" separated PUBLISHER_WATCH mounts all listed envs in",
|
|
135
|
+
);
|
|
136
|
+
console.log(
|
|
137
|
+
" place, but only the first one auto-reloads.",
|
|
138
|
+
);
|
|
118
139
|
console.log(" --help, -h Show this help message");
|
|
119
140
|
process.exit(0);
|
|
120
141
|
}
|
|
@@ -164,7 +185,6 @@ app.use(httpMetricsMiddleware);
|
|
|
164
185
|
// chase OOMKills before checking the obvious config.
|
|
165
186
|
checkHeapConfiguration();
|
|
166
187
|
const environmentStore = new EnvironmentStore(SERVER_ROOT);
|
|
167
|
-
const manifestService = new ManifestService(environmentStore);
|
|
168
188
|
const watchModeController = new WatchModeController(environmentStore);
|
|
169
189
|
const connectionController = new ConnectionController(environmentStore);
|
|
170
190
|
const modelController = new ModelController(environmentStore);
|
|
@@ -179,24 +199,14 @@ const memoryGovernor = memoryGovernorConfig
|
|
|
179
199
|
: null;
|
|
180
200
|
memoryGovernor?.start();
|
|
181
201
|
environmentStore.setMemoryGovernor(memoryGovernor);
|
|
182
|
-
const packageController = new PackageController(
|
|
183
|
-
environmentStore,
|
|
184
|
-
manifestService,
|
|
185
|
-
);
|
|
202
|
+
const packageController = new PackageController(environmentStore);
|
|
186
203
|
const databaseController = new DatabaseController(environmentStore);
|
|
187
204
|
const queryController = new QueryController(environmentStore);
|
|
188
205
|
const compileController = new CompileController(environmentStore);
|
|
189
|
-
const materializationService = new MaterializationService(
|
|
190
|
-
environmentStore,
|
|
191
|
-
manifestService,
|
|
192
|
-
);
|
|
206
|
+
const materializationService = new MaterializationService(environmentStore);
|
|
193
207
|
const materializationController = new MaterializationController(
|
|
194
208
|
materializationService,
|
|
195
209
|
);
|
|
196
|
-
const manifestController = new ManifestController(
|
|
197
|
-
environmentStore,
|
|
198
|
-
manifestService,
|
|
199
|
-
);
|
|
200
210
|
|
|
201
211
|
export const mcpApp = express();
|
|
202
212
|
|
|
@@ -286,6 +296,290 @@ mcpApp.all(MCP_ENDPOINT, async (req, res) => {
|
|
|
286
296
|
}
|
|
287
297
|
});
|
|
288
298
|
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
// In-package HTML data apps
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// These routes must come before the SPA catch-all and (in dev) the Vite proxy
|
|
303
|
+
// so that:
|
|
304
|
+
// - `/sdk/publisher.js` → Publisher runtime helper
|
|
305
|
+
// - `/environments/<env>/packages/<pkg>/<file.ext>` → static file from
|
|
306
|
+
// inside the package dir
|
|
307
|
+
// - `/api/v0/.../events` → live-reload SSE (registered in API routes
|
|
308
|
+
// below; this comment is the cross-reference)
|
|
309
|
+
|
|
310
|
+
// Serve the runtime helper that in-package HTML pages load via
|
|
311
|
+
// <script src="/sdk/publisher.js">. Path resolved once at module load.
|
|
312
|
+
const PUBLISHER_RUNTIME_PATH = path.join(
|
|
313
|
+
path.dirname(__filename_esm),
|
|
314
|
+
"runtime",
|
|
315
|
+
"publisher.js",
|
|
316
|
+
);
|
|
317
|
+
app.get("/sdk/publisher.js", (_req, res) => {
|
|
318
|
+
res.type("application/javascript");
|
|
319
|
+
// Short cache so live edits during local dev show up quickly. In
|
|
320
|
+
// production this file is content-stable per release.
|
|
321
|
+
res.setHeader("cache-control", "public, max-age=60");
|
|
322
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
323
|
+
res.sendFile(PUBLISHER_RUNTIME_PATH, (err) => {
|
|
324
|
+
if (err) {
|
|
325
|
+
logger.error("Failed to send publisher.js runtime", { error: err });
|
|
326
|
+
if (!res.headersSent) res.status(500).end();
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// Serve files from inside a package directory at
|
|
332
|
+
// /environments/<env>/packages/<pkg>/<relative-path>
|
|
333
|
+
//
|
|
334
|
+
// This route fully owns its prefix — it does NOT fall through to the SPA on
|
|
335
|
+
// missing files, because doing so would mask 404s (and in dev mode the SPA
|
|
336
|
+
// catch-all errors out before it can reply). Behavior:
|
|
337
|
+
// - `/environments/<env>/packages/<pkg>` → 302 to `…/<pkg>/`
|
|
338
|
+
// - `/environments/<env>/packages/<pkg>/` → serve `<pkgRoot>/public/index.html`
|
|
339
|
+
// - `/environments/<env>/packages/<pkg>/foo/` → serve `<pkgRoot>/public/foo/index.html`
|
|
340
|
+
// - `/environments/<env>/packages/<pkg>/<file>` → serve `<pkgRoot>/public/<file>`, or 404
|
|
341
|
+
// Only the package's `public/` directory is web-served. Models, data files, and
|
|
342
|
+
// the publisher.json manifest live outside it and are never reachable here, so
|
|
343
|
+
// nothing can be downloaded around the per-model #(authorize) and query
|
|
344
|
+
// controls. The data stays reachable through the permission-checked query path.
|
|
345
|
+
|
|
346
|
+
async function serveFromPackage(
|
|
347
|
+
req: express.Request,
|
|
348
|
+
res: express.Response,
|
|
349
|
+
): Promise<void> {
|
|
350
|
+
const subPathRaw = (req.params as Record<string, string>)["0"] ?? "";
|
|
351
|
+
try {
|
|
352
|
+
const environment = await environmentStore.getEnvironment(
|
|
353
|
+
req.params.environmentName,
|
|
354
|
+
false,
|
|
355
|
+
);
|
|
356
|
+
const pkg = await environment.getPackage(req.params.packageName, false);
|
|
357
|
+
// Only the package's public/ directory is web-served. Models, data, and
|
|
358
|
+
// the publisher.json manifest live outside it and are never reachable
|
|
359
|
+
// through this route. This single directory boundary is the whole
|
|
360
|
+
// access-control story for static files.
|
|
361
|
+
const publicRoot = path.join(pkg.getPackagePath(), "public");
|
|
362
|
+
|
|
363
|
+
// Directory-style fallback: empty path or trailing slash → look for
|
|
364
|
+
// index.html within that directory.
|
|
365
|
+
let subPath = subPathRaw;
|
|
366
|
+
if (subPath === "" || subPath.endsWith("/")) {
|
|
367
|
+
subPath = subPath + "index.html";
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Resolve the requested file under public/ and reject anything that
|
|
371
|
+
// escapes it (`..`, encoded traversal) before touching the disk.
|
|
372
|
+
// safeJoinUnderRoot is the shared lexical-containment primitive (it throws
|
|
373
|
+
// BadRequestError on escape, surfaced as 400 by the outer catch); the
|
|
374
|
+
// realpath check below additionally catches symlinks inside public/ that
|
|
375
|
+
// point outward (403).
|
|
376
|
+
const fullPath = safeJoinUnderRoot(publicRoot, subPath);
|
|
377
|
+
|
|
378
|
+
// Containment check via realpath against the resolved public/ root.
|
|
379
|
+
// Catches symlinks inside public/ that point out (e.g. a malicious
|
|
380
|
+
// package shipping `public/leak -> /etc/passwd`), and tolerates the
|
|
381
|
+
// package root itself being a symlink (how watch-mode in-place mount
|
|
382
|
+
// works): realpath resolves it transparently and legitimate accesses
|
|
383
|
+
// inside public/ stay within realPublicRoot. Missing public/ dir or
|
|
384
|
+
// missing file: realpath throws ENOENT and we 404 cleanly instead of
|
|
385
|
+
// leaking via Express's default error handler.
|
|
386
|
+
const fsp = await import("fs/promises");
|
|
387
|
+
let realPublicRoot: string;
|
|
388
|
+
let realFullPath: string;
|
|
389
|
+
try {
|
|
390
|
+
realPublicRoot = await fsp.realpath(publicRoot);
|
|
391
|
+
realFullPath = await fsp.realpath(fullPath);
|
|
392
|
+
} catch {
|
|
393
|
+
if (!res.headersSent) {
|
|
394
|
+
// Generic 404 with no reflected request input (avoids reflecting
|
|
395
|
+
// user-controlled path/package name into the response body).
|
|
396
|
+
res.status(404).end();
|
|
397
|
+
}
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
const rel = path.relative(realPublicRoot, realFullPath);
|
|
401
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
402
|
+
res.status(403).end();
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Framing policy only applies to HTML documents — setting it on CSS/JS/
|
|
407
|
+
// image assets is meaningless and needlessly strips their default
|
|
408
|
+
// SAMEORIGIN protection. Embeddability defaults to "*" so same-tenant
|
|
409
|
+
// embeds work out of the box, and is overridable via PUBLISHER_FRAME_ANCESTORS.
|
|
410
|
+
const ext = path.extname(realFullPath).toLowerCase();
|
|
411
|
+
if (ext === ".html" || ext === ".htm") {
|
|
412
|
+
const frameAncestors = process.env.PUBLISHER_FRAME_ANCESTORS || "*";
|
|
413
|
+
res.setHeader(
|
|
414
|
+
"Content-Security-Policy",
|
|
415
|
+
`frame-ancestors ${frameAncestors}`,
|
|
416
|
+
);
|
|
417
|
+
res.removeHeader("X-Frame-Options");
|
|
418
|
+
}
|
|
419
|
+
// Never let a served asset be MIME-sniffed into a different content type.
|
|
420
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
421
|
+
res.sendFile(realFullPath, (err) => {
|
|
422
|
+
if (err) {
|
|
423
|
+
// Own the 404 instead of letting Express fall through to a
|
|
424
|
+
// catch-all that may error.
|
|
425
|
+
if (!res.headersSent) {
|
|
426
|
+
// Generic 404, no reflected request input (see above).
|
|
427
|
+
res.status(404).end();
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
} catch (e) {
|
|
432
|
+
// Map service errors to their real status — a bad package name is a 400,
|
|
433
|
+
// memory back-pressure is a 503 — rather than flattening everything to
|
|
434
|
+
// 404. A genuine missing file is already handled by the realpath/sendFile
|
|
435
|
+
// 404 paths above; this catch only sees service-layer failures.
|
|
436
|
+
if (!res.headersSent) {
|
|
437
|
+
const { json, status } = internalErrorToHttpError(e as Error);
|
|
438
|
+
res.status(status).json(json);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// `/environments/<env>/packages/<pkg>` (no trailing slash, no path) redirect so
|
|
444
|
+
// relative URLs in the served HTML resolve as expected. Express's default loose
|
|
445
|
+
// matching also catches the trailing-slash form here, so only redirect URLs that
|
|
446
|
+
// don't already end with `/`.
|
|
447
|
+
//
|
|
448
|
+
// Build the target from the validated route params and the parsed query, not
|
|
449
|
+
// from the raw request URL, so it is always this same canonical, same-origin
|
|
450
|
+
// path with a trailing slash. That removes any open-redirect / header-injection
|
|
451
|
+
// surface from user-controlled input, with the slash placed before any query
|
|
452
|
+
// string (e.g. ?embed_token=...).
|
|
453
|
+
app.get(
|
|
454
|
+
"/environments/:environmentName/packages/:packageName",
|
|
455
|
+
(req, res, next) => {
|
|
456
|
+
if (req.path.endsWith("/")) return next();
|
|
457
|
+
const canonical =
|
|
458
|
+
`/environments/${encodeURIComponent(req.params.environmentName)}` +
|
|
459
|
+
`/packages/${encodeURIComponent(req.params.packageName)}/`;
|
|
460
|
+
const query = new URLSearchParams();
|
|
461
|
+
for (const [key, value] of Object.entries(req.query)) {
|
|
462
|
+
if (Array.isArray(value)) {
|
|
463
|
+
for (const v of value) query.append(key, String(v));
|
|
464
|
+
} else if (value !== undefined) {
|
|
465
|
+
query.append(key, String(value));
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
const qs = query.toString();
|
|
469
|
+
res.redirect(308, qs ? `${canonical}?${qs}` : canonical);
|
|
470
|
+
},
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
app.get(
|
|
474
|
+
"/environments/:environmentName/packages/:packageName/*",
|
|
475
|
+
serveFromPackage,
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
// List the static HTML pages bundled inside a package. Used by the SPA's
|
|
479
|
+
// package-detail view to surface a clickable list, and by anyone who wants
|
|
480
|
+
// to discover pages programmatically without scraping the directory.
|
|
481
|
+
//
|
|
482
|
+
// Returns a `Page[]` (see api-doc.yaml) — each item carries the relative
|
|
483
|
+
// `path`, the `packageName`, the page `title` (from its <title> tag), and a
|
|
484
|
+
// `resource` URL. `resource` is the root-relative static-serve URL (NOT under
|
|
485
|
+
// `${API_PREFIX}`) because pages are static assets served off the server root,
|
|
486
|
+
// unlike API resources such as `Package.resource`.
|
|
487
|
+
// Recursive depth is capped to keep this cheap for huge package directories.
|
|
488
|
+
const PAGES_DEPTH_CAP = 3;
|
|
489
|
+
type PageItem = {
|
|
490
|
+
resource: string;
|
|
491
|
+
packageName: string;
|
|
492
|
+
path: string;
|
|
493
|
+
title: string;
|
|
494
|
+
};
|
|
495
|
+
async function listPackagePages(
|
|
496
|
+
environmentName: string,
|
|
497
|
+
packageName: string,
|
|
498
|
+
publicRoot: string,
|
|
499
|
+
): Promise<PageItem[]> {
|
|
500
|
+
const fs = await import("fs/promises");
|
|
501
|
+
const out: PageItem[] = [];
|
|
502
|
+
|
|
503
|
+
// Resolve the public/ root once and reject any entry whose realpath escapes
|
|
504
|
+
// it. Same containment defense as serveFromPackage: catches symlinks inside
|
|
505
|
+
// public/ pointing outside (e.g. `public/leak -> ../report.malloy`) before we
|
|
506
|
+
// open and read the target's first 4KB for title extraction. A package with
|
|
507
|
+
// no public/ dir fails realpath here and yields an empty list.
|
|
508
|
+
let realPublicRoot: string;
|
|
509
|
+
try {
|
|
510
|
+
realPublicRoot = await fs.realpath(publicRoot);
|
|
511
|
+
} catch {
|
|
512
|
+
return out;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async function walk(dir: string, depth: number) {
|
|
516
|
+
if (depth > PAGES_DEPTH_CAP) return;
|
|
517
|
+
let entries: import("fs").Dirent[];
|
|
518
|
+
try {
|
|
519
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
520
|
+
} catch {
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
for (const entry of entries) {
|
|
524
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules")
|
|
525
|
+
continue;
|
|
526
|
+
const full = path.join(dir, entry.name);
|
|
527
|
+
let realFull: string;
|
|
528
|
+
try {
|
|
529
|
+
realFull = await fs.realpath(full);
|
|
530
|
+
} catch {
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
const contained = path.relative(realPublicRoot, realFull);
|
|
534
|
+
if (contained.startsWith("..") || path.isAbsolute(contained)) continue;
|
|
535
|
+
if (entry.isDirectory()) {
|
|
536
|
+
await walk(full, depth + 1);
|
|
537
|
+
} else if (
|
|
538
|
+
entry.isFile() &&
|
|
539
|
+
(entry.name.endsWith(".html") || entry.name.endsWith(".htm"))
|
|
540
|
+
) {
|
|
541
|
+
const rel = path.relative(publicRoot, full).replace(/\\/g, "/");
|
|
542
|
+
// Cheap title extraction: read first 4KB and grep for <title>.
|
|
543
|
+
let title = rel;
|
|
544
|
+
try {
|
|
545
|
+
const fh = await fs.open(full, "r");
|
|
546
|
+
try {
|
|
547
|
+
const buf = Buffer.alloc(4096);
|
|
548
|
+
const { bytesRead } = await fh.read(buf, 0, 4096, 0);
|
|
549
|
+
const head = buf.slice(0, bytesRead).toString("utf8");
|
|
550
|
+
const m = head.match(/<title[^>]*>([^<]+)<\/title>/i);
|
|
551
|
+
if (m) title = m[1].trim();
|
|
552
|
+
} finally {
|
|
553
|
+
await fh.close();
|
|
554
|
+
}
|
|
555
|
+
} catch {
|
|
556
|
+
// ignore; fall back to relative path as title
|
|
557
|
+
}
|
|
558
|
+
out.push({
|
|
559
|
+
resource: `/environments/${environmentName}/packages/${packageName}/${rel}`,
|
|
560
|
+
packageName,
|
|
561
|
+
path: rel,
|
|
562
|
+
title,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
await walk(publicRoot, 0);
|
|
569
|
+
out.sort((a, b) => {
|
|
570
|
+
// Surface index.html first, then alphabetical.
|
|
571
|
+
if (a.path === "index.html") return -1;
|
|
572
|
+
if (b.path === "index.html") return 1;
|
|
573
|
+
return a.path.localeCompare(b.path);
|
|
574
|
+
});
|
|
575
|
+
return out;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// NOTE: route registration for /pages moved below the CORS middleware so
|
|
579
|
+
// cross-origin SDK consumers (e.g. a customer's React app pointing at
|
|
580
|
+
// `<ServerProvider baseURL="https://publisher.example.com/api/v0">`) get
|
|
581
|
+
// the proper CORS headers. See the registration after `app.use(cors(...))`.
|
|
582
|
+
|
|
289
583
|
// Only serve static files in production mode
|
|
290
584
|
// Otherwise we proxy to the React dev server
|
|
291
585
|
if (!isDevelopment) {
|
|
@@ -343,6 +637,34 @@ try {
|
|
|
343
637
|
// Register draining guard middleware - must be after health endpoints but before other routes
|
|
344
638
|
app.use(drainingGuard);
|
|
345
639
|
|
|
640
|
+
// /pages — registered here (post-CORS, post-body-parser, post-draining) so
|
|
641
|
+
// cross-origin SDK consumers and authenticated requests both work.
|
|
642
|
+
app.get(
|
|
643
|
+
`${API_PREFIX}/environments/:environmentName/packages/:packageName/pages`,
|
|
644
|
+
async (req, res) => {
|
|
645
|
+
try {
|
|
646
|
+
const environment = await environmentStore.getEnvironment(
|
|
647
|
+
req.params.environmentName,
|
|
648
|
+
false,
|
|
649
|
+
);
|
|
650
|
+
const pkg = await environment.getPackage(
|
|
651
|
+
req.params.packageName,
|
|
652
|
+
false,
|
|
653
|
+
);
|
|
654
|
+
const pages = await listPackagePages(
|
|
655
|
+
req.params.environmentName,
|
|
656
|
+
req.params.packageName,
|
|
657
|
+
path.join(pkg.getPackagePath(), "public"),
|
|
658
|
+
);
|
|
659
|
+
res.json(pages);
|
|
660
|
+
} catch (error) {
|
|
661
|
+
logger.error("Failed to list package pages", { error });
|
|
662
|
+
const { json, status } = internalErrorToHttpError(error as Error);
|
|
663
|
+
res.status(status).json(json);
|
|
664
|
+
}
|
|
665
|
+
},
|
|
666
|
+
);
|
|
667
|
+
|
|
346
668
|
app.get(`${API_PREFIX}/status`, async (_req, res) => {
|
|
347
669
|
try {
|
|
348
670
|
const status = await environmentStore.getStatus();
|
|
@@ -358,6 +680,86 @@ app.get(`${API_PREFIX}/watch-mode/status`, watchModeController.getWatchStatus);
|
|
|
358
680
|
app.post(`${API_PREFIX}/watch-mode/start`, watchModeController.startWatching);
|
|
359
681
|
app.post(`${API_PREFIX}/watch-mode/stop`, watchModeController.stopWatchMode);
|
|
360
682
|
|
|
683
|
+
// Live-reload Server-Sent Events stream for in-package HTML dashboards.
|
|
684
|
+
//
|
|
685
|
+
// This endpoint does NOT start watch mode on its own — that's an explicit
|
|
686
|
+
// opt-in (`--watch-env <name>` CLI flag, or `POST /api/v0/watch-mode/start`).
|
|
687
|
+
// Instead it reports whether watch mode is currently active for the requested
|
|
688
|
+
// env via a `mode` event and, if so, fans out file-change events to the
|
|
689
|
+
// browser. This avoids two earlier bugs:
|
|
690
|
+
// - Auto-starting from the request handler made arbitrary fetches reach
|
|
691
|
+
// in to mutate global watch-mode state (`event traversal — see below).
|
|
692
|
+
// - The runtime previously had no way to know "watch mode isn't running,
|
|
693
|
+
// don't expect reloads"; with the `mode` event it can choose to surface
|
|
694
|
+
// a small dev indicator (today: silent).
|
|
695
|
+
//
|
|
696
|
+
// Inputs are validated before any state lookup. Names that don't pass the
|
|
697
|
+
// canonical `assertSafePackageName` allowlist get 400 — preventing requests
|
|
698
|
+
// like `/api/v0/environments/%2e%2e/packages/x/events` from reaching the
|
|
699
|
+
// EnvironmentStore at all. We reuse the shared sanitizer rather than a local
|
|
700
|
+
// regex so the rules stay in one place (see path_safety.ts).
|
|
701
|
+
// Cap concurrent live-reload SSE connections so the endpoint can't be used to
|
|
702
|
+
// exhaust server sockets/memory with unbounded long-lived streams. Generous,
|
|
703
|
+
// since legitimate use is one stream per open dashboard tab.
|
|
704
|
+
const MAX_SSE_CONNECTIONS = 1000;
|
|
705
|
+
let sseConnectionCount = 0;
|
|
706
|
+
app.get(
|
|
707
|
+
`${API_PREFIX}/environments/:environmentName/packages/:packageName/events`,
|
|
708
|
+
async (req, res) => {
|
|
709
|
+
const env = req.params.environmentName;
|
|
710
|
+
const pkg = req.params.packageName;
|
|
711
|
+
try {
|
|
712
|
+
assertSafePackageName(env);
|
|
713
|
+
assertSafePackageName(pkg);
|
|
714
|
+
const environment = await environmentStore.getEnvironment(env, false);
|
|
715
|
+
await environment.getPackage(pkg, false); // 404 if missing
|
|
716
|
+
} catch (error) {
|
|
717
|
+
const { json, status } = internalErrorToHttpError(error as Error);
|
|
718
|
+
res.status(status).json(json);
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if (sseConnectionCount >= MAX_SSE_CONNECTIONS) {
|
|
723
|
+
res.status(503).json({
|
|
724
|
+
code: 503,
|
|
725
|
+
message: "Too many live-reload connections; try again shortly.",
|
|
726
|
+
});
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
sseConnectionCount++;
|
|
730
|
+
|
|
731
|
+
res.set({
|
|
732
|
+
"content-type": "text/event-stream",
|
|
733
|
+
"cache-control": "no-cache",
|
|
734
|
+
connection: "keep-alive",
|
|
735
|
+
// Disable proxy/CDN buffering so events flush immediately.
|
|
736
|
+
"x-accel-buffering": "no",
|
|
737
|
+
});
|
|
738
|
+
res.flushHeaders();
|
|
739
|
+
|
|
740
|
+
const watching = watchModeController.isWatching(env);
|
|
741
|
+
res.write("event: hello\ndata: connected\n\n");
|
|
742
|
+
res.write(`event: mode\ndata: ${watching ? "enabled" : "disabled"}\n\n`);
|
|
743
|
+
|
|
744
|
+
const key = `${env}/${pkg}`;
|
|
745
|
+
const send = () => {
|
|
746
|
+
res.write("event: changed\ndata: changed\n\n");
|
|
747
|
+
};
|
|
748
|
+
watchModeController.events.on(key, send);
|
|
749
|
+
// Keep the connection alive through idle proxies (heartbeat every 25s).
|
|
750
|
+
const heartbeat = setInterval(() => {
|
|
751
|
+
res.write(": heartbeat\n\n");
|
|
752
|
+
}, 25000);
|
|
753
|
+
const cleanup = () => {
|
|
754
|
+
clearInterval(heartbeat);
|
|
755
|
+
watchModeController.events.off(key, send);
|
|
756
|
+
sseConnectionCount--;
|
|
757
|
+
};
|
|
758
|
+
// "close" covers both clean and abrupt disconnects on Node >= 20.
|
|
759
|
+
req.on("close", cleanup);
|
|
760
|
+
},
|
|
761
|
+
);
|
|
762
|
+
|
|
361
763
|
app.get(`${API_PREFIX}/environments`, async (_req, res) => {
|
|
362
764
|
try {
|
|
363
765
|
res.status(200).json(await environmentStore.listEnvironments());
|
|
@@ -640,28 +1042,6 @@ app.get(
|
|
|
640
1042
|
},
|
|
641
1043
|
);
|
|
642
1044
|
|
|
643
|
-
/**
|
|
644
|
-
* @deprecated Use /environments/:environmentName/connections/:connectionName/sqlSource POST method instead
|
|
645
|
-
*/
|
|
646
|
-
app.get(
|
|
647
|
-
`${API_PREFIX}/environments/:environmentName/connections/:connectionName/sqlSource`,
|
|
648
|
-
async (req, res) => {
|
|
649
|
-
try {
|
|
650
|
-
res.status(200).json(
|
|
651
|
-
await connectionController.getConnectionSqlSource(
|
|
652
|
-
req.params.environmentName,
|
|
653
|
-
req.params.connectionName,
|
|
654
|
-
req.query.sqlStatement as string,
|
|
655
|
-
),
|
|
656
|
-
);
|
|
657
|
-
} catch (error) {
|
|
658
|
-
logger.error(error);
|
|
659
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
660
|
-
res.status(status).json(json);
|
|
661
|
-
}
|
|
662
|
-
},
|
|
663
|
-
);
|
|
664
|
-
|
|
665
1045
|
app.post(
|
|
666
1046
|
`${API_PREFIX}/environments/:environmentName/connections/:connectionName/sqlSource`,
|
|
667
1047
|
async (req, res) => {
|
|
@@ -682,26 +1062,6 @@ app.post(
|
|
|
682
1062
|
);
|
|
683
1063
|
|
|
684
1064
|
// Per-package versions
|
|
685
|
-
app.get(
|
|
686
|
-
`${API_PREFIX}/environments/:environmentName/packages/:packageName/connections/:connectionName/sqlSource`,
|
|
687
|
-
async (req, res) => {
|
|
688
|
-
try {
|
|
689
|
-
res.status(200).json(
|
|
690
|
-
await connectionController.getConnectionSqlSource(
|
|
691
|
-
req.params.environmentName,
|
|
692
|
-
req.params.connectionName,
|
|
693
|
-
req.query.sqlStatement as string,
|
|
694
|
-
req.params.packageName,
|
|
695
|
-
),
|
|
696
|
-
);
|
|
697
|
-
} catch (error) {
|
|
698
|
-
logger.error(error);
|
|
699
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
700
|
-
res.status(status).json(json);
|
|
701
|
-
}
|
|
702
|
-
},
|
|
703
|
-
);
|
|
704
|
-
|
|
705
1065
|
app.post(
|
|
706
1066
|
`${API_PREFIX}/environments/:environmentName/packages/:packageName/connections/:connectionName/sqlSource`,
|
|
707
1067
|
async (req, res) => {
|
|
@@ -736,21 +1096,12 @@ app.post(
|
|
|
736
1096
|
queryConcurrency(),
|
|
737
1097
|
async (req, res) => {
|
|
738
1098
|
try {
|
|
739
|
-
let options: string | ParsedQs | (string | ParsedQs)[] | undefined;
|
|
740
|
-
|
|
741
|
-
// Support both body and query parameters for options for backwards compatibility
|
|
742
|
-
// TODO: To be removed in the future
|
|
743
|
-
if (req.body?.options) {
|
|
744
|
-
options = req.body.options;
|
|
745
|
-
} else {
|
|
746
|
-
options = req.query.options;
|
|
747
|
-
}
|
|
748
1099
|
res.status(200).json(
|
|
749
1100
|
await connectionController.getConnectionQueryData(
|
|
750
1101
|
req.params.environmentName,
|
|
751
1102
|
req.params.connectionName,
|
|
752
1103
|
req.body.sqlStatement as string,
|
|
753
|
-
options as string,
|
|
1104
|
+
req.body?.options as string,
|
|
754
1105
|
),
|
|
755
1106
|
);
|
|
756
1107
|
} catch (error) {
|
|
@@ -766,65 +1117,12 @@ app.post(
|
|
|
766
1117
|
queryConcurrency(),
|
|
767
1118
|
async (req, res) => {
|
|
768
1119
|
try {
|
|
769
|
-
let options: string | ParsedQs | (string | ParsedQs)[] | undefined;
|
|
770
|
-
if (req.body?.options) {
|
|
771
|
-
options = req.body.options;
|
|
772
|
-
} else {
|
|
773
|
-
options = req.query.options;
|
|
774
|
-
}
|
|
775
1120
|
res.status(200).json(
|
|
776
1121
|
await connectionController.getConnectionQueryData(
|
|
777
1122
|
req.params.environmentName,
|
|
778
1123
|
req.params.connectionName,
|
|
779
1124
|
req.body.sqlStatement as string,
|
|
780
|
-
options as string,
|
|
781
|
-
req.params.packageName,
|
|
782
|
-
),
|
|
783
|
-
);
|
|
784
|
-
} catch (error) {
|
|
785
|
-
logger.error(error);
|
|
786
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
787
|
-
res.status(status).json(json);
|
|
788
|
-
}
|
|
789
|
-
},
|
|
790
|
-
);
|
|
791
|
-
|
|
792
|
-
/**
|
|
793
|
-
* @deprecated Use environments/:environmentName/connections/:connectionName/sqlTemporaryTable POST method instead
|
|
794
|
-
*/
|
|
795
|
-
app.get(
|
|
796
|
-
`${API_PREFIX}/environments/:environmentName/connections/:connectionName/temporaryTable`,
|
|
797
|
-
queryConcurrency(),
|
|
798
|
-
async (req, res) => {
|
|
799
|
-
try {
|
|
800
|
-
res.status(200).json(
|
|
801
|
-
await connectionController.getConnectionTemporaryTable(
|
|
802
|
-
req.params.environmentName,
|
|
803
|
-
req.params.connectionName,
|
|
804
|
-
req.query.sqlStatement as string,
|
|
805
|
-
),
|
|
806
|
-
);
|
|
807
|
-
} catch (error) {
|
|
808
|
-
logger.error(error);
|
|
809
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
810
|
-
res.status(status).json(json);
|
|
811
|
-
}
|
|
812
|
-
},
|
|
813
|
-
);
|
|
814
|
-
|
|
815
|
-
/**
|
|
816
|
-
* @deprecated Use /environments/:environmentName/packages/:packageName/connections/:connectionName/sqlTemporaryTable
|
|
817
|
-
*/
|
|
818
|
-
app.get(
|
|
819
|
-
`${API_PREFIX}/environments/:environmentName/packages/:packageName/connections/:connectionName/temporaryTable`,
|
|
820
|
-
queryConcurrency(),
|
|
821
|
-
async (req, res) => {
|
|
822
|
-
try {
|
|
823
|
-
res.status(200).json(
|
|
824
|
-
await connectionController.getConnectionTemporaryTable(
|
|
825
|
-
req.params.environmentName,
|
|
826
|
-
req.params.connectionName,
|
|
827
|
-
req.query.sqlStatement as string,
|
|
1125
|
+
req.body?.options as string,
|
|
828
1126
|
req.params.packageName,
|
|
829
1127
|
),
|
|
830
1128
|
);
|
|
@@ -901,11 +1199,9 @@ app.post(
|
|
|
901
1199
|
`${API_PREFIX}/environments/:environmentName/packages`,
|
|
902
1200
|
async (req, res) => {
|
|
903
1201
|
try {
|
|
904
|
-
const autoLoadManifest = req.query.autoLoadManifest === "true";
|
|
905
1202
|
const _package = await packageController.addPackage(
|
|
906
1203
|
req.params.environmentName,
|
|
907
1204
|
req.body,
|
|
908
|
-
{ autoLoadManifest },
|
|
909
1205
|
);
|
|
910
1206
|
res.status(200).json(_package?.getPackageMetadata());
|
|
911
1207
|
} catch (error) {
|
|
@@ -1289,33 +1585,17 @@ app.get(
|
|
|
1289
1585
|
},
|
|
1290
1586
|
);
|
|
1291
1587
|
|
|
1292
|
-
app.post(
|
|
1293
|
-
`${API_PREFIX}/environments/:environmentName/packages/:packageName/materializations/teardown`,
|
|
1294
|
-
async (req, res) => {
|
|
1295
|
-
try {
|
|
1296
|
-
const result = await materializationController.teardownPackage(
|
|
1297
|
-
req.params.environmentName,
|
|
1298
|
-
req.params.packageName,
|
|
1299
|
-
req.body || {},
|
|
1300
|
-
);
|
|
1301
|
-
res.status(200).json(result);
|
|
1302
|
-
} catch (error) {
|
|
1303
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1304
|
-
res.status(status).json(json);
|
|
1305
|
-
}
|
|
1306
|
-
},
|
|
1307
|
-
);
|
|
1308
|
-
|
|
1309
1588
|
app.post(
|
|
1310
1589
|
`${API_PREFIX}/environments/:environmentName/packages/:packageName/materializations/:materializationId`,
|
|
1311
1590
|
async (req, res) => {
|
|
1312
1591
|
try {
|
|
1313
1592
|
const action = req.query.action;
|
|
1314
|
-
if (action === "
|
|
1315
|
-
const build = await materializationController.
|
|
1593
|
+
if (action === "build") {
|
|
1594
|
+
const build = await materializationController.buildMaterialization(
|
|
1316
1595
|
req.params.environmentName,
|
|
1317
1596
|
req.params.packageName,
|
|
1318
1597
|
req.params.materializationId,
|
|
1598
|
+
req.body || {},
|
|
1319
1599
|
);
|
|
1320
1600
|
res.status(202).json(build);
|
|
1321
1601
|
} else if (action === "stop") {
|
|
@@ -1327,7 +1607,7 @@ app.post(
|
|
|
1327
1607
|
res.status(200).json(build);
|
|
1328
1608
|
} else {
|
|
1329
1609
|
throw new BadRequestError(
|
|
1330
|
-
`Unsupported action '${String(action ?? "")}'. Expected '
|
|
1610
|
+
`Unsupported action '${String(action ?? "")}'. Expected 'build' or 'stop'.`,
|
|
1331
1611
|
);
|
|
1332
1612
|
}
|
|
1333
1613
|
} catch (error) {
|
|
@@ -1345,6 +1625,7 @@ app.delete(
|
|
|
1345
1625
|
req.params.environmentName,
|
|
1346
1626
|
req.params.packageName,
|
|
1347
1627
|
req.params.materializationId,
|
|
1628
|
+
{ dropTables: req.query.dropTables === "true" },
|
|
1348
1629
|
);
|
|
1349
1630
|
res.status(204).send();
|
|
1350
1631
|
} catch (error) {
|
|
@@ -1354,49 +1635,6 @@ app.delete(
|
|
|
1354
1635
|
},
|
|
1355
1636
|
);
|
|
1356
1637
|
|
|
1357
|
-
// ==================== MANIFEST ROUTES ====================
|
|
1358
|
-
|
|
1359
|
-
app.get(
|
|
1360
|
-
`${API_PREFIX}/environments/:environmentName/packages/:packageName/manifest`,
|
|
1361
|
-
async (req, res) => {
|
|
1362
|
-
try {
|
|
1363
|
-
const manifest = await manifestController.getManifest(
|
|
1364
|
-
req.params.environmentName,
|
|
1365
|
-
req.params.packageName,
|
|
1366
|
-
);
|
|
1367
|
-
res.status(200).json(manifest);
|
|
1368
|
-
} catch (error) {
|
|
1369
|
-
logger.error("Get manifest error", { error });
|
|
1370
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1371
|
-
res.status(status).json(json);
|
|
1372
|
-
}
|
|
1373
|
-
},
|
|
1374
|
-
);
|
|
1375
|
-
|
|
1376
|
-
app.post(
|
|
1377
|
-
`${API_PREFIX}/environments/:environmentName/packages/:packageName/manifest`,
|
|
1378
|
-
async (req, res) => {
|
|
1379
|
-
try {
|
|
1380
|
-
const action = req.query.action;
|
|
1381
|
-
if (action === "reload") {
|
|
1382
|
-
const manifest = await manifestController.reloadManifest(
|
|
1383
|
-
req.params.environmentName,
|
|
1384
|
-
req.params.packageName,
|
|
1385
|
-
);
|
|
1386
|
-
res.status(200).json(manifest);
|
|
1387
|
-
} else {
|
|
1388
|
-
throw new BadRequestError(
|
|
1389
|
-
`Unsupported action '${String(action ?? "")}'. Expected 'reload'.`,
|
|
1390
|
-
);
|
|
1391
|
-
}
|
|
1392
|
-
} catch (error) {
|
|
1393
|
-
logger.error("Manifest action error", { error });
|
|
1394
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1395
|
-
res.status(status).json(json);
|
|
1396
|
-
}
|
|
1397
|
-
},
|
|
1398
|
-
);
|
|
1399
|
-
|
|
1400
1638
|
// Register legacy `/projects/...` routes for backwards compatibility with
|
|
1401
1639
|
// clients that haven't migrated to `/environments/...` yet. Must be added
|
|
1402
1640
|
// before the SPA catch-all below.
|
|
@@ -1409,12 +1647,34 @@ registerLegacyRoutes(app, {
|
|
|
1409
1647
|
queryController,
|
|
1410
1648
|
compileController,
|
|
1411
1649
|
materializationController,
|
|
1412
|
-
manifestController,
|
|
1413
1650
|
});
|
|
1414
1651
|
|
|
1415
1652
|
// Modify the catch-all route to only serve index.html in production
|
|
1416
1653
|
if (!isDevelopment) {
|
|
1417
|
-
|
|
1654
|
+
const SPA_INDEX = path.resolve(ROOT, "index.html");
|
|
1655
|
+
app.get("*", (req, res) => {
|
|
1656
|
+
res.sendFile(SPA_INDEX, (err) => {
|
|
1657
|
+
if (!err) return;
|
|
1658
|
+
// The SPA bundle isn't built. This happens when running directly
|
|
1659
|
+
// from source (`bun run src/server.ts`) without first running
|
|
1660
|
+
// `bun run build:app`. Return a friendly placeholder rather than
|
|
1661
|
+
// a 500, and surface package URLs the user might be looking for.
|
|
1662
|
+
if (res.headersSent) return;
|
|
1663
|
+
res.status(404)
|
|
1664
|
+
.type("text/html")
|
|
1665
|
+
.send(
|
|
1666
|
+
`<!doctype html><meta charset="utf-8">
|
|
1667
|
+
<title>Publisher</title>
|
|
1668
|
+
<style>body{font:14px/1.4 -apple-system,system-ui,sans-serif;margin:40px;max-width:720px;color:#222}</style>
|
|
1669
|
+
<h1>Publisher is running, but the SPA bundle isn't built.</h1>
|
|
1670
|
+
<p>You requested <code>${req.path.replace(/[<>&]/g, (c) => ({ "<": "<", ">": ">", "&": "&" })[c] ?? c)}</code>.
|
|
1671
|
+
The Publisher API is available at <a href="/api/v0/environments">/api/v0/environments</a>.</p>
|
|
1672
|
+
<p>To get the Publisher web UI, run <code>cd packages/app && bunx vite build</code>
|
|
1673
|
+
or start the server with <code>NODE_ENV=development</code> after launching Vite on <code>:5173</code>.</p>
|
|
1674
|
+
<p>For in-package HTML data apps, browse to <code>/environments/<env>/packages/<pkg>/<file></code> directly.</p>`,
|
|
1675
|
+
);
|
|
1676
|
+
});
|
|
1677
|
+
});
|
|
1418
1678
|
}
|
|
1419
1679
|
|
|
1420
1680
|
app.use(
|
|
@@ -1448,7 +1708,7 @@ mainServer.timeout = 600000;
|
|
|
1448
1708
|
mainServer.keepAliveTimeout = 600000;
|
|
1449
1709
|
mainServer.headersTimeout = 600000;
|
|
1450
1710
|
|
|
1451
|
-
mainServer.listen(PUBLISHER_PORT, PUBLISHER_HOST, () => {
|
|
1711
|
+
mainServer.listen(PUBLISHER_PORT, PUBLISHER_HOST, async () => {
|
|
1452
1712
|
const address = mainServer.address() as AddressInfo;
|
|
1453
1713
|
logger.info(
|
|
1454
1714
|
`Publisher server listening at http://${address.address}:${address.port}`,
|
|
@@ -1458,6 +1718,44 @@ mainServer.listen(PUBLISHER_PORT, PUBLISHER_HOST, () => {
|
|
|
1458
1718
|
"Running in development mode - proxying to React dev server at http://localhost:5173",
|
|
1459
1719
|
);
|
|
1460
1720
|
}
|
|
1721
|
+
// If `--watch-env <name>` (or PUBLISHER_WATCH=name1,name2) was passed,
|
|
1722
|
+
// wait for env initialization to settle, then start watch mode for each
|
|
1723
|
+
// named env. Packages in those envs are already mounted in-place via the
|
|
1724
|
+
// EnvironmentStore in-place path (see `loadEnvironmentIntoDisk`), so the
|
|
1725
|
+
// chokidar watcher will see edits to your source repo and fan them out
|
|
1726
|
+
// to any connected SSE clients.
|
|
1727
|
+
const watchEnvList = (process.env.PUBLISHER_WATCH || "")
|
|
1728
|
+
.split(",")
|
|
1729
|
+
.map((s) => s.trim())
|
|
1730
|
+
.filter((s) => s.length > 0);
|
|
1731
|
+
if (watchEnvList.length > 0) {
|
|
1732
|
+
// The watcher tracks exactly one env at a time (`WatchModeController`
|
|
1733
|
+
// holds a single chokidar instance). Every env in PUBLISHER_WATCH is
|
|
1734
|
+
// still mounted in place (live source) by the EnvironmentStore, but only
|
|
1735
|
+
// the first is watched, so the others do not auto-reload.
|
|
1736
|
+
if (watchEnvList.length > 1) {
|
|
1737
|
+
logger.warn(
|
|
1738
|
+
`Multiple watch environments requested (${watchEnvList.join(
|
|
1739
|
+
", ",
|
|
1740
|
+
)}); watch mode auto-reloads one at a time. Watching "${
|
|
1741
|
+
watchEnvList[0]
|
|
1742
|
+
}". The others are mounted in place (their source is live) but will not auto-reload. Pass a single --watch-env (or one PUBLISHER_WATCH value) to silence this.`,
|
|
1743
|
+
);
|
|
1744
|
+
}
|
|
1745
|
+
const envName = watchEnvList[0];
|
|
1746
|
+
try {
|
|
1747
|
+
await environmentStore.finishedInitialization;
|
|
1748
|
+
await watchModeController.ensureWatching(envName);
|
|
1749
|
+
logger.info(
|
|
1750
|
+
`Watch mode active for environment "${envName}" (in-place mount, source-edit live reload).`,
|
|
1751
|
+
);
|
|
1752
|
+
} catch (error) {
|
|
1753
|
+
logger.error(
|
|
1754
|
+
`Failed to start watch mode for environment "${envName}"`,
|
|
1755
|
+
{ error },
|
|
1756
|
+
);
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1461
1759
|
});
|
|
1462
1760
|
const mcpServer = mcpApp.listen(MCP_PORT, PUBLISHER_HOST, () => {
|
|
1463
1761
|
logger.info(`MCP server listening at http://${PUBLISHER_HOST}:${MCP_PORT}`);
|