@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.
Files changed (100) hide show
  1. package/build.ts +10 -1
  2. package/dist/app/api-doc.yaml +494 -397
  3. package/dist/app/assets/{EnvironmentPage-CX06cjOF.js → EnvironmentPage-BYwBeC2F.js} +1 -1
  4. package/dist/app/assets/HomePage-ivu4vdpj.js +1 -0
  5. package/dist/app/assets/{MainPage-nUJ9YatG.js → MainPage-B2DnHEDU.js} +2 -2
  6. package/dist/app/assets/MaterializationsPage-BZEuwF9P.js +1 -0
  7. package/dist/app/assets/ModelPage-Dpu3bfPg.js +1 -0
  8. package/dist/app/assets/{PackagePage-BaEVdEAG.js → PackagePage-B8PwcRHt.js} +1 -1
  9. package/dist/app/assets/{RouteError-BShQjZio.js → RouteError-BhbywAeC.js} +1 -1
  10. package/dist/app/assets/{WorkbookPage-CBn6ZjJW.js → WorkbookPage-C-JXsJG0.js} +1 -1
  11. package/dist/app/assets/{core-DECXYL4E.es-OaRfXwuQ.js → core-pPlPr7jK.es-CNEOlxKB.js} +1 -1
  12. package/dist/app/assets/{index-BLfPC1gy.js → index-BHEm8Egc.js} +1 -1
  13. package/dist/app/assets/{index-Dy3YhAZQ.js → index-BsvDrV14.js} +1 -1
  14. package/dist/app/assets/{index-DqiJ0bWp.js → index-ChR1fKR2.js} +134 -134
  15. package/dist/app/assets/{index.umd-DAN9K8yC.js → index.umd-BVLPYNuj.js} +1 -1
  16. package/dist/app/index.html +1 -1
  17. package/dist/instrumentation.mjs +18 -8
  18. package/dist/package_load_worker.mjs +19 -2
  19. package/dist/runtime/publisher.js +318 -0
  20. package/dist/server.mjs +1703 -1443
  21. package/package.json +5 -4
  22. package/scripts/bake-duckdb-extensions.js +104 -0
  23. package/src/constants.ts +12 -0
  24. package/src/controller/materialization.controller.ts +79 -35
  25. package/src/controller/package.controller.spec.ts +179 -0
  26. package/src/controller/package.controller.ts +60 -73
  27. package/src/controller/watch-mode.controller.ts +176 -46
  28. package/src/dto/package.dto.ts +16 -1
  29. package/src/errors.spec.ts +33 -0
  30. package/src/errors.ts +18 -0
  31. package/src/health.spec.ts +34 -1
  32. package/src/instrumentation.ts +33 -17
  33. package/src/materialization_metrics.ts +66 -0
  34. package/src/mcp/error_messages.spec.ts +35 -0
  35. package/src/mcp/error_messages.ts +14 -1
  36. package/src/mcp/handler_utils.ts +12 -0
  37. package/src/package_load/package_load_pool.ts +7 -1
  38. package/src/package_load/package_load_worker.ts +44 -4
  39. package/src/package_load/protocol.ts +7 -1
  40. package/src/runtime/publisher.js +318 -0
  41. package/src/server-old.ts +7 -149
  42. package/src/server.ts +488 -190
  43. package/src/service/authorize_integration.spec.ts +163 -2
  44. package/src/service/compile_authorize.spec.ts +85 -0
  45. package/src/service/environment.ts +270 -12
  46. package/src/service/environment_store.spec.ts +0 -81
  47. package/src/service/environment_store.ts +142 -34
  48. package/src/service/explore_visibility.spec.ts +434 -0
  49. package/src/service/exports_probe.spec.ts +107 -0
  50. package/src/service/manifest_loader.spec.ts +99 -0
  51. package/src/service/manifest_loader.ts +95 -0
  52. package/src/service/materialization_service.spec.ts +324 -512
  53. package/src/service/materialization_service.ts +816 -656
  54. package/src/service/model.ts +444 -13
  55. package/src/service/package.spec.ts +14 -2
  56. package/src/service/package.ts +271 -20
  57. package/src/service/package_rollback.spec.ts +190 -0
  58. package/src/service/package_worker_path.spec.ts +223 -0
  59. package/src/service/query_boundary.spec.ts +470 -0
  60. package/src/storage/DatabaseInterface.ts +35 -57
  61. package/src/storage/StorageManager.mock.ts +0 -9
  62. package/src/storage/StorageManager.ts +7 -290
  63. package/src/storage/duckdb/DuckDBConnection.ts +70 -124
  64. package/src/storage/duckdb/DuckDBRepository.ts +2 -35
  65. package/src/storage/duckdb/MaterializationRepository.ts +52 -27
  66. package/src/storage/duckdb/schema.ts +4 -20
  67. package/tests/fixtures/authorize-compile/model.malloy +9 -0
  68. package/tests/fixtures/authorize-compile/publisher.json +4 -0
  69. package/tests/fixtures/html-pages-nopublic/model.malloy +1 -0
  70. package/tests/fixtures/html-pages-nopublic/publisher.json +5 -0
  71. package/tests/fixtures/html-pages-test/data.csv +3 -0
  72. package/tests/fixtures/html-pages-test/public/assets/app.css +3 -0
  73. package/tests/fixtures/html-pages-test/public/data.json +1 -0
  74. package/tests/fixtures/html-pages-test/public/index.html +9 -0
  75. package/tests/fixtures/html-pages-test/public/sub/page2.html +9 -0
  76. package/tests/fixtures/html-pages-test/publisher.json +5 -0
  77. package/tests/fixtures/html-pages-test/report.malloy +1 -0
  78. package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +92 -0
  79. package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +138 -0
  80. package/tests/integration/html_pages/html_pages.integration.spec.ts +378 -0
  81. package/tests/integration/materialization/manifest_binding.integration.spec.ts +175 -0
  82. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +277 -266
  83. package/tests/integration/watch-mode/watch_mode.integration.spec.ts +421 -0
  84. package/tests/unit/duckdb/attached_databases.test.ts +111 -0
  85. package/tests/unit/duckdb/duckdb_connection.test.ts +181 -0
  86. package/tests/unit/duckdb/legacy_schema_migration.test.ts +7 -4
  87. package/tests/unit/duckdb/repositories.test.ts +208 -0
  88. package/dist/app/assets/HomePage-CNFt_eUU.js +0 -1
  89. package/dist/app/assets/MaterializationsPage-B5goxVXW.js +0 -1
  90. package/dist/app/assets/ModelPage-Ba7Xh4lL.js +0 -1
  91. package/src/controller/manifest.controller.ts +0 -38
  92. package/src/service/manifest_service.spec.ts +0 -206
  93. package/src/service/manifest_service.ts +0 -117
  94. package/src/service/materialized_table_gc.spec.ts +0 -384
  95. package/src/service/materialized_table_gc.ts +0 -231
  96. package/src/storage/duckdb/DuckDBManifestStore.ts +0 -70
  97. package/src/storage/duckdb/ManifestRepository.ts +0 -120
  98. package/src/storage/duckdb/manifest_store.spec.ts +0 -133
  99. package/src/storage/ducklake/DuckLakeManifestStore.ts +0 -155
  100. 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 === "start") {
1315
- const build = await materializationController.startMaterialization(
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 'start' or 'stop'.`,
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
- app.get("*", (_req, res) => res.sendFile(path.resolve(ROOT, "index.html")));
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) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" })[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 &amp;&amp; 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/&lt;env&gt;/packages/&lt;pkg&gt;/&lt;file&gt;</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}`);