@malloy-publisher/server 0.0.235 → 0.0.236
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/dist/app/api-doc.yaml +47 -17
- package/dist/package_load_worker.mjs +8 -1
- package/dist/server.mjs +471 -54
- package/package.json +5 -2
- package/.eslintrc.json +0 -44
- package/.prettierignore +0 -1
- package/.prettierrc +0 -4
- package/build.ts +0 -97
- package/eslint.config.mjs +0 -76
- package/k6-tests/README.md +0 -144
- package/k6-tests/bun.lock +0 -797
- package/k6-tests/load-test/load-test-crud-connections.ts +0 -263
- package/k6-tests/load-test/load-test-crud-packages.ts +0 -461
- package/k6-tests/load-test/load-test-crud-projects.ts +0 -160
- package/k6-tests/load-test/load-test-crud.ts +0 -142
- package/k6-tests/load-test/load-test.ts +0 -784
- package/k6-tests/package.json +0 -27
- package/k6-tests/scripts/run-with-otel.sh +0 -50
- package/k6-tests/smoke-test/smoke-test.ts +0 -158
- package/k6-tests/tsconfig.json +0 -29
- package/k6-tests/utils/client_factory.ts +0 -128
- package/k6-tests/utils/common.ts +0 -489
- package/k6-tests/utils/logger.ts +0 -62
- package/k6-tests/utils/types.d.ts +0 -35
- package/malloy_mcp.dxt +0 -0
- package/publisher.config.example.bigquery.json +0 -33
- package/publisher.config.example.duckdb.json +0 -23
- package/publisher.config.json +0 -23
- package/scripts/bake-duckdb-extensions.js +0 -110
- package/src/config.spec.ts +0 -1535
- package/src/config.theme.spec.ts +0 -178
- package/src/config.ts +0 -1142
- package/src/constants.ts +0 -105
- package/src/controller/compile.controller.ts +0 -40
- package/src/controller/connection.controller.spec.ts +0 -849
- package/src/controller/connection.controller.ts +0 -901
- package/src/controller/database.controller.ts +0 -24
- package/src/controller/materialization.controller.spec.ts +0 -293
- package/src/controller/materialization.controller.ts +0 -319
- package/src/controller/model.controller.ts +0 -164
- package/src/controller/package.controller.spec.ts +0 -273
- package/src/controller/package.controller.ts +0 -306
- package/src/controller/query.controller.ts +0 -152
- package/src/controller/theme.controller.ts +0 -83
- package/src/controller/watch-mode.controller.ts +0 -243
- package/src/data_styles.ts +0 -71
- package/src/default-publisher.config.json +0 -23
- package/src/ducklake_version.spec.ts +0 -163
- package/src/ducklake_version.ts +0 -153
- package/src/errors.spec.ts +0 -119
- package/src/errors.ts +0 -237
- package/src/filter_deprecation.spec.ts +0 -64
- package/src/filter_deprecation.ts +0 -42
- package/src/health.spec.ts +0 -123
- package/src/health.ts +0 -233
- package/src/heap_check.spec.ts +0 -144
- package/src/heap_check.ts +0 -144
- package/src/instrumentation.ts +0 -214
- package/src/json_utils.spec.ts +0 -51
- package/src/json_utils.ts +0 -33
- package/src/logger.spec.ts +0 -210
- package/src/logger.ts +0 -251
- package/src/malloy_pin_prereqs.spec.ts +0 -25
- package/src/materialization_metrics.spec.ts +0 -169
- package/src/materialization_metrics.ts +0 -346
- package/src/mcp/error_messages.spec.ts +0 -35
- package/src/mcp/error_messages.ts +0 -183
- package/src/mcp/handler_utils.spec.ts +0 -108
- package/src/mcp/handler_utils.ts +0 -251
- package/src/mcp/mcp_constants.ts +0 -6
- package/src/mcp/query_envelope.spec.ts +0 -229
- package/src/mcp/query_envelope.ts +0 -240
- package/src/mcp/server.protocol.spec.ts +0 -250
- package/src/mcp/server.ts +0 -87
- package/src/mcp/skills/build_skills_bundle.spec.ts +0 -51
- package/src/mcp/skills/build_skills_bundle.ts +0 -176
- package/src/mcp/skills/skills_bundle.json +0 -1
- package/src/mcp/skills/skills_bundle.spec.ts +0 -171
- package/src/mcp/tool_response.spec.ts +0 -108
- package/src/mcp/tool_response.ts +0 -138
- package/src/mcp/tools/compile_tool.spec.ts +0 -315
- package/src/mcp/tools/compile_tool.ts +0 -208
- package/src/mcp/tools/docs_search/build_docs_index.ts +0 -132
- package/src/mcp/tools/docs_search/malloy_docs_index.json +0 -1
- package/src/mcp/tools/docs_search_tool.spec.ts +0 -32
- package/src/mcp/tools/docs_search_tool.ts +0 -128
- package/src/mcp/tools/embedding_index.spec.ts +0 -1236
- package/src/mcp/tools/embedding_index.ts +0 -808
- package/src/mcp/tools/execute_query_tool.spec.ts +0 -294
- package/src/mcp/tools/execute_query_tool.ts +0 -316
- package/src/mcp/tools/get_context_eval.ts +0 -275
- package/src/mcp/tools/get_context_tool.spec.ts +0 -592
- package/src/mcp/tools/get_context_tool.ts +0 -585
- package/src/mcp/tools/reload_package_tool.spec.ts +0 -232
- package/src/mcp/tools/reload_package_tool.ts +0 -132
- package/src/mcp_config.spec.ts +0 -919
- package/src/mcp_config.ts +0 -425
- package/src/oom_guards.integration.spec.ts +0 -269
- package/src/package_load/package_load_pool.spec.ts +0 -252
- package/src/package_load/package_load_pool.ts +0 -931
- package/src/package_load/package_load_worker.ts +0 -1034
- package/src/package_load/protocol.ts +0 -364
- package/src/package_load/rpc_wait_accountant.spec.ts +0 -109
- package/src/package_load/rpc_wait_accountant.ts +0 -76
- package/src/package_load_metrics.spec.ts +0 -114
- package/src/package_load_metrics.ts +0 -127
- package/src/path_safety.spec.ts +0 -158
- package/src/path_safety.ts +0 -146
- package/src/pg_helpers.spec.ts +0 -223
- package/src/pg_helpers.ts +0 -52
- package/src/query_cap_metrics.spec.ts +0 -89
- package/src/query_cap_metrics.ts +0 -116
- package/src/query_concurrency.spec.ts +0 -247
- package/src/query_concurrency.ts +0 -237
- package/src/query_metadata_metrics.ts +0 -49
- package/src/query_param_utils.ts +0 -29
- package/src/query_timeout.spec.ts +0 -224
- package/src/query_timeout.ts +0 -179
- package/src/runtime/publisher.js +0 -328
- package/src/server-old.ts +0 -988
- package/src/server.ts +0 -2059
- package/src/service/annotations.spec.ts +0 -118
- package/src/service/annotations.ts +0 -91
- package/src/service/authorize.spec.ts +0 -154
- package/src/service/authorize.ts +0 -498
- package/src/service/authorize_integration.spec.ts +0 -2041
- package/src/service/build_plan.spec.ts +0 -685
- package/src/service/build_plan.ts +0 -797
- package/src/service/compile_authorize.spec.ts +0 -85
- package/src/service/compile_fragment_techniques.spec.ts +0 -156
- package/src/service/connection.spec.ts +0 -2360
- package/src/service/connection.ts +0 -2187
- package/src/service/connection_config.spec.ts +0 -734
- package/src/service/connection_config.ts +0 -899
- package/src/service/connection_federation.spec.ts +0 -184
- package/src/service/connection_fingerprint.spec.ts +0 -102
- package/src/service/connection_service.spec.ts +0 -711
- package/src/service/connection_service.ts +0 -291
- package/src/service/cron_evaluator.spec.ts +0 -79
- package/src/service/cron_evaluator.ts +0 -105
- package/src/service/db_utils.spec.ts +0 -1085
- package/src/service/db_utils.ts +0 -1502
- package/src/service/duckdb_instance_isolation.spec.ts +0 -137
- package/src/service/ducklake_lazy_attach.spec.ts +0 -110
- package/src/service/embedding_provider.spec.ts +0 -329
- package/src/service/embedding_provider.ts +0 -236
- package/src/service/environment.ts +0 -2191
- package/src/service/environment_admission.spec.ts +0 -344
- package/src/service/environment_compile.spec.ts +0 -197
- package/src/service/environment_store.spec.ts +0 -2274
- package/src/service/environment_store.ts +0 -2434
- package/src/service/environment_store_anchoring.spec.ts +0 -107
- package/src/service/environment_store_clone.spec.ts +0 -350
- package/src/service/explore_visibility.spec.ts +0 -434
- package/src/service/exports_probe.spec.ts +0 -107
- package/src/service/extension_fetch_policy.spec.ts +0 -256
- package/src/service/filter.spec.ts +0 -458
- package/src/service/filter.ts +0 -341
- package/src/service/filter_bypass.spec.ts +0 -418
- package/src/service/filter_integration.spec.ts +0 -1004
- package/src/service/freshness.spec.ts +0 -183
- package/src/service/freshness.ts +0 -112
- package/src/service/gcs_s3_utils.ts +0 -427
- package/src/service/given.ts +0 -105
- package/src/service/givens_integration.spec.ts +0 -219
- package/src/service/manifest_loader.spec.ts +0 -189
- package/src/service/manifest_loader.ts +0 -156
- package/src/service/materialization_build_session.spec.ts +0 -435
- package/src/service/materialization_build_session.ts +0 -681
- package/src/service/materialization_config_validation.spec.ts +0 -99
- package/src/service/materialization_config_validation.ts +0 -120
- package/src/service/materialization_eligibility.spec.ts +0 -158
- package/src/service/materialization_eligibility.ts +0 -305
- package/src/service/materialization_schedule_surface.spec.ts +0 -256
- package/src/service/materialization_scheduler.spec.ts +0 -289
- package/src/service/materialization_scheduler.ts +0 -304
- package/src/service/materialization_serve_transform.spec.ts +0 -1003
- package/src/service/materialization_serve_transform.ts +0 -779
- package/src/service/materialization_service.spec.ts +0 -2373
- package/src/service/materialization_service.ts +0 -2342
- package/src/service/materialization_test_fixtures.ts +0 -249
- package/src/service/model.spec.ts +0 -1157
- package/src/service/model.ts +0 -3370
- package/src/service/model_limits.spec.ts +0 -209
- package/src/service/model_limits.ts +0 -131
- package/src/service/model_storage_serve.spec.ts +0 -193
- package/src/service/model_storage_serve_joins.spec.ts +0 -193
- package/src/service/package.spec.ts +0 -612
- package/src/service/package.ts +0 -1799
- package/src/service/package_manifest.spec.ts +0 -230
- package/src/service/package_manifest.ts +0 -241
- package/src/service/package_memory_governor.spec.ts +0 -173
- package/src/service/package_memory_governor.ts +0 -233
- package/src/service/package_quote_bound_tables.spec.ts +0 -171
- package/src/service/package_race.spec.ts +0 -208
- package/src/service/package_reload_safety.spec.ts +0 -193
- package/src/service/package_rollback.spec.ts +0 -190
- package/src/service/package_worker_path.spec.ts +0 -645
- package/src/service/path_injection.spec.ts +0 -39
- package/src/service/persist_annotation_validation.spec.ts +0 -89
- package/src/service/persist_annotation_validation.ts +0 -59
- package/src/service/persistence_policy.spec.ts +0 -374
- package/src/service/proxy.spec.ts +0 -414
- package/src/service/proxy.ts +0 -248
- package/src/service/query_boundary.spec.ts +0 -470
- package/src/service/query_metadata.spec.ts +0 -408
- package/src/service/query_metadata.ts +0 -492
- package/src/service/query_metadata_identity.spec.ts +0 -149
- package/src/service/query_text.spec.ts +0 -79
- package/src/service/query_text.ts +0 -43
- package/src/service/quoting.spec.ts +0 -135
- package/src/service/quoting.ts +0 -78
- package/src/service/resolve_environment.ts +0 -15
- package/src/service/restricted_mode.spec.ts +0 -299
- package/src/service/source_extraction.ts +0 -225
- package/src/service/theme_store.ts +0 -199
- package/src/storage/DatabaseInterface.ts +0 -198
- package/src/storage/StorageManager.ts +0 -113
- package/src/storage/duckdb/ConnectionRepository.ts +0 -143
- package/src/storage/duckdb/DuckDBConnection.ts +0 -123
- package/src/storage/duckdb/DuckDBRepository.ts +0 -208
- package/src/storage/duckdb/EnvironmentRepository.ts +0 -148
- package/src/storage/duckdb/MaterializationRepository.spec.ts +0 -191
- package/src/storage/duckdb/MaterializationRepository.ts +0 -336
- package/src/storage/duckdb/PackageRepository.ts +0 -135
- package/src/storage/duckdb/schema.ts +0 -229
- package/src/stream_helpers.spec.ts +0 -280
- package/src/stream_helpers.ts +0 -162
- package/src/telemetry.ts +0 -20
- package/src/test_helpers/metrics_harness.ts +0 -166
- package/src/theme_key_parity.spec.ts +0 -57
- package/src/utils.ts +0 -29
- package/tests/fixtures/authorize-compile/model.malloy +0 -9
- package/tests/fixtures/authorize-compile/publisher.json +0 -4
- package/tests/fixtures/html-pages-nopublic/model.malloy +0 -1
- package/tests/fixtures/html-pages-nopublic/publisher.json +0 -5
- package/tests/fixtures/html-pages-test/data.csv +0 -3
- package/tests/fixtures/html-pages-test/public/assets/app.css +0 -3
- package/tests/fixtures/html-pages-test/public/barehtml.html +0 -4
- package/tests/fixtures/html-pages-test/public/bodymeta.html +0 -8
- package/tests/fixtures/html-pages-test/public/data.json +0 -1
- package/tests/fixtures/html-pages-test/public/fitcomment.html +0 -11
- package/tests/fixtures/html-pages-test/public/index.html +0 -9
- package/tests/fixtures/html-pages-test/public/nohead.html +0 -8
- package/tests/fixtures/html-pages-test/public/notfit.html +0 -13
- package/tests/fixtures/html-pages-test/public/slides.html +0 -12
- package/tests/fixtures/html-pages-test/public/sub/page2.html +0 -9
- package/tests/fixtures/html-pages-test/public/unterminated.html +0 -10
- package/tests/fixtures/html-pages-test/publisher.json +0 -5
- package/tests/fixtures/html-pages-test/report.malloy +0 -1
- package/tests/fixtures/persist-multi-level/data/orders.csv +0 -5
- package/tests/fixtures/persist-multi-level/multi_level.malloy +0 -18
- package/tests/fixtures/persist-multi-level/publisher.json +0 -5
- package/tests/fixtures/persist-schedule-test/data/orders.csv +0 -5
- package/tests/fixtures/persist-schedule-test/persist_schedule_test.malloy +0 -11
- package/tests/fixtures/persist-schedule-test/publisher.json +0 -7
- package/tests/fixtures/persist-schedule-warn-test/data/orders.csv +0 -5
- package/tests/fixtures/persist-schedule-warn-test/persist_warn_test.malloy +0 -11
- package/tests/fixtures/persist-schedule-warn-test/publisher.json +0 -6
- package/tests/fixtures/persist-test/data/orders.csv +0 -5
- package/tests/fixtures/persist-test/persist_test.malloy +0 -11
- package/tests/fixtures/persist-test/publisher.json +0 -5
- package/tests/fixtures/publisher.config.json +0 -15
- package/tests/fixtures/query-givens/data/orders.csv +0 -7
- package/tests/fixtures/query-givens/model.malloy +0 -34
- package/tests/fixtures/query-givens/publisher.json +0 -5
- package/tests/fixtures/xlsx/database.xlsx +0 -0
- package/tests/harness/mcp_test_setup.ts +0 -332
- package/tests/harness/rest_e2e.ts +0 -68
- package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +0 -92
- package/tests/integration/concurrent_package/concurrent_package.integration.spec.ts +0 -280
- package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +0 -138
- package/tests/integration/first_boot/readiness_line.integration.spec.ts +0 -177
- package/tests/integration/html_pages/html_pages.integration.spec.ts +0 -440
- package/tests/integration/legacy_routes/legacy_routes.integration.spec.ts +0 -259
- package/tests/integration/materialization/environment_list.integration.spec.ts +0 -133
- package/tests/integration/materialization/freshness_gate.integration.spec.ts +0 -292
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +0 -430
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +0 -441
- package/tests/integration/materialization/orchestrated_rebind.integration.spec.ts +0 -150
- package/tests/integration/materialization/reference_manifest.integration.spec.ts +0 -251
- package/tests/integration/materialization/schedule_edit.integration.spec.ts +0 -126
- package/tests/integration/materialization/scheduler.integration.spec.ts +0 -200
- package/tests/integration/materialization/scheduler_recovery.integration.spec.ts +0 -172
- package/tests/integration/materialization/scheduler_transitions.integration.spec.ts +0 -256
- package/tests/integration/materialization/scheduler_wiring.integration.spec.ts +0 -143
- package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +0 -438
- package/tests/integration/mcp/mcp_get_context_semantic.integration.spec.ts +0 -235
- package/tests/integration/mcp/mcp_transport.integration.spec.ts +0 -98
- package/tests/integration/query_givens/query_givens.integration.spec.ts +0 -146
- package/tests/integration/query_givens/query_givens_authorize.integration.spec.ts +0 -121
- package/tests/integration/sdk_givens/sdk_givens.integration.spec.ts +0 -110
- package/tests/integration/watch-mode/watch_mode.integration.spec.ts +0 -421
- package/tests/unit/controller/theme.controller.spec.ts +0 -141
- package/tests/unit/duckdb/attached_databases.test.ts +0 -1243
- package/tests/unit/duckdb/duckdb_connection.test.ts +0 -181
- package/tests/unit/duckdb/legacy_schema_migration.test.ts +0 -197
- package/tests/unit/duckdb/repositories.test.ts +0 -208
- package/tests/unit/ducklake/ducklake.test.ts +0 -952
- package/tests/unit/service/theme_store.spec.ts +0 -139
- package/tsconfig.json +0 -32
package/src/server.ts
DELETED
|
@@ -1,2059 +0,0 @@
|
|
|
1
|
-
// Pre-load the instrumentation module; the instrumentation module must be loaded before the other imports.
|
|
2
|
-
import type { GivenValue } from "@malloydata/malloy";
|
|
3
|
-
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
4
|
-
import bodyParser from "body-parser";
|
|
5
|
-
import cors from "cors";
|
|
6
|
-
import express from "express";
|
|
7
|
-
import * as http from "http";
|
|
8
|
-
import { createProxyMiddleware } from "http-proxy-middleware";
|
|
9
|
-
import { AddressInfo } from "net";
|
|
10
|
-
import * as path from "path";
|
|
11
|
-
import { fileURLToPath } from "url";
|
|
12
|
-
import { CompileController } from "./controller/compile.controller";
|
|
13
|
-
import { ConnectionController } from "./controller/connection.controller";
|
|
14
|
-
import { DatabaseController } from "./controller/database.controller";
|
|
15
|
-
import { ModelController } from "./controller/model.controller";
|
|
16
|
-
import { PackageController } from "./controller/package.controller";
|
|
17
|
-
import { QueryController } from "./controller/query.controller";
|
|
18
|
-
import { WatchModeController } from "./controller/watch-mode.controller";
|
|
19
|
-
import {
|
|
20
|
-
BadRequestError,
|
|
21
|
-
internalErrorToHttpError,
|
|
22
|
-
NotImplementedError,
|
|
23
|
-
ServiceUnavailableError,
|
|
24
|
-
} from "./errors";
|
|
25
|
-
import {
|
|
26
|
-
drainingGuard,
|
|
27
|
-
registerHealthEndpoints,
|
|
28
|
-
registerSignalHandlers,
|
|
29
|
-
} from "./health";
|
|
30
|
-
import "./instrumentation";
|
|
31
|
-
import {
|
|
32
|
-
getPrometheusMetricsHandler,
|
|
33
|
-
httpMetricsMiddleware,
|
|
34
|
-
} from "./instrumentation";
|
|
35
|
-
import { logger, loggerMiddleware } from "./logger";
|
|
36
|
-
|
|
37
|
-
import {
|
|
38
|
-
getEmbeddingConfig,
|
|
39
|
-
getExtensionFetchPolicy,
|
|
40
|
-
getMaterializationSchedulerConfig,
|
|
41
|
-
getMemoryGovernorConfig,
|
|
42
|
-
getPersistCollisionEnforce,
|
|
43
|
-
getPersistStorageMode,
|
|
44
|
-
getQueryMetadataMode,
|
|
45
|
-
} from "./config";
|
|
46
|
-
import { setFilterDeprecationHeaders } from "./filter_deprecation";
|
|
47
|
-
import { checkHeapConfiguration } from "./heap_check";
|
|
48
|
-
import { queryConcurrency } from "./query_concurrency";
|
|
49
|
-
import { MaterializationController } from "./controller/materialization.controller";
|
|
50
|
-
import { ThemeController } from "./controller/theme.controller";
|
|
51
|
-
import { initializeMcpServer } from "./mcp/server";
|
|
52
|
-
import {
|
|
53
|
-
addCommand,
|
|
54
|
-
ensureMcpConfig,
|
|
55
|
-
logMcpConfigOutcome,
|
|
56
|
-
MCP_CONFIG_FILENAME,
|
|
57
|
-
mcpConfigEnabled,
|
|
58
|
-
mcpEndpoint,
|
|
59
|
-
resolveBoundPort,
|
|
60
|
-
resolveClientHost,
|
|
61
|
-
} from "./mcp_config";
|
|
62
|
-
import { registerLegacyRoutes } from "./server-old";
|
|
63
|
-
import { EnvironmentStore } from "./service/environment_store";
|
|
64
|
-
import { MaterializationScheduler } from "./service/materialization_scheduler";
|
|
65
|
-
import { MaterializationService } from "./service/materialization_service";
|
|
66
|
-
import {
|
|
67
|
-
normalizeQueryArray,
|
|
68
|
-
parseNonNegativeIntParam,
|
|
69
|
-
} from "./query_param_utils";
|
|
70
|
-
import { PackageMemoryGovernor } from "./service/package_memory_governor";
|
|
71
|
-
import { ThemeStore } from "./service/theme_store";
|
|
72
|
-
import { assertSafePackageName, safeJoinUnderRoot } from "./path_safety";
|
|
73
|
-
|
|
74
|
-
// Parse command line arguments
|
|
75
|
-
function parseArgs() {
|
|
76
|
-
const args = process.argv.slice(2);
|
|
77
|
-
let sawServerRoot = false;
|
|
78
|
-
let sawConfig = false;
|
|
79
|
-
for (let i = 0; i < args.length; i++) {
|
|
80
|
-
const arg = args[i];
|
|
81
|
-
if (arg === "--port" && args[i + 1]) {
|
|
82
|
-
process.env.PUBLISHER_PORT = args[i + 1];
|
|
83
|
-
i++;
|
|
84
|
-
} else if (arg === "--host" && args[i + 1]) {
|
|
85
|
-
process.env.PUBLISHER_HOST = args[i + 1];
|
|
86
|
-
i++;
|
|
87
|
-
} else if (arg === "--server_root" && args[i + 1]) {
|
|
88
|
-
sawServerRoot = true;
|
|
89
|
-
process.env.SERVER_ROOT = args[i + 1];
|
|
90
|
-
i++;
|
|
91
|
-
} else if (arg === "--config" && args[i + 1]) {
|
|
92
|
-
sawConfig = true;
|
|
93
|
-
process.env.PUBLISHER_CONFIG_PATH = args[i + 1];
|
|
94
|
-
i++;
|
|
95
|
-
} else if (arg === "--mcp_port" && args[i + 1]) {
|
|
96
|
-
process.env.MCP_PORT = args[i + 1];
|
|
97
|
-
i++;
|
|
98
|
-
} else if (arg === "--shutdown_drain_duration_seconds" && args[i + 1]) {
|
|
99
|
-
process.env.SHUTDOWN_DRAIN_DURATION_SECONDS = args[i + 1];
|
|
100
|
-
i++;
|
|
101
|
-
} else if (
|
|
102
|
-
arg === "--shutdown_graceful_close_timeout_seconds" &&
|
|
103
|
-
args[i + 1]
|
|
104
|
-
) {
|
|
105
|
-
process.env.SHUTDOWN_GRACEFUL_CLOSE_TIMEOUT_SECONDS = args[i + 1];
|
|
106
|
-
i++;
|
|
107
|
-
} else if (arg === "--init") {
|
|
108
|
-
process.env.INITIALIZE_STORAGE = "true";
|
|
109
|
-
} else if (arg === "--no-mcp-config") {
|
|
110
|
-
process.env.PUBLISHER_NO_MCP_CONFIG = "true";
|
|
111
|
-
} else if (arg === "--watch-env" && args[i + 1]) {
|
|
112
|
-
// Append (don't overwrite) so multiple --watch-env flags compose
|
|
113
|
-
// and so an explicit env var pre-set still wins.
|
|
114
|
-
const existing = process.env.PUBLISHER_WATCH || "";
|
|
115
|
-
process.env.PUBLISHER_WATCH = existing
|
|
116
|
-
? `${existing},${args[i + 1]}`
|
|
117
|
-
: args[i + 1];
|
|
118
|
-
i++;
|
|
119
|
-
} else if (arg === "--help" || arg === "-h") {
|
|
120
|
-
console.log("Malloy Publisher Server");
|
|
121
|
-
console.log("");
|
|
122
|
-
console.log("Usage: malloy-publisher [options]");
|
|
123
|
-
console.log("");
|
|
124
|
-
console.log("Options:");
|
|
125
|
-
console.log(
|
|
126
|
-
" --port <number> Port to run the server on (default: 4000)",
|
|
127
|
-
);
|
|
128
|
-
console.log(
|
|
129
|
-
" --host <string> Host to bind the REST and MCP servers to (default: 0.0.0.0)",
|
|
130
|
-
);
|
|
131
|
-
console.log(
|
|
132
|
-
" --server_root <path> Root directory to serve files from (default: .)",
|
|
133
|
-
);
|
|
134
|
-
console.log(
|
|
135
|
-
" --config <path> Path to publisher.config.json (default: <server_root>/publisher.config.json; falls back to bundled DuckDB-only sample config if missing)",
|
|
136
|
-
);
|
|
137
|
-
console.log(
|
|
138
|
-
" --mcp_port <number> Port for MCP server (default: 4040)",
|
|
139
|
-
);
|
|
140
|
-
console.log(
|
|
141
|
-
" --shutdown_drain_duration_seconds <number> Time in seconds to keep service in draining state before closing servers (default: 0)",
|
|
142
|
-
);
|
|
143
|
-
console.log(
|
|
144
|
-
" --shutdown_graceful_close_timeout_seconds <number> Time in seconds to wait after closing servers before exit (default: 0)",
|
|
145
|
-
);
|
|
146
|
-
console.log(
|
|
147
|
-
" --init Wipe persisted storage and re-sync it from the config (default: false)",
|
|
148
|
-
);
|
|
149
|
-
console.log(
|
|
150
|
-
" --no-mcp-config Do not write .mcp.json into the working directory (default: it is written, so an agent opened here finds this server; skipped when the directory already has one, is your home directory or the filesystem root, is inside a git working tree, or the MCP port bound is not the one requested)",
|
|
151
|
-
);
|
|
152
|
-
console.log(
|
|
153
|
-
" --watch-env <name> Enable dev-mode watch for the named environment.",
|
|
154
|
-
);
|
|
155
|
-
console.log(
|
|
156
|
-
" Mounts local-dir packages in-place (symlink, not",
|
|
157
|
-
);
|
|
158
|
-
console.log(
|
|
159
|
-
" copy) so source-edit live reload works. A comma-",
|
|
160
|
-
);
|
|
161
|
-
console.log(
|
|
162
|
-
" separated PUBLISHER_WATCH mounts all listed envs in",
|
|
163
|
-
);
|
|
164
|
-
console.log(
|
|
165
|
-
" place, but only the first one auto-reloads.",
|
|
166
|
-
);
|
|
167
|
-
console.log(" --help, -h Show this help message");
|
|
168
|
-
process.exit(0);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
// Zero-config invocation (`npx @malloy-publisher/server`) opts in to
|
|
172
|
-
// the bundled DuckDB-only sample config so the Quick Start works
|
|
173
|
-
// without any flags. Any explicit --server_root or --config disables
|
|
174
|
-
// this — the user told us where to look. Skip in NODE_ENV=test as a
|
|
175
|
-
// belt-and-suspenders so any spec that ends up evaluating this
|
|
176
|
-
// module doesn't accidentally pin the EnvironmentStore to the
|
|
177
|
-
// bundled examples config.
|
|
178
|
-
if (!sawServerRoot && !sawConfig && process.env.NODE_ENV !== "test") {
|
|
179
|
-
process.env.PUBLISHER_USE_BUNDLED_DEFAULT = "true";
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Parse CLI arguments before setting up constants
|
|
184
|
-
parseArgs();
|
|
185
|
-
|
|
186
|
-
// Fail fast at boot on a malformed PERSIST_STORAGE_MODE. The getter throws on an
|
|
187
|
-
// invalid value; without this it would only throw lazily inside a per-request
|
|
188
|
-
// package-metadata call (storageWarnings), on every deployment regardless of
|
|
189
|
-
// whether the tier is used. Reading it once here surfaces a typo at startup.
|
|
190
|
-
getPersistStorageMode();
|
|
191
|
-
// Same for PERSIST_COLLISION_ENFORCE, whose only other caller is the publish
|
|
192
|
-
// path — so a typo would otherwise surface as a failed publish request rather
|
|
193
|
-
// than a failed boot.
|
|
194
|
-
getPersistCollisionEnforce();
|
|
195
|
-
|
|
196
|
-
// Same hazard, wider blast radius: getQueryMetadataMode() throws on an invalid
|
|
197
|
-
// value and is read while resolving EVERY statement, so a typo'd off switch
|
|
198
|
-
// ("false", "0", "disabled") would boot clean and then fail every query and
|
|
199
|
-
// every build — the one thing the metadata path promises never to do.
|
|
200
|
-
getQueryMetadataMode();
|
|
201
|
-
|
|
202
|
-
const PUBLISHER_PORT = Number(process.env.PUBLISHER_PORT || 4000);
|
|
203
|
-
const PUBLISHER_HOST = process.env.PUBLISHER_HOST || "0.0.0.0";
|
|
204
|
-
const MCP_PORT = Number(process.env.MCP_PORT || 4040);
|
|
205
|
-
// Resolved here rather than in the listen callback: parseBoolEnv throws on a
|
|
206
|
-
// typo, which is the convention for flags in this server, but a throw inside a
|
|
207
|
-
// listen callback is an uncaughtException that kills a server which has already
|
|
208
|
-
// bound both ports. At module scope it is an ordinary startup failure.
|
|
209
|
-
const MCP_CONFIG_ENABLED = mcpConfigEnabled();
|
|
210
|
-
const MCP_ENDPOINT = "/mcp";
|
|
211
|
-
const SHUTDOWN_DRAIN_DURATION_SECONDS = Number(
|
|
212
|
-
process.env.SHUTDOWN_DRAIN_DURATION_SECONDS || 0,
|
|
213
|
-
);
|
|
214
|
-
const SHUTDOWN_GRACEFUL_CLOSE_TIMEOUT_SECONDS = Number(
|
|
215
|
-
process.env.SHUTDOWN_GRACEFUL_CLOSE_TIMEOUT_SECONDS || 0,
|
|
216
|
-
);
|
|
217
|
-
// Find the app directory relative to this bundled server file.
|
|
218
|
-
// Works under both ESM (import.meta.url) and when invoked via NPX.
|
|
219
|
-
const __filename_esm = fileURLToPath(import.meta.url);
|
|
220
|
-
const ROOT = path.join(path.dirname(__filename_esm), "app");
|
|
221
|
-
const SERVER_ROOT = path.resolve(process.cwd(), process.env.SERVER_ROOT || ".");
|
|
222
|
-
const API_PREFIX = "/api/v0";
|
|
223
|
-
const isDevelopment = process.env["NODE_ENV"] === "development";
|
|
224
|
-
|
|
225
|
-
export const app = express();
|
|
226
|
-
app.use(loggerMiddleware);
|
|
227
|
-
app.use(httpMetricsMiddleware);
|
|
228
|
-
// Probe the V8 heap ceiling once at startup and warn if it's below
|
|
229
|
-
// the recommended floor. The row/byte caps from Steps 1–3 still
|
|
230
|
-
// bound per-request memory; this is a "your --max-old-space-size
|
|
231
|
-
// looks low for the default caps" advisory so operators don't
|
|
232
|
-
// chase OOMKills before checking the obvious config.
|
|
233
|
-
checkHeapConfiguration();
|
|
234
|
-
export const environmentStore = new EnvironmentStore(SERVER_ROOT);
|
|
235
|
-
const watchModeController = new WatchModeController(environmentStore);
|
|
236
|
-
const connectionController = new ConnectionController(environmentStore);
|
|
237
|
-
const modelController = new ModelController(environmentStore);
|
|
238
|
-
// PackageMemoryGovernor is opt-in via PUBLISHER_MAX_MEMORY_BYTES.
|
|
239
|
-
// When set, it polls process RSS and flips an `isBackpressured` flag
|
|
240
|
-
// that Environment.getPackage / addPackage consult before allocating
|
|
241
|
-
// any new package — the server responds with HTTP 503 instead of
|
|
242
|
-
// OOM-killing the pod.
|
|
243
|
-
// Validate the DuckDB extension-fetch policy at boot so an unrecognised value
|
|
244
|
-
// (e.g. a k8s-manifest typo) fails the boot loudly here, rather than surfacing
|
|
245
|
-
// on the first query that resolves a DuckDB connection — which matters most for
|
|
246
|
-
// an operator relying on `local-only` for a no-network guarantee. Logging the
|
|
247
|
-
// resolved policy also records the posture the server booted with.
|
|
248
|
-
logger.info(`DuckDB extension-fetch policy: ${getExtensionFetchPolicy()}`);
|
|
249
|
-
// Resolve the embedding config at boot so a malformed EMBEDDING_API_BASE /
|
|
250
|
-
// EMBEDDING_DIMENSIONS fails loudly at startup (getEmbeddingConfig throws),
|
|
251
|
-
// matching the sibling getters above, rather than surfacing as a warn on the
|
|
252
|
-
// first getContext call that reaches tier 4 — or never. Logs the posture the
|
|
253
|
-
// server booted with; the host only, never the key.
|
|
254
|
-
const embeddingConfig = getEmbeddingConfig();
|
|
255
|
-
if (embeddingConfig) {
|
|
256
|
-
logger.info(
|
|
257
|
-
`Semantic get_context enabled: model ${embeddingConfig.model} at ${new URL(embeddingConfig.baseUrl).host}`,
|
|
258
|
-
);
|
|
259
|
-
}
|
|
260
|
-
const memoryGovernorConfig = getMemoryGovernorConfig();
|
|
261
|
-
const memoryGovernor = memoryGovernorConfig
|
|
262
|
-
? new PackageMemoryGovernor(memoryGovernorConfig)
|
|
263
|
-
: null;
|
|
264
|
-
memoryGovernor?.start();
|
|
265
|
-
environmentStore.setMemoryGovernor(memoryGovernor);
|
|
266
|
-
const packageController = new PackageController(environmentStore);
|
|
267
|
-
const databaseController = new DatabaseController(environmentStore);
|
|
268
|
-
const queryController = new QueryController(environmentStore);
|
|
269
|
-
const compileController = new CompileController(environmentStore);
|
|
270
|
-
const materializationService = new MaterializationService(environmentStore);
|
|
271
|
-
const materializationController = new MaterializationController(
|
|
272
|
-
materializationService,
|
|
273
|
-
);
|
|
274
|
-
/**
|
|
275
|
-
* Construct and start the standalone materialization scheduler from environment
|
|
276
|
-
* config, or return null when the feature is disabled
|
|
277
|
-
* (`PUBLISHER_LOCAL_MATERIALIZATION_SCHEDULER` unset/false — the default, so an
|
|
278
|
-
* orchestrated deployment never runs it). Extracted from the module body so a
|
|
279
|
-
* test can drive the real env-var → {@link getMaterializationSchedulerConfig} →
|
|
280
|
-
* construct → `start()`/`unref()` → timer path an operator uses; the
|
|
281
|
-
* module-level singleton below is armed once at import and can't be re-created
|
|
282
|
-
* per test.
|
|
283
|
-
*/
|
|
284
|
-
export function startMaterializationSchedulerFromEnv(
|
|
285
|
-
store: EnvironmentStore,
|
|
286
|
-
service: MaterializationService,
|
|
287
|
-
): MaterializationScheduler | null {
|
|
288
|
-
const config = getMaterializationSchedulerConfig();
|
|
289
|
-
if (!config) return null;
|
|
290
|
-
const scheduler = new MaterializationScheduler(store, service, config);
|
|
291
|
-
scheduler.start();
|
|
292
|
-
return scheduler;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// Standalone materialization scheduler: opt-in via
|
|
296
|
-
// PUBLISHER_LOCAL_MATERIALIZATION_SCHEDULER (default off, so an orchestrated
|
|
297
|
-
// deployment — whose control plane drives materialization — never runs it). The
|
|
298
|
-
// sweep timer is `unref`'d, so it never keeps the process alive on shutdown.
|
|
299
|
-
startMaterializationSchedulerFromEnv(environmentStore, materializationService);
|
|
300
|
-
const themeStore = new ThemeStore(environmentStore.storageManager, SERVER_ROOT);
|
|
301
|
-
const themeController = new ThemeController(themeStore, SERVER_ROOT);
|
|
302
|
-
|
|
303
|
-
export const mcpApp = express();
|
|
304
|
-
|
|
305
|
-
// Register health endpoints on mcpApp (for E2E tests)
|
|
306
|
-
registerHealthEndpoints(mcpApp);
|
|
307
|
-
|
|
308
|
-
mcpApp.use(MCP_ENDPOINT, express.json());
|
|
309
|
-
mcpApp.use(MCP_ENDPOINT, cors());
|
|
310
|
-
|
|
311
|
-
mcpApp.all(MCP_ENDPOINT, async (req, res) => {
|
|
312
|
-
logger.info(`[MCP Debug] Handling ${req.method} (Stateless)`);
|
|
313
|
-
|
|
314
|
-
try {
|
|
315
|
-
if (req.method === "POST") {
|
|
316
|
-
const transport = new StreamableHTTPServerTransport({
|
|
317
|
-
sessionIdGenerator: undefined,
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
transport.onclose = () => {
|
|
321
|
-
logger.info(
|
|
322
|
-
`[MCP Transport Info] Stateless transport closed for a request.`,
|
|
323
|
-
);
|
|
324
|
-
};
|
|
325
|
-
transport.onerror = (err: Error) => {
|
|
326
|
-
logger.error(`[MCP Transport Error] Stateless transport error:`, {
|
|
327
|
-
error: err,
|
|
328
|
-
});
|
|
329
|
-
};
|
|
330
|
-
|
|
331
|
-
const requestMcpServer = initializeMcpServer(environmentStore);
|
|
332
|
-
await requestMcpServer.connect(transport);
|
|
333
|
-
|
|
334
|
-
res.on("close", () => {
|
|
335
|
-
logger.info(
|
|
336
|
-
"[MCP Transport Info] Response closed, cleaning up stateless transport.",
|
|
337
|
-
);
|
|
338
|
-
transport.close().catch((err) => {
|
|
339
|
-
logger.error(
|
|
340
|
-
"[MCP Transport Error] Error closing stateless transport on response close:",
|
|
341
|
-
{ error: err },
|
|
342
|
-
);
|
|
343
|
-
});
|
|
344
|
-
});
|
|
345
|
-
|
|
346
|
-
await transport.handleRequest(req, res, req.body);
|
|
347
|
-
} else if (req.method === "GET" || req.method === "DELETE") {
|
|
348
|
-
logger.warn(
|
|
349
|
-
`[MCP Transport Warn] Method Not Allowed in Stateless Mode: ${req.method}`,
|
|
350
|
-
);
|
|
351
|
-
res.setHeader("Allow", "POST");
|
|
352
|
-
res.status(405).json({
|
|
353
|
-
jsonrpc: "2.0",
|
|
354
|
-
error: {
|
|
355
|
-
code: -32601,
|
|
356
|
-
message: "Method Not Allowed in Stateless Mode",
|
|
357
|
-
},
|
|
358
|
-
id: null,
|
|
359
|
-
});
|
|
360
|
-
return;
|
|
361
|
-
} else {
|
|
362
|
-
logger.warn(`[MCP Transport Warn] Method Not Allowed: ${req.method}`);
|
|
363
|
-
res.setHeader("Allow", "POST");
|
|
364
|
-
res.status(405).json({
|
|
365
|
-
jsonrpc: "2.0",
|
|
366
|
-
error: { code: -32601, message: "Method Not Allowed" },
|
|
367
|
-
id: null,
|
|
368
|
-
});
|
|
369
|
-
return;
|
|
370
|
-
}
|
|
371
|
-
} catch (error) {
|
|
372
|
-
logger.error(
|
|
373
|
-
`[MCP Transport Error] Unhandled error in ${req.method} handler (Stateless):`,
|
|
374
|
-
{ error },
|
|
375
|
-
);
|
|
376
|
-
if (!res.headersSent) {
|
|
377
|
-
res.status(500).json({
|
|
378
|
-
jsonrpc: "2.0",
|
|
379
|
-
error: { code: -32603, message: "Internal server error" },
|
|
380
|
-
id:
|
|
381
|
-
typeof req.body === "object" &&
|
|
382
|
-
req.body !== null &&
|
|
383
|
-
"id" in req.body
|
|
384
|
-
? req.body.id
|
|
385
|
-
: null,
|
|
386
|
-
});
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
// ---------------------------------------------------------------------------
|
|
392
|
-
// In-package HTML data apps
|
|
393
|
-
// ---------------------------------------------------------------------------
|
|
394
|
-
// These routes must come before the SPA catch-all and (in dev) the Vite proxy
|
|
395
|
-
// so that:
|
|
396
|
-
// - `/sdk/publisher.js` → Publisher runtime helper
|
|
397
|
-
// - `/environments/<env>/packages/<pkg>/<file.ext>` → static file from
|
|
398
|
-
// inside the package dir
|
|
399
|
-
// - `/api/v0/.../events` → live-reload SSE (registered in API routes
|
|
400
|
-
// below; this comment is the cross-reference)
|
|
401
|
-
|
|
402
|
-
// Serve the runtime helper that in-package HTML pages load via
|
|
403
|
-
// <script src="/sdk/publisher.js">. Path resolved once at module load.
|
|
404
|
-
const PUBLISHER_RUNTIME_PATH = path.join(
|
|
405
|
-
path.dirname(__filename_esm),
|
|
406
|
-
"runtime",
|
|
407
|
-
"publisher.js",
|
|
408
|
-
);
|
|
409
|
-
app.get("/sdk/publisher.js", (_req, res) => {
|
|
410
|
-
res.type("application/javascript");
|
|
411
|
-
// Short cache so live edits during local dev show up quickly. In
|
|
412
|
-
// production this file is content-stable per release.
|
|
413
|
-
res.setHeader("cache-control", "public, max-age=60");
|
|
414
|
-
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
415
|
-
res.sendFile(PUBLISHER_RUNTIME_PATH, (err) => {
|
|
416
|
-
if (err) {
|
|
417
|
-
logger.error("Failed to send publisher.js runtime", { error: err });
|
|
418
|
-
if (!res.headersSent) res.status(500).end();
|
|
419
|
-
}
|
|
420
|
-
});
|
|
421
|
-
});
|
|
422
|
-
|
|
423
|
-
// Serve files from inside a package directory at
|
|
424
|
-
// /environments/<env>/packages/<pkg>/<relative-path>
|
|
425
|
-
//
|
|
426
|
-
// This route fully owns its prefix — it does NOT fall through to the SPA on
|
|
427
|
-
// missing files, because doing so would mask 404s (and in dev mode the SPA
|
|
428
|
-
// catch-all errors out before it can reply). Behavior:
|
|
429
|
-
// - `/environments/<env>/packages/<pkg>` → 302 to `…/<pkg>/`
|
|
430
|
-
// - `/environments/<env>/packages/<pkg>/` → serve `<pkgRoot>/public/index.html`
|
|
431
|
-
// - `/environments/<env>/packages/<pkg>/foo/` → serve `<pkgRoot>/public/foo/index.html`
|
|
432
|
-
// - `/environments/<env>/packages/<pkg>/<file>` → serve `<pkgRoot>/public/<file>`, or 404
|
|
433
|
-
// Only the package's `public/` directory is web-served. Models, data files, and
|
|
434
|
-
// the publisher.json manifest live outside it and are never reachable here, so
|
|
435
|
-
// nothing can be downloaded around the per-model #(authorize) and query
|
|
436
|
-
// controls. The data stays reachable through the permission-checked query path.
|
|
437
|
-
|
|
438
|
-
async function serveFromPackage(
|
|
439
|
-
req: express.Request,
|
|
440
|
-
res: express.Response,
|
|
441
|
-
): Promise<void> {
|
|
442
|
-
const subPathRaw = (req.params as Record<string, string>)["0"] ?? "";
|
|
443
|
-
try {
|
|
444
|
-
const environment = await environmentStore.getEnvironment(
|
|
445
|
-
req.params.environmentName,
|
|
446
|
-
false,
|
|
447
|
-
);
|
|
448
|
-
const pkg = await environment.getPackage(req.params.packageName, false);
|
|
449
|
-
// Only the package's public/ directory is web-served. Models, data, and
|
|
450
|
-
// the publisher.json manifest live outside it and are never reachable
|
|
451
|
-
// through this route. This single directory boundary is the whole
|
|
452
|
-
// access-control story for static files.
|
|
453
|
-
const publicRoot = path.join(pkg.getPackagePath(), "public");
|
|
454
|
-
|
|
455
|
-
// Directory-style fallback: empty path or trailing slash → look for
|
|
456
|
-
// index.html within that directory.
|
|
457
|
-
let subPath = subPathRaw;
|
|
458
|
-
if (subPath === "" || subPath.endsWith("/")) {
|
|
459
|
-
subPath = subPath + "index.html";
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
// Resolve the requested file under public/ and reject anything that
|
|
463
|
-
// escapes it (`..`, encoded traversal) before touching the disk.
|
|
464
|
-
// safeJoinUnderRoot is the shared lexical-containment primitive (it throws
|
|
465
|
-
// BadRequestError on escape, surfaced as 400 by the outer catch); the
|
|
466
|
-
// realpath check below additionally catches symlinks inside public/ that
|
|
467
|
-
// point outward (403).
|
|
468
|
-
const fullPath = safeJoinUnderRoot(publicRoot, subPath);
|
|
469
|
-
|
|
470
|
-
// Containment check via realpath against the resolved public/ root.
|
|
471
|
-
// Catches symlinks inside public/ that point out (e.g. a malicious
|
|
472
|
-
// package shipping `public/leak -> /etc/passwd`), and tolerates the
|
|
473
|
-
// package root itself being a symlink (how watch-mode in-place mount
|
|
474
|
-
// works): realpath resolves it transparently and legitimate accesses
|
|
475
|
-
// inside public/ stay within realPublicRoot. Missing public/ dir or
|
|
476
|
-
// missing file: realpath throws ENOENT and we 404 cleanly instead of
|
|
477
|
-
// leaking via Express's default error handler.
|
|
478
|
-
const fsp = await import("fs/promises");
|
|
479
|
-
let realPublicRoot: string;
|
|
480
|
-
let realFullPath: string;
|
|
481
|
-
try {
|
|
482
|
-
realPublicRoot = await fsp.realpath(publicRoot);
|
|
483
|
-
realFullPath = await fsp.realpath(fullPath);
|
|
484
|
-
} catch {
|
|
485
|
-
if (!res.headersSent) {
|
|
486
|
-
// Generic 404 with no reflected request input (avoids reflecting
|
|
487
|
-
// user-controlled path/package name into the response body).
|
|
488
|
-
res.status(404).end();
|
|
489
|
-
}
|
|
490
|
-
return;
|
|
491
|
-
}
|
|
492
|
-
const rel = path.relative(realPublicRoot, realFullPath);
|
|
493
|
-
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
494
|
-
res.status(403).end();
|
|
495
|
-
return;
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
// Framing policy only applies to HTML documents — setting it on CSS/JS/
|
|
499
|
-
// image assets is meaningless and needlessly strips their default
|
|
500
|
-
// SAMEORIGIN protection. Embeddability defaults to "*" so same-tenant
|
|
501
|
-
// embeds work out of the box, and is overridable via PUBLISHER_FRAME_ANCESTORS.
|
|
502
|
-
const ext = path.extname(realFullPath).toLowerCase();
|
|
503
|
-
if (ext === ".html" || ext === ".htm") {
|
|
504
|
-
const frameAncestors = process.env.PUBLISHER_FRAME_ANCESTORS || "*";
|
|
505
|
-
res.setHeader(
|
|
506
|
-
"Content-Security-Policy",
|
|
507
|
-
`frame-ancestors ${frameAncestors}`,
|
|
508
|
-
);
|
|
509
|
-
res.removeHeader("X-Frame-Options");
|
|
510
|
-
}
|
|
511
|
-
// Never let a served asset be MIME-sniffed into a different content type.
|
|
512
|
-
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
513
|
-
res.sendFile(realFullPath, (err) => {
|
|
514
|
-
if (err) {
|
|
515
|
-
// Own the 404 instead of letting Express fall through to a
|
|
516
|
-
// catch-all that may error.
|
|
517
|
-
if (!res.headersSent) {
|
|
518
|
-
// Generic 404, no reflected request input (see above).
|
|
519
|
-
res.status(404).end();
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
});
|
|
523
|
-
} catch (e) {
|
|
524
|
-
// Map service errors to their real status — a bad package name is a 400,
|
|
525
|
-
// memory back-pressure is a 503 — rather than flattening everything to
|
|
526
|
-
// 404. A genuine missing file is already handled by the realpath/sendFile
|
|
527
|
-
// 404 paths above; this catch only sees service-layer failures.
|
|
528
|
-
if (!res.headersSent) {
|
|
529
|
-
const { json, status } = internalErrorToHttpError(e as Error);
|
|
530
|
-
res.status(status).json(json);
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
// `/environments/<env>/packages/<pkg>` (no trailing slash, no path) redirect so
|
|
536
|
-
// relative URLs in the served HTML resolve as expected. Express's default loose
|
|
537
|
-
// matching also catches the trailing-slash form here, so only redirect URLs that
|
|
538
|
-
// don't already end with `/`.
|
|
539
|
-
//
|
|
540
|
-
// Build the target from the validated route params and the parsed query, not
|
|
541
|
-
// from the raw request URL, so it is always this same canonical, same-origin
|
|
542
|
-
// path with a trailing slash. That removes any open-redirect / header-injection
|
|
543
|
-
// surface from user-controlled input, with the slash placed before any query
|
|
544
|
-
// string (e.g. ?embed_token=...).
|
|
545
|
-
app.get(
|
|
546
|
-
"/environments/:environmentName/packages/:packageName",
|
|
547
|
-
(req, res, next) => {
|
|
548
|
-
if (req.path.endsWith("/")) return next();
|
|
549
|
-
const canonical =
|
|
550
|
-
`/environments/${encodeURIComponent(req.params.environmentName)}` +
|
|
551
|
-
`/packages/${encodeURIComponent(req.params.packageName)}/`;
|
|
552
|
-
const query = new URLSearchParams();
|
|
553
|
-
for (const [key, value] of Object.entries(req.query)) {
|
|
554
|
-
if (Array.isArray(value)) {
|
|
555
|
-
for (const v of value) query.append(key, String(v));
|
|
556
|
-
} else if (value !== undefined) {
|
|
557
|
-
query.append(key, String(value));
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
const qs = query.toString();
|
|
561
|
-
res.redirect(308, qs ? `${canonical}?${qs}` : canonical);
|
|
562
|
-
},
|
|
563
|
-
);
|
|
564
|
-
|
|
565
|
-
app.get(
|
|
566
|
-
"/environments/:environmentName/packages/:packageName/*",
|
|
567
|
-
serveFromPackage,
|
|
568
|
-
);
|
|
569
|
-
|
|
570
|
-
// List the static HTML pages bundled inside a package. Used by the SPA's
|
|
571
|
-
// package-detail view to surface a clickable list, and by anyone who wants
|
|
572
|
-
// to discover pages programmatically without scraping the directory.
|
|
573
|
-
//
|
|
574
|
-
// Returns a `Page[]` (see api-doc.yaml) — each item carries the relative
|
|
575
|
-
// `path`, the `packageName`, the page `title` (from its <title> tag), and a
|
|
576
|
-
// `resource` URL. `resource` is the root-relative static-serve URL (NOT under
|
|
577
|
-
// `${API_PREFIX}`) because pages are static assets served off the server root,
|
|
578
|
-
// unlike API resources such as `Package.resource`.
|
|
579
|
-
// Recursive depth is capped to keep this cheap for huge package directories.
|
|
580
|
-
const PAGES_DEPTH_CAP = 3;
|
|
581
|
-
type PageItem = {
|
|
582
|
-
resource: string;
|
|
583
|
-
packageName: string;
|
|
584
|
-
path: string;
|
|
585
|
-
title: string;
|
|
586
|
-
fit?: "viewport";
|
|
587
|
-
};
|
|
588
|
-
|
|
589
|
-
// The spots in an HTML head where a "<meta ...>" literal would NOT be a live
|
|
590
|
-
// tag: HTML comments (terminated or unterminated) and raw-text/RCDATA elements
|
|
591
|
-
// (script/style/title/textarea). One alternation so a single .replace covers
|
|
592
|
-
// them all (and so the fixpoint loop below applies one self-referential
|
|
593
|
-
// replace, which is the complete-sanitization shape CodeQL recognizes).
|
|
594
|
-
const NON_TAG_TEXT_PATTERN =
|
|
595
|
-
/<!--[\s\S]*?-->|<!--[\s\S]*$|<(script|style|title|textarea)\b[\s\S]*?<\/\1\s*>/gi;
|
|
596
|
-
|
|
597
|
-
// Remove those matches until the string stops changing. A single pass is
|
|
598
|
-
// incomplete because removing one match can splice the surrounding text into a
|
|
599
|
-
// new one (CWE-116), so re-apply the same pattern to its own output until a
|
|
600
|
-
// fixpoint. Each pass only deletes, so the string strictly shrinks and the loop
|
|
601
|
-
// terminates, bounded by the input length (callers pass at most the first 4KB).
|
|
602
|
-
function stripNonTagText(input: string): string {
|
|
603
|
-
let current = input;
|
|
604
|
-
let previous: string;
|
|
605
|
-
do {
|
|
606
|
-
previous = current;
|
|
607
|
-
current = current.replace(NON_TAG_TEXT_PATTERN, "");
|
|
608
|
-
} while (current !== previous);
|
|
609
|
-
return current;
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
async function listPackagePages(
|
|
613
|
-
environmentName: string,
|
|
614
|
-
packageName: string,
|
|
615
|
-
publicRoot: string,
|
|
616
|
-
): Promise<PageItem[]> {
|
|
617
|
-
const fs = await import("fs/promises");
|
|
618
|
-
const out: PageItem[] = [];
|
|
619
|
-
|
|
620
|
-
// Resolve the public/ root once and reject any entry whose realpath escapes
|
|
621
|
-
// it. Same containment defense as serveFromPackage: catches symlinks inside
|
|
622
|
-
// public/ pointing outside (e.g. `public/leak -> ../report.malloy`) before we
|
|
623
|
-
// open and read the target's first 4KB for title extraction. A package with
|
|
624
|
-
// no public/ dir fails realpath here and yields an empty list.
|
|
625
|
-
let realPublicRoot: string;
|
|
626
|
-
try {
|
|
627
|
-
realPublicRoot = await fs.realpath(publicRoot);
|
|
628
|
-
} catch {
|
|
629
|
-
return out;
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
async function walk(dir: string, depth: number) {
|
|
633
|
-
if (depth > PAGES_DEPTH_CAP) return;
|
|
634
|
-
let entries: import("fs").Dirent[];
|
|
635
|
-
try {
|
|
636
|
-
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
637
|
-
} catch {
|
|
638
|
-
return;
|
|
639
|
-
}
|
|
640
|
-
for (const entry of entries) {
|
|
641
|
-
if (entry.name.startsWith(".") || entry.name === "node_modules")
|
|
642
|
-
continue;
|
|
643
|
-
const full = path.join(dir, entry.name);
|
|
644
|
-
let realFull: string;
|
|
645
|
-
try {
|
|
646
|
-
realFull = await fs.realpath(full);
|
|
647
|
-
} catch {
|
|
648
|
-
continue;
|
|
649
|
-
}
|
|
650
|
-
const contained = path.relative(realPublicRoot, realFull);
|
|
651
|
-
if (contained.startsWith("..") || path.isAbsolute(contained)) continue;
|
|
652
|
-
if (entry.isDirectory()) {
|
|
653
|
-
await walk(full, depth + 1);
|
|
654
|
-
} else if (
|
|
655
|
-
entry.isFile() &&
|
|
656
|
-
(entry.name.endsWith(".html") || entry.name.endsWith(".htm"))
|
|
657
|
-
) {
|
|
658
|
-
const rel = path.relative(publicRoot, full).replace(/\\/g, "/");
|
|
659
|
-
// Cheap metadata extraction: read first 4KB and grep the <head>.
|
|
660
|
-
let title = rel;
|
|
661
|
-
let fit: "viewport" | undefined;
|
|
662
|
-
try {
|
|
663
|
-
const fh = await fs.open(full, "r");
|
|
664
|
-
try {
|
|
665
|
-
const buf = Buffer.alloc(4096);
|
|
666
|
-
const { bytesRead } = await fh.read(buf, 0, 4096, 0);
|
|
667
|
-
const head = buf.slice(0, bytesRead).toString("utf8");
|
|
668
|
-
const m = head.match(/<title[^>]*>([^<]+)<\/title>/i);
|
|
669
|
-
if (m) title = m[1].trim();
|
|
670
|
-
// Full-screen apps (e.g. slide decks) opt into a viewport-fill
|
|
671
|
-
// embed with <meta name="publisher:fit" content="viewport">.
|
|
672
|
-
// FIRST strip the spots where the literal string is NOT a live
|
|
673
|
-
// tag (comments, script/style/title/textarea), THEN look at the
|
|
674
|
-
// <head> region only (up to </head> or <body>). Order matters:
|
|
675
|
-
// stripping before locating the boundary keeps a literal
|
|
676
|
-
// "<body>"/"</head>" inside a comment or <script> from
|
|
677
|
-
// truncating the scan and hiding a real tag. What's left and
|
|
678
|
-
// matches is a genuine <meta> the browser would honor too, so a
|
|
679
|
-
// documented/commented example or a string in a code block
|
|
680
|
-
// can't opt the page in. Match by name (attribute order/quoting
|
|
681
|
-
// vary), then confirm content="viewport"; the [\s"'] before
|
|
682
|
-
// `name` keeps `data-name="publisher:fit"` out. Like the title,
|
|
683
|
-
// the tag must sit within the first 4KB.
|
|
684
|
-
const cleaned = stripNonTagText(head);
|
|
685
|
-
const headEnd = cleaned.search(/<\/head\s*>|<body[\s>]/i);
|
|
686
|
-
const headTags =
|
|
687
|
-
headEnd === -1 ? cleaned : cleaned.slice(0, headEnd);
|
|
688
|
-
const fitMeta = headTags.match(
|
|
689
|
-
/<meta\b[^>]*[\s"']name\s*=\s*["']publisher:fit["'][^>]*>/i,
|
|
690
|
-
);
|
|
691
|
-
if (
|
|
692
|
-
fitMeta &&
|
|
693
|
-
/\bcontent\s*=\s*["']\s*viewport\s*["']/i.test(fitMeta[0])
|
|
694
|
-
) {
|
|
695
|
-
fit = "viewport";
|
|
696
|
-
}
|
|
697
|
-
} finally {
|
|
698
|
-
await fh.close();
|
|
699
|
-
}
|
|
700
|
-
} catch {
|
|
701
|
-
// ignore; fall back to relative path as title
|
|
702
|
-
}
|
|
703
|
-
out.push({
|
|
704
|
-
resource: `/environments/${environmentName}/packages/${packageName}/${rel}`,
|
|
705
|
-
packageName,
|
|
706
|
-
path: rel,
|
|
707
|
-
title,
|
|
708
|
-
fit,
|
|
709
|
-
});
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
await walk(publicRoot, 0);
|
|
715
|
-
out.sort((a, b) => {
|
|
716
|
-
// Surface index.html first, then alphabetical.
|
|
717
|
-
if (a.path === "index.html") return -1;
|
|
718
|
-
if (b.path === "index.html") return 1;
|
|
719
|
-
return a.path.localeCompare(b.path);
|
|
720
|
-
});
|
|
721
|
-
return out;
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
// NOTE: route registration for /pages moved below the CORS middleware so
|
|
725
|
-
// cross-origin SDK consumers (e.g. a customer's React app pointing at
|
|
726
|
-
// `<ServerProvider baseURL="https://publisher.example.com/api/v0">`) get
|
|
727
|
-
// the proper CORS headers. See the registration after `app.use(cors(...))`.
|
|
728
|
-
|
|
729
|
-
// Only serve static files in production mode
|
|
730
|
-
// Otherwise we proxy to the React dev server
|
|
731
|
-
if (!isDevelopment) {
|
|
732
|
-
app.use("/", express.static(ROOT));
|
|
733
|
-
app.use("/api-doc.html", express.static(path.join(ROOT, "api-doc.html")));
|
|
734
|
-
} else {
|
|
735
|
-
// In development mode, proxy requests to React dev server
|
|
736
|
-
// Handle API routes first
|
|
737
|
-
app.use(`${API_PREFIX}`, loggerMiddleware);
|
|
738
|
-
|
|
739
|
-
// Proxy everything else to Vite
|
|
740
|
-
app.use(
|
|
741
|
-
createProxyMiddleware({
|
|
742
|
-
target: "http://localhost:5173",
|
|
743
|
-
changeOrigin: true,
|
|
744
|
-
ws: true,
|
|
745
|
-
pathFilter: (path) =>
|
|
746
|
-
!path.startsWith("/api/") &&
|
|
747
|
-
!path.startsWith("/metrics") &&
|
|
748
|
-
!path.startsWith("/health"),
|
|
749
|
-
}),
|
|
750
|
-
);
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
const setVersionIdError = (res: express.Response) => {
|
|
754
|
-
const { json, status } = internalErrorToHttpError(
|
|
755
|
-
new NotImplementedError("Version IDs not implemented."),
|
|
756
|
-
);
|
|
757
|
-
res.status(status).json(json);
|
|
758
|
-
};
|
|
759
|
-
|
|
760
|
-
app.use(
|
|
761
|
-
cors({
|
|
762
|
-
origin: "http://localhost:5173",
|
|
763
|
-
credentials: true,
|
|
764
|
-
}),
|
|
765
|
-
);
|
|
766
|
-
|
|
767
|
-
// Set body-parser JSON limit to 1Mb (default: 100kb)
|
|
768
|
-
app.use(bodyParser.json({ limit: "1mb" }));
|
|
769
|
-
|
|
770
|
-
// Register health check endpoints on main app:
|
|
771
|
-
// - Required for production/Kubernetes monitoring (main server on PUBLISHER_PORT)
|
|
772
|
-
registerHealthEndpoints(app);
|
|
773
|
-
|
|
774
|
-
// Register Prometheus metrics endpoint
|
|
775
|
-
try {
|
|
776
|
-
const metricsHandler = getPrometheusMetricsHandler();
|
|
777
|
-
app.get("/metrics", metricsHandler);
|
|
778
|
-
logger.info("Prometheus metrics endpoint registered at /metrics");
|
|
779
|
-
} catch (error) {
|
|
780
|
-
logger.warn("Failed to register Prometheus metrics endpoint", { error });
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
// Register draining guard middleware - must be after health endpoints but before other routes
|
|
784
|
-
app.use(drainingGuard);
|
|
785
|
-
|
|
786
|
-
// /pages — registered here (post-CORS, post-body-parser, post-draining) so
|
|
787
|
-
// cross-origin SDK consumers and authenticated requests both work.
|
|
788
|
-
app.get(
|
|
789
|
-
`${API_PREFIX}/environments/:environmentName/packages/:packageName/pages`,
|
|
790
|
-
async (req, res) => {
|
|
791
|
-
try {
|
|
792
|
-
const environment = await environmentStore.getEnvironment(
|
|
793
|
-
req.params.environmentName,
|
|
794
|
-
false,
|
|
795
|
-
);
|
|
796
|
-
const pkg = await environment.getPackage(
|
|
797
|
-
req.params.packageName,
|
|
798
|
-
false,
|
|
799
|
-
);
|
|
800
|
-
const pages = await listPackagePages(
|
|
801
|
-
req.params.environmentName,
|
|
802
|
-
req.params.packageName,
|
|
803
|
-
path.join(pkg.getPackagePath(), "public"),
|
|
804
|
-
);
|
|
805
|
-
res.json(pages);
|
|
806
|
-
} catch (error) {
|
|
807
|
-
logger.error("Failed to list package pages", { error });
|
|
808
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
809
|
-
res.status(status).json(json);
|
|
810
|
-
}
|
|
811
|
-
},
|
|
812
|
-
);
|
|
813
|
-
|
|
814
|
-
app.get(`${API_PREFIX}/status`, async (_req, res) => {
|
|
815
|
-
try {
|
|
816
|
-
const status = await environmentStore.getStatus();
|
|
817
|
-
// Compose theme onto the status response so the SDK can read both
|
|
818
|
-
// in one round trip on app boot. ThemeStore is the source of truth;
|
|
819
|
-
// publisher.config.json is only a boot seed (see ThemeStore). The
|
|
820
|
-
// field is always present (an empty object means "no overrides
|
|
821
|
-
// yet"), so the OpenAPI shape and the runtime payload agree.
|
|
822
|
-
// The theme here is cosmetic, so during the brief window before storage
|
|
823
|
-
// initializes report no overrides rather than 500 an endpoint the
|
|
824
|
-
// control plane polls for serving state (themeStore.get() throws until
|
|
825
|
-
// storage is ready). GET /theme, the editor's authoritative load, is
|
|
826
|
-
// answered with 503 during that window instead.
|
|
827
|
-
const theme = environmentStore.storageManager.isInitialized()
|
|
828
|
-
? await themeStore.get()
|
|
829
|
-
: undefined;
|
|
830
|
-
res.status(200).json({ ...status, theme: theme ?? {} });
|
|
831
|
-
} catch (error) {
|
|
832
|
-
logger.error("Error getting status", { error });
|
|
833
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
834
|
-
res.status(status).json(json);
|
|
835
|
-
}
|
|
836
|
-
});
|
|
837
|
-
|
|
838
|
-
app.get(`${API_PREFIX}/theme`, async (_req, res) => {
|
|
839
|
-
try {
|
|
840
|
-
if (!environmentStore.storageManager.isInitialized()) {
|
|
841
|
-
// Storage is still initializing. Answer 503 (not 200 with an empty
|
|
842
|
-
// theme) so the Theme Editor's load stays in an error state and
|
|
843
|
-
// never adopts {} as the authoritative saved baseline, which would
|
|
844
|
-
// let a subsequent edit auto-save {} over the real persisted theme.
|
|
845
|
-
throw new ServiceUnavailableError(
|
|
846
|
-
"Theme storage is still initializing. Retry shortly.",
|
|
847
|
-
);
|
|
848
|
-
}
|
|
849
|
-
res.status(200).json(await themeController.getTheme());
|
|
850
|
-
} catch (error) {
|
|
851
|
-
logger.error("Error getting theme", { error });
|
|
852
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
853
|
-
res.status(status).json(json);
|
|
854
|
-
}
|
|
855
|
-
});
|
|
856
|
-
|
|
857
|
-
app.put(`${API_PREFIX}/theme`, async (req, res) => {
|
|
858
|
-
try {
|
|
859
|
-
res.status(200).json(await themeController.putTheme(req.body));
|
|
860
|
-
} catch (error) {
|
|
861
|
-
logger.error("Error saving theme", { error });
|
|
862
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
863
|
-
res.status(status).json(json);
|
|
864
|
-
}
|
|
865
|
-
});
|
|
866
|
-
|
|
867
|
-
app.delete(`${API_PREFIX}/theme`, async (_req, res) => {
|
|
868
|
-
try {
|
|
869
|
-
res.status(200).json(await themeController.resetTheme());
|
|
870
|
-
} catch (error) {
|
|
871
|
-
logger.error("Error resetting theme", { error });
|
|
872
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
873
|
-
res.status(status).json(json);
|
|
874
|
-
}
|
|
875
|
-
});
|
|
876
|
-
|
|
877
|
-
app.get(`${API_PREFIX}/watch-mode/status`, watchModeController.getWatchStatus);
|
|
878
|
-
app.post(`${API_PREFIX}/watch-mode/start`, watchModeController.startWatching);
|
|
879
|
-
app.post(`${API_PREFIX}/watch-mode/stop`, watchModeController.stopWatchMode);
|
|
880
|
-
|
|
881
|
-
// Live-reload Server-Sent Events stream for in-package HTML dashboards.
|
|
882
|
-
//
|
|
883
|
-
// This endpoint does NOT start watch mode on its own — that's an explicit
|
|
884
|
-
// opt-in (`--watch-env <name>` CLI flag, or `POST /api/v0/watch-mode/start`).
|
|
885
|
-
// Instead it reports whether watch mode is currently active for the requested
|
|
886
|
-
// env via a `mode` event and, if so, fans out file-change events to the
|
|
887
|
-
// browser. This avoids two failure modes:
|
|
888
|
-
// - Auto-starting from the request handler would let arbitrary fetches
|
|
889
|
-
// reach in to mutate global watch-mode state.
|
|
890
|
-
// - Without the `mode` event the client cannot tell "watch mode isn't
|
|
891
|
-
// running, don't expect reloads"; with it the client can choose to
|
|
892
|
-
// surface a small dev indicator (today: silent).
|
|
893
|
-
//
|
|
894
|
-
// Inputs are validated before any state lookup. Names that don't pass the
|
|
895
|
-
// canonical `assertSafePackageName` allowlist get 400 — preventing requests
|
|
896
|
-
// like `/api/v0/environments/%2e%2e/packages/x/events` from reaching the
|
|
897
|
-
// EnvironmentStore at all. We reuse the shared sanitizer rather than a local
|
|
898
|
-
// regex so the rules stay in one place (see path_safety.ts).
|
|
899
|
-
// Cap concurrent live-reload SSE connections so the endpoint can't be used to
|
|
900
|
-
// exhaust server sockets/memory with unbounded long-lived streams. Generous,
|
|
901
|
-
// since legitimate use is one stream per open dashboard tab.
|
|
902
|
-
const MAX_SSE_CONNECTIONS = 1000;
|
|
903
|
-
let sseConnectionCount = 0;
|
|
904
|
-
app.get(
|
|
905
|
-
`${API_PREFIX}/environments/:environmentName/packages/:packageName/events`,
|
|
906
|
-
async (req, res) => {
|
|
907
|
-
const env = req.params.environmentName;
|
|
908
|
-
const pkg = req.params.packageName;
|
|
909
|
-
try {
|
|
910
|
-
assertSafePackageName(env);
|
|
911
|
-
assertSafePackageName(pkg);
|
|
912
|
-
const environment = await environmentStore.getEnvironment(env, false);
|
|
913
|
-
await environment.getPackage(pkg, false); // 404 if missing
|
|
914
|
-
} catch (error) {
|
|
915
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
916
|
-
res.status(status).json(json);
|
|
917
|
-
return;
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
if (sseConnectionCount >= MAX_SSE_CONNECTIONS) {
|
|
921
|
-
res.status(503).json({
|
|
922
|
-
code: 503,
|
|
923
|
-
message: "Too many live-reload connections; try again shortly.",
|
|
924
|
-
});
|
|
925
|
-
return;
|
|
926
|
-
}
|
|
927
|
-
sseConnectionCount++;
|
|
928
|
-
|
|
929
|
-
res.set({
|
|
930
|
-
"content-type": "text/event-stream",
|
|
931
|
-
"cache-control": "no-cache",
|
|
932
|
-
connection: "keep-alive",
|
|
933
|
-
// Disable proxy/CDN buffering so events flush immediately.
|
|
934
|
-
"x-accel-buffering": "no",
|
|
935
|
-
});
|
|
936
|
-
res.flushHeaders();
|
|
937
|
-
|
|
938
|
-
const watching = watchModeController.isWatching(env);
|
|
939
|
-
res.write("event: hello\ndata: connected\n\n");
|
|
940
|
-
res.write(`event: mode\ndata: ${watching ? "enabled" : "disabled"}\n\n`);
|
|
941
|
-
|
|
942
|
-
const key = `${env}/${pkg}`;
|
|
943
|
-
const send = () => {
|
|
944
|
-
res.write("event: changed\ndata: changed\n\n");
|
|
945
|
-
};
|
|
946
|
-
watchModeController.events.on(key, send);
|
|
947
|
-
// Keep the connection alive through idle proxies (heartbeat every 25s).
|
|
948
|
-
const heartbeat = setInterval(() => {
|
|
949
|
-
res.write(": heartbeat\n\n");
|
|
950
|
-
}, 25000);
|
|
951
|
-
const cleanup = () => {
|
|
952
|
-
clearInterval(heartbeat);
|
|
953
|
-
watchModeController.events.off(key, send);
|
|
954
|
-
sseConnectionCount--;
|
|
955
|
-
};
|
|
956
|
-
// "close" covers both clean and abrupt disconnects on Node >= 20.
|
|
957
|
-
req.on("close", cleanup);
|
|
958
|
-
},
|
|
959
|
-
);
|
|
960
|
-
|
|
961
|
-
app.get(`${API_PREFIX}/environments`, async (_req, res) => {
|
|
962
|
-
try {
|
|
963
|
-
res.status(200).json(await environmentStore.listEnvironments());
|
|
964
|
-
} catch (error) {
|
|
965
|
-
logger.error(error);
|
|
966
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
967
|
-
res.status(status).json(json);
|
|
968
|
-
}
|
|
969
|
-
});
|
|
970
|
-
|
|
971
|
-
app.post(`${API_PREFIX}/environments`, async (req, res) => {
|
|
972
|
-
try {
|
|
973
|
-
logger.info("Adding environment", { body: req.body });
|
|
974
|
-
const environment = await environmentStore.addEnvironment(req.body);
|
|
975
|
-
res.status(200).json(await environment.serialize());
|
|
976
|
-
} catch (error) {
|
|
977
|
-
logger.error(error);
|
|
978
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
979
|
-
res.status(status).json(json);
|
|
980
|
-
}
|
|
981
|
-
});
|
|
982
|
-
|
|
983
|
-
app.get(`${API_PREFIX}/environments/:environmentName`, async (req, res) => {
|
|
984
|
-
try {
|
|
985
|
-
const environment = await environmentStore.getEnvironment(
|
|
986
|
-
req.params.environmentName,
|
|
987
|
-
req.query.reload === "true",
|
|
988
|
-
);
|
|
989
|
-
res.status(200).json(await environment.serialize());
|
|
990
|
-
} catch (error) {
|
|
991
|
-
logger.error(error);
|
|
992
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
993
|
-
res.status(status).json(json);
|
|
994
|
-
}
|
|
995
|
-
});
|
|
996
|
-
|
|
997
|
-
app.patch(`${API_PREFIX}/environments/:environmentName`, async (req, res) => {
|
|
998
|
-
try {
|
|
999
|
-
const environment = await environmentStore.updateEnvironment(req.body);
|
|
1000
|
-
res.status(200).json(await environment.serialize());
|
|
1001
|
-
} catch (error) {
|
|
1002
|
-
logger.error(error);
|
|
1003
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1004
|
-
res.status(status).json(json);
|
|
1005
|
-
}
|
|
1006
|
-
});
|
|
1007
|
-
|
|
1008
|
-
app.delete(`${API_PREFIX}/environments/:environmentName`, async (req, res) => {
|
|
1009
|
-
try {
|
|
1010
|
-
const environment = await environmentStore.deleteEnvironment(
|
|
1011
|
-
req.params.environmentName,
|
|
1012
|
-
);
|
|
1013
|
-
res.status(200).json(await environment?.serialize());
|
|
1014
|
-
} catch (error) {
|
|
1015
|
-
logger.error(error);
|
|
1016
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1017
|
-
res.status(status).json(json);
|
|
1018
|
-
}
|
|
1019
|
-
});
|
|
1020
|
-
|
|
1021
|
-
app.get(
|
|
1022
|
-
`${API_PREFIX}/environments/:environmentName/connections`,
|
|
1023
|
-
async (req, res) => {
|
|
1024
|
-
try {
|
|
1025
|
-
res.status(200).json(
|
|
1026
|
-
await connectionController.listConnections(
|
|
1027
|
-
req.params.environmentName,
|
|
1028
|
-
),
|
|
1029
|
-
);
|
|
1030
|
-
} catch (error) {
|
|
1031
|
-
logger.error(error);
|
|
1032
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1033
|
-
res.status(status).json(json);
|
|
1034
|
-
}
|
|
1035
|
-
},
|
|
1036
|
-
);
|
|
1037
|
-
|
|
1038
|
-
app.get(
|
|
1039
|
-
`${API_PREFIX}/environments/:environmentName/connections/:connectionName`,
|
|
1040
|
-
async (req, res) => {
|
|
1041
|
-
try {
|
|
1042
|
-
res.status(200).json(
|
|
1043
|
-
await connectionController.getConnection(
|
|
1044
|
-
req.params.environmentName,
|
|
1045
|
-
req.params.connectionName,
|
|
1046
|
-
),
|
|
1047
|
-
);
|
|
1048
|
-
} catch (error) {
|
|
1049
|
-
logger.error(error);
|
|
1050
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1051
|
-
res.status(status).json(json);
|
|
1052
|
-
}
|
|
1053
|
-
},
|
|
1054
|
-
);
|
|
1055
|
-
|
|
1056
|
-
app.post(
|
|
1057
|
-
`${API_PREFIX}/environments/:environmentName/connections/:connectionName`,
|
|
1058
|
-
async (req, res) => {
|
|
1059
|
-
try {
|
|
1060
|
-
const result = await connectionController.addConnection(
|
|
1061
|
-
req.params.environmentName,
|
|
1062
|
-
req.params.connectionName,
|
|
1063
|
-
req.body,
|
|
1064
|
-
);
|
|
1065
|
-
res.status(201).json(result);
|
|
1066
|
-
} catch (error) {
|
|
1067
|
-
logger.error("Error creating connection", { error });
|
|
1068
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1069
|
-
res.status(status).json(json);
|
|
1070
|
-
}
|
|
1071
|
-
},
|
|
1072
|
-
);
|
|
1073
|
-
|
|
1074
|
-
app.patch(
|
|
1075
|
-
`${API_PREFIX}/environments/:environmentName/connections/:connectionName`,
|
|
1076
|
-
async (req, res) => {
|
|
1077
|
-
try {
|
|
1078
|
-
const result = await connectionController.updateConnection(
|
|
1079
|
-
req.params.environmentName,
|
|
1080
|
-
req.params.connectionName,
|
|
1081
|
-
req.body,
|
|
1082
|
-
);
|
|
1083
|
-
res.status(200).json(result);
|
|
1084
|
-
} catch (error) {
|
|
1085
|
-
logger.error("Error updating connection", { error });
|
|
1086
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1087
|
-
res.status(status).json(json);
|
|
1088
|
-
}
|
|
1089
|
-
},
|
|
1090
|
-
);
|
|
1091
|
-
|
|
1092
|
-
app.delete(
|
|
1093
|
-
`${API_PREFIX}/environments/:environmentName/connections/:connectionName`,
|
|
1094
|
-
async (req, res) => {
|
|
1095
|
-
try {
|
|
1096
|
-
const result = await connectionController.deleteConnection(
|
|
1097
|
-
req.params.environmentName,
|
|
1098
|
-
req.params.connectionName,
|
|
1099
|
-
);
|
|
1100
|
-
res.status(200).json(result);
|
|
1101
|
-
} catch (error) {
|
|
1102
|
-
logger.error("Error deleting connection", { error });
|
|
1103
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1104
|
-
res.status(status).json(json);
|
|
1105
|
-
}
|
|
1106
|
-
},
|
|
1107
|
-
);
|
|
1108
|
-
|
|
1109
|
-
app.post(`${API_PREFIX}/connections/test`, async (req, res) => {
|
|
1110
|
-
try {
|
|
1111
|
-
const connectionStatus =
|
|
1112
|
-
await connectionController.testConnectionConfiguration(req.body);
|
|
1113
|
-
res.status(200).json(connectionStatus);
|
|
1114
|
-
} catch (error) {
|
|
1115
|
-
logger.error(error);
|
|
1116
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1117
|
-
res.status(status).json(json);
|
|
1118
|
-
}
|
|
1119
|
-
});
|
|
1120
|
-
|
|
1121
|
-
app.get(
|
|
1122
|
-
`${API_PREFIX}/environments/:environmentName/connections/:connectionName/schemas`,
|
|
1123
|
-
async (req, res) => {
|
|
1124
|
-
try {
|
|
1125
|
-
res.status(200).json(
|
|
1126
|
-
await connectionController.listSchemas(
|
|
1127
|
-
req.params.environmentName,
|
|
1128
|
-
req.params.connectionName,
|
|
1129
|
-
),
|
|
1130
|
-
);
|
|
1131
|
-
} catch (error) {
|
|
1132
|
-
logger.error(error);
|
|
1133
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1134
|
-
res.status(status).json(json);
|
|
1135
|
-
}
|
|
1136
|
-
},
|
|
1137
|
-
);
|
|
1138
|
-
|
|
1139
|
-
app.get(
|
|
1140
|
-
`${API_PREFIX}/environments/:environmentName/connections/:connectionName/schemas/:schemaName/tables`,
|
|
1141
|
-
async (req, res) => {
|
|
1142
|
-
logger.info("req.params", { params: req.params });
|
|
1143
|
-
try {
|
|
1144
|
-
const results = await connectionController.listTables(
|
|
1145
|
-
req.params.environmentName,
|
|
1146
|
-
req.params.connectionName,
|
|
1147
|
-
req.params.schemaName,
|
|
1148
|
-
normalizeQueryArray(req.query.tableNames),
|
|
1149
|
-
);
|
|
1150
|
-
res.status(200).json(results);
|
|
1151
|
-
} catch (error) {
|
|
1152
|
-
logger.error(error);
|
|
1153
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1154
|
-
res.status(status).json(json);
|
|
1155
|
-
}
|
|
1156
|
-
},
|
|
1157
|
-
);
|
|
1158
|
-
|
|
1159
|
-
app.get(
|
|
1160
|
-
`${API_PREFIX}/environments/:environmentName/connections/:connectionName/schemas/:schemaName/tables/:tablePath`,
|
|
1161
|
-
async (req, res) => {
|
|
1162
|
-
logger.info("req.params", { params: req.params });
|
|
1163
|
-
try {
|
|
1164
|
-
const results = await connectionController.getTable(
|
|
1165
|
-
req.params.environmentName,
|
|
1166
|
-
req.params.connectionName,
|
|
1167
|
-
req.params.schemaName,
|
|
1168
|
-
req.params.tablePath,
|
|
1169
|
-
);
|
|
1170
|
-
res.status(200).json(results);
|
|
1171
|
-
} catch (error) {
|
|
1172
|
-
logger.error(error);
|
|
1173
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1174
|
-
res.status(status).json(json);
|
|
1175
|
-
}
|
|
1176
|
-
},
|
|
1177
|
-
);
|
|
1178
|
-
|
|
1179
|
-
// ── Per-package connection data routes ─────────────────────────────
|
|
1180
|
-
// `duckdb` is per-package; non-`duckdb` names fall through to the
|
|
1181
|
-
// project's connection registry via the package's MalloyConfig wrapper.
|
|
1182
|
-
app.get(
|
|
1183
|
-
`${API_PREFIX}/environments/:environmentName/packages/:packageName/connections/:connectionName/schemas`,
|
|
1184
|
-
async (req, res) => {
|
|
1185
|
-
try {
|
|
1186
|
-
res.status(200).json(
|
|
1187
|
-
await connectionController.listSchemas(
|
|
1188
|
-
req.params.environmentName,
|
|
1189
|
-
req.params.connectionName,
|
|
1190
|
-
req.params.packageName,
|
|
1191
|
-
),
|
|
1192
|
-
);
|
|
1193
|
-
} catch (error) {
|
|
1194
|
-
logger.error(error);
|
|
1195
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1196
|
-
res.status(status).json(json);
|
|
1197
|
-
}
|
|
1198
|
-
},
|
|
1199
|
-
);
|
|
1200
|
-
|
|
1201
|
-
app.get(
|
|
1202
|
-
`${API_PREFIX}/environments/:environmentName/packages/:packageName/connections/:connectionName/schemas/:schemaName/tables`,
|
|
1203
|
-
async (req, res) => {
|
|
1204
|
-
try {
|
|
1205
|
-
res.status(200).json(
|
|
1206
|
-
await connectionController.listTables(
|
|
1207
|
-
req.params.environmentName,
|
|
1208
|
-
req.params.connectionName,
|
|
1209
|
-
req.params.schemaName,
|
|
1210
|
-
normalizeQueryArray(req.query.tableNames),
|
|
1211
|
-
req.params.packageName,
|
|
1212
|
-
),
|
|
1213
|
-
);
|
|
1214
|
-
} catch (error) {
|
|
1215
|
-
logger.error(error);
|
|
1216
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1217
|
-
res.status(status).json(json);
|
|
1218
|
-
}
|
|
1219
|
-
},
|
|
1220
|
-
);
|
|
1221
|
-
|
|
1222
|
-
app.get(
|
|
1223
|
-
`${API_PREFIX}/environments/:environmentName/packages/:packageName/connections/:connectionName/schemas/:schemaName/tables/:tablePath`,
|
|
1224
|
-
async (req, res) => {
|
|
1225
|
-
try {
|
|
1226
|
-
res.status(200).json(
|
|
1227
|
-
await connectionController.getTable(
|
|
1228
|
-
req.params.environmentName,
|
|
1229
|
-
req.params.connectionName,
|
|
1230
|
-
req.params.schemaName,
|
|
1231
|
-
req.params.tablePath,
|
|
1232
|
-
req.params.packageName,
|
|
1233
|
-
),
|
|
1234
|
-
);
|
|
1235
|
-
} catch (error) {
|
|
1236
|
-
logger.error(error);
|
|
1237
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1238
|
-
res.status(status).json(json);
|
|
1239
|
-
}
|
|
1240
|
-
},
|
|
1241
|
-
);
|
|
1242
|
-
|
|
1243
|
-
app.post(
|
|
1244
|
-
`${API_PREFIX}/environments/:environmentName/connections/:connectionName/sqlSource`,
|
|
1245
|
-
async (req, res) => {
|
|
1246
|
-
try {
|
|
1247
|
-
res.status(200).json(
|
|
1248
|
-
await connectionController.getConnectionSqlSource(
|
|
1249
|
-
req.params.environmentName,
|
|
1250
|
-
req.params.connectionName,
|
|
1251
|
-
req.body.sqlStatement as string,
|
|
1252
|
-
),
|
|
1253
|
-
);
|
|
1254
|
-
} catch (error) {
|
|
1255
|
-
logger.error(error);
|
|
1256
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1257
|
-
res.status(status).json(json);
|
|
1258
|
-
}
|
|
1259
|
-
},
|
|
1260
|
-
);
|
|
1261
|
-
|
|
1262
|
-
// Per-package versions
|
|
1263
|
-
app.post(
|
|
1264
|
-
`${API_PREFIX}/environments/:environmentName/packages/:packageName/connections/:connectionName/sqlSource`,
|
|
1265
|
-
async (req, res) => {
|
|
1266
|
-
try {
|
|
1267
|
-
res.status(200).json(
|
|
1268
|
-
await connectionController.getConnectionSqlSource(
|
|
1269
|
-
req.params.environmentName,
|
|
1270
|
-
req.params.connectionName,
|
|
1271
|
-
req.body.sqlStatement as string,
|
|
1272
|
-
req.params.packageName,
|
|
1273
|
-
),
|
|
1274
|
-
);
|
|
1275
|
-
} catch (error) {
|
|
1276
|
-
logger.error(error);
|
|
1277
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1278
|
-
res.status(status).json(json);
|
|
1279
|
-
}
|
|
1280
|
-
},
|
|
1281
|
-
);
|
|
1282
|
-
|
|
1283
|
-
// NOTE: The deprecated `GET …/connections/:connectionName/queryData`
|
|
1284
|
-
// and `GET …/packages/:packageName/connections/:connectionName/queryData`
|
|
1285
|
-
// routes were removed in the operational-guards changeset.
|
|
1286
|
-
// They had been marked `@deprecated` for several releases; clients
|
|
1287
|
-
// must now use the POST `…/sqlQuery` endpoints below, which take the
|
|
1288
|
-
// SQL in the request body so the row/byte caps and query-timeout
|
|
1289
|
-
// signals introduced in the OOM-mitigation work apply uniformly.
|
|
1290
|
-
// The legacy `GET /projects/…/queryData` twins under `server-old.ts`
|
|
1291
|
-
// remain in place for now.
|
|
1292
|
-
app.post(
|
|
1293
|
-
`${API_PREFIX}/environments/:environmentName/connections/:connectionName/sqlQuery`,
|
|
1294
|
-
queryConcurrency(),
|
|
1295
|
-
async (req, res) => {
|
|
1296
|
-
try {
|
|
1297
|
-
res.status(200).json(
|
|
1298
|
-
await connectionController.getConnectionQueryData(
|
|
1299
|
-
req.params.environmentName,
|
|
1300
|
-
req.params.connectionName,
|
|
1301
|
-
req.body.sqlStatement as string,
|
|
1302
|
-
req.body?.options as string,
|
|
1303
|
-
undefined,
|
|
1304
|
-
{
|
|
1305
|
-
queryMetadata: req.body?.queryMetadata,
|
|
1306
|
-
queryClass: req.body?.queryClass,
|
|
1307
|
-
},
|
|
1308
|
-
),
|
|
1309
|
-
);
|
|
1310
|
-
} catch (error) {
|
|
1311
|
-
logger.error(error);
|
|
1312
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1313
|
-
res.status(status).json(json);
|
|
1314
|
-
}
|
|
1315
|
-
},
|
|
1316
|
-
);
|
|
1317
|
-
|
|
1318
|
-
app.post(
|
|
1319
|
-
`${API_PREFIX}/environments/:environmentName/packages/:packageName/connections/:connectionName/sqlQuery`,
|
|
1320
|
-
queryConcurrency(),
|
|
1321
|
-
async (req, res) => {
|
|
1322
|
-
try {
|
|
1323
|
-
res.status(200).json(
|
|
1324
|
-
await connectionController.getConnectionQueryData(
|
|
1325
|
-
req.params.environmentName,
|
|
1326
|
-
req.params.connectionName,
|
|
1327
|
-
req.body.sqlStatement as string,
|
|
1328
|
-
req.body?.options as string,
|
|
1329
|
-
req.params.packageName,
|
|
1330
|
-
{
|
|
1331
|
-
queryMetadata: req.body?.queryMetadata,
|
|
1332
|
-
queryClass: req.body?.queryClass,
|
|
1333
|
-
},
|
|
1334
|
-
),
|
|
1335
|
-
);
|
|
1336
|
-
} catch (error) {
|
|
1337
|
-
logger.error(error);
|
|
1338
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1339
|
-
res.status(status).json(json);
|
|
1340
|
-
}
|
|
1341
|
-
},
|
|
1342
|
-
);
|
|
1343
|
-
|
|
1344
|
-
app.post(
|
|
1345
|
-
`${API_PREFIX}/environments/:environmentName/connections/:connectionName/sqlTemporaryTable`,
|
|
1346
|
-
queryConcurrency(),
|
|
1347
|
-
async (req, res) => {
|
|
1348
|
-
try {
|
|
1349
|
-
res.status(200).json(
|
|
1350
|
-
await connectionController.getConnectionTemporaryTable(
|
|
1351
|
-
req.params.environmentName,
|
|
1352
|
-
req.params.connectionName,
|
|
1353
|
-
req.body.sqlStatement as string,
|
|
1354
|
-
),
|
|
1355
|
-
);
|
|
1356
|
-
} catch (error) {
|
|
1357
|
-
logger.error(error);
|
|
1358
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1359
|
-
res.status(status).json(json);
|
|
1360
|
-
}
|
|
1361
|
-
},
|
|
1362
|
-
);
|
|
1363
|
-
|
|
1364
|
-
app.post(
|
|
1365
|
-
`${API_PREFIX}/environments/:environmentName/packages/:packageName/connections/:connectionName/sqlTemporaryTable`,
|
|
1366
|
-
queryConcurrency(),
|
|
1367
|
-
async (req, res) => {
|
|
1368
|
-
try {
|
|
1369
|
-
res.status(200).json(
|
|
1370
|
-
await connectionController.getConnectionTemporaryTable(
|
|
1371
|
-
req.params.environmentName,
|
|
1372
|
-
req.params.connectionName,
|
|
1373
|
-
req.body.sqlStatement as string,
|
|
1374
|
-
req.params.packageName,
|
|
1375
|
-
),
|
|
1376
|
-
);
|
|
1377
|
-
} catch (error) {
|
|
1378
|
-
logger.error(error);
|
|
1379
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1380
|
-
res.status(status).json(json);
|
|
1381
|
-
}
|
|
1382
|
-
},
|
|
1383
|
-
);
|
|
1384
|
-
|
|
1385
|
-
app.get(
|
|
1386
|
-
`${API_PREFIX}/environments/:environmentName/packages`,
|
|
1387
|
-
async (req, res) => {
|
|
1388
|
-
if (req.query.versionId) {
|
|
1389
|
-
setVersionIdError(res);
|
|
1390
|
-
return;
|
|
1391
|
-
}
|
|
1392
|
-
|
|
1393
|
-
try {
|
|
1394
|
-
res.status(200).json(
|
|
1395
|
-
await packageController.listPackages(req.params.environmentName),
|
|
1396
|
-
);
|
|
1397
|
-
} catch (error) {
|
|
1398
|
-
logger.error(error);
|
|
1399
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1400
|
-
res.status(status).json(json);
|
|
1401
|
-
}
|
|
1402
|
-
},
|
|
1403
|
-
);
|
|
1404
|
-
|
|
1405
|
-
app.post(
|
|
1406
|
-
`${API_PREFIX}/environments/:environmentName/packages`,
|
|
1407
|
-
async (req, res) => {
|
|
1408
|
-
try {
|
|
1409
|
-
const _package = await packageController.addPackage(
|
|
1410
|
-
req.params.environmentName,
|
|
1411
|
-
req.body,
|
|
1412
|
-
);
|
|
1413
|
-
res.status(200).json(_package?.getPackageMetadata());
|
|
1414
|
-
} catch (error) {
|
|
1415
|
-
logger.error(error);
|
|
1416
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1417
|
-
res.status(status).json(json);
|
|
1418
|
-
}
|
|
1419
|
-
},
|
|
1420
|
-
);
|
|
1421
|
-
|
|
1422
|
-
// Environment-scoped aggregate: every materialization across all packages in
|
|
1423
|
-
// the env, newest first. Nested under `/packages` as the collection-level
|
|
1424
|
-
// sibling of the per-package `/packages/:packageName/materializations` list.
|
|
1425
|
-
// MUST stay registered ahead of `/packages/:packageName` below so the literal
|
|
1426
|
-
// `materializations` segment wins the match; consequently `materializations` is
|
|
1427
|
-
// a reserved package name at this position (a package can never be named that).
|
|
1428
|
-
app.get(
|
|
1429
|
-
`${API_PREFIX}/environments/:environmentName/packages/materializations`,
|
|
1430
|
-
async (req, res) => {
|
|
1431
|
-
try {
|
|
1432
|
-
const limit = parseNonNegativeIntParam(req.query.limit);
|
|
1433
|
-
const offset = parseNonNegativeIntParam(req.query.offset);
|
|
1434
|
-
const builds =
|
|
1435
|
-
await materializationController.listEnvironmentMaterializations(
|
|
1436
|
-
req.params.environmentName,
|
|
1437
|
-
{ limit, offset },
|
|
1438
|
-
);
|
|
1439
|
-
res.status(200).json(builds);
|
|
1440
|
-
} catch (error) {
|
|
1441
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1442
|
-
res.status(status).json(json);
|
|
1443
|
-
}
|
|
1444
|
-
},
|
|
1445
|
-
);
|
|
1446
|
-
|
|
1447
|
-
app.get(
|
|
1448
|
-
`${API_PREFIX}/environments/:environmentName/packages/:packageName`,
|
|
1449
|
-
async (req, res) => {
|
|
1450
|
-
if (req.query.versionId) {
|
|
1451
|
-
setVersionIdError(res);
|
|
1452
|
-
return;
|
|
1453
|
-
}
|
|
1454
|
-
|
|
1455
|
-
try {
|
|
1456
|
-
res.status(200).json(
|
|
1457
|
-
await packageController.getPackage(
|
|
1458
|
-
req.params.environmentName,
|
|
1459
|
-
req.params.packageName,
|
|
1460
|
-
req.query.reload === "true",
|
|
1461
|
-
),
|
|
1462
|
-
);
|
|
1463
|
-
} catch (error) {
|
|
1464
|
-
logger.error(error);
|
|
1465
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1466
|
-
res.status(status).json(json);
|
|
1467
|
-
}
|
|
1468
|
-
},
|
|
1469
|
-
);
|
|
1470
|
-
|
|
1471
|
-
app.patch(
|
|
1472
|
-
`${API_PREFIX}/environments/:environmentName/packages/:packageName`,
|
|
1473
|
-
async (req, res) => {
|
|
1474
|
-
try {
|
|
1475
|
-
res.status(200).json(
|
|
1476
|
-
await packageController.updatePackage(
|
|
1477
|
-
req.params.environmentName,
|
|
1478
|
-
req.params.packageName,
|
|
1479
|
-
req.body,
|
|
1480
|
-
),
|
|
1481
|
-
);
|
|
1482
|
-
} catch (error) {
|
|
1483
|
-
logger.error(error);
|
|
1484
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1485
|
-
res.status(status).json(json);
|
|
1486
|
-
}
|
|
1487
|
-
},
|
|
1488
|
-
);
|
|
1489
|
-
|
|
1490
|
-
app.delete(
|
|
1491
|
-
`${API_PREFIX}/environments/:environmentName/packages/:packageName`,
|
|
1492
|
-
async (req, res) => {
|
|
1493
|
-
try {
|
|
1494
|
-
res.status(200).json(
|
|
1495
|
-
await packageController.deletePackage(
|
|
1496
|
-
req.params.environmentName,
|
|
1497
|
-
req.params.packageName,
|
|
1498
|
-
),
|
|
1499
|
-
);
|
|
1500
|
-
} catch (error) {
|
|
1501
|
-
logger.error(error);
|
|
1502
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1503
|
-
res.status(status).json(json);
|
|
1504
|
-
}
|
|
1505
|
-
},
|
|
1506
|
-
);
|
|
1507
|
-
|
|
1508
|
-
app.get(
|
|
1509
|
-
`${API_PREFIX}/environments/:environmentName/packages/:packageName/models`,
|
|
1510
|
-
async (req, res) => {
|
|
1511
|
-
if (req.query.versionId) {
|
|
1512
|
-
setVersionIdError(res);
|
|
1513
|
-
return;
|
|
1514
|
-
}
|
|
1515
|
-
|
|
1516
|
-
try {
|
|
1517
|
-
res.status(200).json(
|
|
1518
|
-
await modelController.listModels(
|
|
1519
|
-
req.params.environmentName,
|
|
1520
|
-
req.params.packageName,
|
|
1521
|
-
),
|
|
1522
|
-
);
|
|
1523
|
-
} catch (error) {
|
|
1524
|
-
logger.error(error);
|
|
1525
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1526
|
-
res.status(status).json(json);
|
|
1527
|
-
}
|
|
1528
|
-
},
|
|
1529
|
-
);
|
|
1530
|
-
|
|
1531
|
-
app.get(
|
|
1532
|
-
`${API_PREFIX}/environments/:environmentName/packages/:packageName/models/*?`,
|
|
1533
|
-
async (req, res) => {
|
|
1534
|
-
if (req.query.versionId) {
|
|
1535
|
-
setVersionIdError(res);
|
|
1536
|
-
return;
|
|
1537
|
-
}
|
|
1538
|
-
|
|
1539
|
-
try {
|
|
1540
|
-
// Express stores wildcard matches in params['0']
|
|
1541
|
-
const modelPath = (req.params as Record<string, string>)["0"];
|
|
1542
|
-
res.status(200).json(
|
|
1543
|
-
await modelController.getModel(
|
|
1544
|
-
req.params.environmentName,
|
|
1545
|
-
req.params.packageName,
|
|
1546
|
-
modelPath,
|
|
1547
|
-
),
|
|
1548
|
-
);
|
|
1549
|
-
} catch (error) {
|
|
1550
|
-
logger.error(error);
|
|
1551
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1552
|
-
res.status(status).json(json);
|
|
1553
|
-
}
|
|
1554
|
-
},
|
|
1555
|
-
);
|
|
1556
|
-
|
|
1557
|
-
app.get(
|
|
1558
|
-
`${API_PREFIX}/environments/:environmentName/packages/:packageName/notebooks`,
|
|
1559
|
-
async (req, res) => {
|
|
1560
|
-
if (req.query.versionId) {
|
|
1561
|
-
setVersionIdError(res);
|
|
1562
|
-
return;
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1565
|
-
try {
|
|
1566
|
-
res.status(200).json(
|
|
1567
|
-
await modelController.listNotebooks(
|
|
1568
|
-
req.params.environmentName,
|
|
1569
|
-
req.params.packageName,
|
|
1570
|
-
),
|
|
1571
|
-
);
|
|
1572
|
-
} catch (error) {
|
|
1573
|
-
logger.error(error);
|
|
1574
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1575
|
-
res.status(status).json(json);
|
|
1576
|
-
}
|
|
1577
|
-
},
|
|
1578
|
-
);
|
|
1579
|
-
|
|
1580
|
-
// Execute notebook cell route must come BEFORE the general get notebook route
|
|
1581
|
-
// to avoid the wildcard matching incorrectly
|
|
1582
|
-
app.get(
|
|
1583
|
-
`${API_PREFIX}/environments/:environmentName/packages/:packageName/notebooks/*/cells/:cellIndex`,
|
|
1584
|
-
queryConcurrency(),
|
|
1585
|
-
async (req, res) => {
|
|
1586
|
-
if (req.query.versionId) {
|
|
1587
|
-
setVersionIdError(res);
|
|
1588
|
-
return;
|
|
1589
|
-
}
|
|
1590
|
-
|
|
1591
|
-
try {
|
|
1592
|
-
const cellIndex = parseInt(req.params.cellIndex, 10);
|
|
1593
|
-
if (isNaN(cellIndex)) {
|
|
1594
|
-
res.status(400).json({
|
|
1595
|
-
error: "Invalid cell index",
|
|
1596
|
-
});
|
|
1597
|
-
return;
|
|
1598
|
-
}
|
|
1599
|
-
|
|
1600
|
-
// Express stores wildcard matches in params['0']
|
|
1601
|
-
const notebookPath = (req.params as Record<string, string>)["0"];
|
|
1602
|
-
|
|
1603
|
-
// Parse optional filter_params (JSON query string) and bypass_filters
|
|
1604
|
-
let filterParams: Record<string, string | string[]> | undefined;
|
|
1605
|
-
if (typeof req.query.filter_params === "string") {
|
|
1606
|
-
try {
|
|
1607
|
-
filterParams = JSON.parse(req.query.filter_params);
|
|
1608
|
-
} catch {
|
|
1609
|
-
res.status(400).json({
|
|
1610
|
-
error: "Invalid filter_params: must be valid JSON",
|
|
1611
|
-
});
|
|
1612
|
-
return;
|
|
1613
|
-
}
|
|
1614
|
-
}
|
|
1615
|
-
const bypassFilters =
|
|
1616
|
-
req.query.bypass_filters === "true" ? true : undefined;
|
|
1617
|
-
|
|
1618
|
-
let givens: Record<string, GivenValue> | undefined;
|
|
1619
|
-
if (typeof req.query.givens === "string") {
|
|
1620
|
-
try {
|
|
1621
|
-
givens = JSON.parse(req.query.givens);
|
|
1622
|
-
} catch {
|
|
1623
|
-
res.status(400).json({
|
|
1624
|
-
error: "Invalid givens: must be valid JSON",
|
|
1625
|
-
});
|
|
1626
|
-
return;
|
|
1627
|
-
}
|
|
1628
|
-
}
|
|
1629
|
-
|
|
1630
|
-
const result = await modelController.executeNotebookCell(
|
|
1631
|
-
req.params.environmentName,
|
|
1632
|
-
req.params.packageName,
|
|
1633
|
-
notebookPath,
|
|
1634
|
-
cellIndex,
|
|
1635
|
-
filterParams,
|
|
1636
|
-
bypassFilters,
|
|
1637
|
-
givens,
|
|
1638
|
-
);
|
|
1639
|
-
setFilterDeprecationHeaders(res, {
|
|
1640
|
-
filterParams,
|
|
1641
|
-
bypassFilters,
|
|
1642
|
-
});
|
|
1643
|
-
res.status(200).json(result);
|
|
1644
|
-
} catch (error) {
|
|
1645
|
-
logger.error(error);
|
|
1646
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1647
|
-
res.status(status).json(json);
|
|
1648
|
-
}
|
|
1649
|
-
},
|
|
1650
|
-
);
|
|
1651
|
-
|
|
1652
|
-
app.get(
|
|
1653
|
-
`${API_PREFIX}/environments/:environmentName/packages/:packageName/notebooks/*?`,
|
|
1654
|
-
async (req, res) => {
|
|
1655
|
-
if (req.query.versionId) {
|
|
1656
|
-
setVersionIdError(res);
|
|
1657
|
-
return;
|
|
1658
|
-
}
|
|
1659
|
-
|
|
1660
|
-
try {
|
|
1661
|
-
// Express stores wildcard matches in params['0']
|
|
1662
|
-
const notebookPath = (req.params as Record<string, string>)["0"];
|
|
1663
|
-
res.status(200).json(
|
|
1664
|
-
await modelController.getNotebook(
|
|
1665
|
-
req.params.environmentName,
|
|
1666
|
-
req.params.packageName,
|
|
1667
|
-
notebookPath,
|
|
1668
|
-
),
|
|
1669
|
-
);
|
|
1670
|
-
} catch (error) {
|
|
1671
|
-
logger.error(error);
|
|
1672
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1673
|
-
res.status(status).json(json);
|
|
1674
|
-
}
|
|
1675
|
-
},
|
|
1676
|
-
);
|
|
1677
|
-
|
|
1678
|
-
app.post(
|
|
1679
|
-
`${API_PREFIX}/environments/:environmentName/packages/:packageName/models/*?/query`,
|
|
1680
|
-
queryConcurrency(),
|
|
1681
|
-
async (req, res) => {
|
|
1682
|
-
if (req.body.versionId) {
|
|
1683
|
-
setVersionIdError(res);
|
|
1684
|
-
return;
|
|
1685
|
-
}
|
|
1686
|
-
|
|
1687
|
-
try {
|
|
1688
|
-
// Express stores wildcard matches in params['0']
|
|
1689
|
-
const modelPath = (req.params as Record<string, string>)["0"];
|
|
1690
|
-
const result = await queryController.getQuery(
|
|
1691
|
-
req.params.environmentName,
|
|
1692
|
-
req.params.packageName,
|
|
1693
|
-
modelPath,
|
|
1694
|
-
req.body.sourceName as string,
|
|
1695
|
-
req.body.queryName as string,
|
|
1696
|
-
req.body.query as string,
|
|
1697
|
-
req.body.compactJson === true,
|
|
1698
|
-
(req.body.filterParams ?? req.body.sourceFilters) as
|
|
1699
|
-
| Record<string, string | string[]>
|
|
1700
|
-
| undefined,
|
|
1701
|
-
req.body.bypassFilters === true ? true : undefined,
|
|
1702
|
-
req.body.givens as Record<string, GivenValue> | undefined,
|
|
1703
|
-
{
|
|
1704
|
-
queryMetadata: req.body?.queryMetadata,
|
|
1705
|
-
queryClass: req.body?.queryClass,
|
|
1706
|
-
versionId: req.body?.versionId as string | undefined,
|
|
1707
|
-
},
|
|
1708
|
-
);
|
|
1709
|
-
setFilterDeprecationHeaders(res, {
|
|
1710
|
-
filterParams: req.body.filterParams ?? req.body.sourceFilters,
|
|
1711
|
-
bypassFilters: req.body.bypassFilters === true ? true : undefined,
|
|
1712
|
-
});
|
|
1713
|
-
res.status(200).json(result);
|
|
1714
|
-
} catch (error) {
|
|
1715
|
-
logger.error(error);
|
|
1716
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1717
|
-
res.status(status).json(json);
|
|
1718
|
-
}
|
|
1719
|
-
},
|
|
1720
|
-
);
|
|
1721
|
-
|
|
1722
|
-
app.get(
|
|
1723
|
-
`${API_PREFIX}/environments/:environmentName/packages/:packageName/databases`,
|
|
1724
|
-
async (req, res) => {
|
|
1725
|
-
if (req.query.versionId) {
|
|
1726
|
-
setVersionIdError(res);
|
|
1727
|
-
return;
|
|
1728
|
-
}
|
|
1729
|
-
|
|
1730
|
-
try {
|
|
1731
|
-
res.status(200).json(
|
|
1732
|
-
await databaseController.listDatabases(
|
|
1733
|
-
req.params.environmentName,
|
|
1734
|
-
req.params.packageName,
|
|
1735
|
-
),
|
|
1736
|
-
);
|
|
1737
|
-
} catch (error) {
|
|
1738
|
-
logger.error(error);
|
|
1739
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1740
|
-
res.status(status).json(json);
|
|
1741
|
-
}
|
|
1742
|
-
},
|
|
1743
|
-
);
|
|
1744
|
-
|
|
1745
|
-
app.post(
|
|
1746
|
-
`${API_PREFIX}/environments/:environmentName/packages/:packageName/models/*?/compile`,
|
|
1747
|
-
async (req, res) => {
|
|
1748
|
-
try {
|
|
1749
|
-
// Express stores wildcard matches in params['0'], so nested model
|
|
1750
|
-
// paths (models in subdirectories) compile just like they query.
|
|
1751
|
-
const result = await compileController.compile(
|
|
1752
|
-
req.params.environmentName,
|
|
1753
|
-
req.params.packageName,
|
|
1754
|
-
(req.params as Record<string, string>)["0"],
|
|
1755
|
-
req.body.source,
|
|
1756
|
-
req.body.includeSql === true,
|
|
1757
|
-
req.body.givens as Record<string, GivenValue> | undefined,
|
|
1758
|
-
);
|
|
1759
|
-
res.status(200).json(result);
|
|
1760
|
-
} catch (error) {
|
|
1761
|
-
logger.error("Compilation error", { error });
|
|
1762
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1763
|
-
res.status(status).json(json);
|
|
1764
|
-
}
|
|
1765
|
-
},
|
|
1766
|
-
);
|
|
1767
|
-
|
|
1768
|
-
// ==================== MATERIALIZATION ROUTES ====================
|
|
1769
|
-
// The environment-scoped aggregate list (every materialization across all
|
|
1770
|
-
// packages) is registered up in the package routes as
|
|
1771
|
-
// `/packages/materializations`, ahead of `/packages/:packageName`, so the
|
|
1772
|
-
// literal wins the match — see that route for the ordering contract.
|
|
1773
|
-
|
|
1774
|
-
app.post(
|
|
1775
|
-
`${API_PREFIX}/environments/:environmentName/packages/:packageName/materializations`,
|
|
1776
|
-
async (req, res) => {
|
|
1777
|
-
try {
|
|
1778
|
-
const build = await materializationController.createMaterialization(
|
|
1779
|
-
req.params.environmentName,
|
|
1780
|
-
req.params.packageName,
|
|
1781
|
-
req.body || {},
|
|
1782
|
-
);
|
|
1783
|
-
res.status(201).json(build);
|
|
1784
|
-
} catch (error) {
|
|
1785
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1786
|
-
res.status(status).json(json);
|
|
1787
|
-
}
|
|
1788
|
-
},
|
|
1789
|
-
);
|
|
1790
|
-
|
|
1791
|
-
app.get(
|
|
1792
|
-
`${API_PREFIX}/environments/:environmentName/packages/:packageName/materializations`,
|
|
1793
|
-
async (req, res) => {
|
|
1794
|
-
try {
|
|
1795
|
-
const limit = parseNonNegativeIntParam(req.query.limit);
|
|
1796
|
-
const offset = parseNonNegativeIntParam(req.query.offset);
|
|
1797
|
-
const builds = await materializationController.listMaterializations(
|
|
1798
|
-
req.params.environmentName,
|
|
1799
|
-
req.params.packageName,
|
|
1800
|
-
{ limit, offset },
|
|
1801
|
-
);
|
|
1802
|
-
res.status(200).json(builds);
|
|
1803
|
-
} catch (error) {
|
|
1804
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1805
|
-
res.status(status).json(json);
|
|
1806
|
-
}
|
|
1807
|
-
},
|
|
1808
|
-
);
|
|
1809
|
-
|
|
1810
|
-
app.get(
|
|
1811
|
-
`${API_PREFIX}/environments/:environmentName/packages/:packageName/materializations/:materializationId`,
|
|
1812
|
-
async (req, res) => {
|
|
1813
|
-
try {
|
|
1814
|
-
const build = await materializationController.getMaterialization(
|
|
1815
|
-
req.params.environmentName,
|
|
1816
|
-
req.params.packageName,
|
|
1817
|
-
req.params.materializationId,
|
|
1818
|
-
);
|
|
1819
|
-
res.status(200).json(build);
|
|
1820
|
-
} catch (error) {
|
|
1821
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1822
|
-
res.status(status).json(json);
|
|
1823
|
-
}
|
|
1824
|
-
},
|
|
1825
|
-
);
|
|
1826
|
-
|
|
1827
|
-
app.post(
|
|
1828
|
-
`${API_PREFIX}/environments/:environmentName/packages/:packageName/materializations/:materializationId`,
|
|
1829
|
-
async (req, res) => {
|
|
1830
|
-
try {
|
|
1831
|
-
const action = req.query.action;
|
|
1832
|
-
if (action === "stop") {
|
|
1833
|
-
const build = await materializationController.stopMaterialization(
|
|
1834
|
-
req.params.environmentName,
|
|
1835
|
-
req.params.packageName,
|
|
1836
|
-
req.params.materializationId,
|
|
1837
|
-
);
|
|
1838
|
-
res.status(200).json(build);
|
|
1839
|
-
} else {
|
|
1840
|
-
throw new BadRequestError(
|
|
1841
|
-
`Unsupported action '${String(action ?? "")}'. Expected 'stop'.`,
|
|
1842
|
-
);
|
|
1843
|
-
}
|
|
1844
|
-
} catch (error) {
|
|
1845
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1846
|
-
res.status(status).json(json);
|
|
1847
|
-
}
|
|
1848
|
-
},
|
|
1849
|
-
);
|
|
1850
|
-
|
|
1851
|
-
app.delete(
|
|
1852
|
-
`${API_PREFIX}/environments/:environmentName/packages/:packageName/materializations/:materializationId`,
|
|
1853
|
-
async (req, res) => {
|
|
1854
|
-
try {
|
|
1855
|
-
await materializationController.deleteMaterialization(
|
|
1856
|
-
req.params.environmentName,
|
|
1857
|
-
req.params.packageName,
|
|
1858
|
-
req.params.materializationId,
|
|
1859
|
-
{ dropTables: req.query.dropTables === "true" },
|
|
1860
|
-
);
|
|
1861
|
-
res.status(204).send();
|
|
1862
|
-
} catch (error) {
|
|
1863
|
-
const { json, status } = internalErrorToHttpError(error as Error);
|
|
1864
|
-
res.status(status).json(json);
|
|
1865
|
-
}
|
|
1866
|
-
},
|
|
1867
|
-
);
|
|
1868
|
-
|
|
1869
|
-
// Register legacy `/projects/...` routes for backwards compatibility with
|
|
1870
|
-
// clients that haven't migrated to `/environments/...` yet. Must be added
|
|
1871
|
-
// before the SPA catch-all below.
|
|
1872
|
-
registerLegacyRoutes(app, {
|
|
1873
|
-
environmentStore,
|
|
1874
|
-
connectionController,
|
|
1875
|
-
modelController,
|
|
1876
|
-
packageController,
|
|
1877
|
-
databaseController,
|
|
1878
|
-
queryController,
|
|
1879
|
-
compileController,
|
|
1880
|
-
materializationController,
|
|
1881
|
-
});
|
|
1882
|
-
|
|
1883
|
-
// Modify the catch-all route to only serve index.html in production
|
|
1884
|
-
if (!isDevelopment) {
|
|
1885
|
-
const SPA_INDEX = path.resolve(ROOT, "index.html");
|
|
1886
|
-
app.get("*", (req, res) => {
|
|
1887
|
-
res.sendFile(SPA_INDEX, (err) => {
|
|
1888
|
-
if (!err) return;
|
|
1889
|
-
// The SPA bundle isn't built. This happens when running directly
|
|
1890
|
-
// from source (`bun run src/server.ts`) without first running
|
|
1891
|
-
// `bun run build:app`. Return a friendly placeholder rather than
|
|
1892
|
-
// a 500, and surface package URLs the user might be looking for.
|
|
1893
|
-
if (res.headersSent) return;
|
|
1894
|
-
res.status(404)
|
|
1895
|
-
.type("text/html")
|
|
1896
|
-
.send(
|
|
1897
|
-
`<!doctype html><meta charset="utf-8">
|
|
1898
|
-
<title>Publisher</title>
|
|
1899
|
-
<style>body{font:14px/1.4 -apple-system,system-ui,sans-serif;margin:40px;max-width:720px;color:#222}</style>
|
|
1900
|
-
<h1>Publisher is running, but the SPA bundle isn't built.</h1>
|
|
1901
|
-
<p>You requested <code>${req.path.replace(/[<>&]/g, (c) => ({ "<": "<", ">": ">", "&": "&" })[c] ?? c)}</code>.
|
|
1902
|
-
The Publisher API is available at <a href="/api/v0/environments">/api/v0/environments</a>.</p>
|
|
1903
|
-
<p>To get the Publisher web UI, run <code>cd packages/app && bunx vite build</code>
|
|
1904
|
-
or start the server with <code>NODE_ENV=development</code> after launching Vite on <code>:5173</code>.</p>
|
|
1905
|
-
<p>For in-package HTML data apps, browse to <code>/environments/<env>/packages/<pkg>/<file></code> directly.</p>`,
|
|
1906
|
-
);
|
|
1907
|
-
});
|
|
1908
|
-
});
|
|
1909
|
-
}
|
|
1910
|
-
|
|
1911
|
-
app.use(
|
|
1912
|
-
(
|
|
1913
|
-
err: Error,
|
|
1914
|
-
_req: express.Request,
|
|
1915
|
-
res: express.Response,
|
|
1916
|
-
_next: express.NextFunction,
|
|
1917
|
-
) => {
|
|
1918
|
-
logger.error("Unhandled error:", err);
|
|
1919
|
-
const { json, status } = internalErrorToHttpError(err);
|
|
1920
|
-
res.status(status).json(json);
|
|
1921
|
-
},
|
|
1922
|
-
);
|
|
1923
|
-
|
|
1924
|
-
// Eagerly construct the package-load worker pool so we fail fast at
|
|
1925
|
-
// boot if PACKAGE_LOAD_WORKERS is misconfigured (e.g. set to 0, the
|
|
1926
|
-
// removed in-process fallback). Surfacing the bad config here is much
|
|
1927
|
-
// friendlier than surfacing it on the first package load, which could
|
|
1928
|
-
// be hours after start.
|
|
1929
|
-
{
|
|
1930
|
-
const { getPackageLoadPool } = await import(
|
|
1931
|
-
"./package_load/package_load_pool"
|
|
1932
|
-
);
|
|
1933
|
-
getPackageLoadPool();
|
|
1934
|
-
}
|
|
1935
|
-
|
|
1936
|
-
const mainServer = http.createServer({ maxHeaderSize: 262144 }, app);
|
|
1937
|
-
|
|
1938
|
-
mainServer.timeout = 600000;
|
|
1939
|
-
mainServer.keepAliveTimeout = 600000;
|
|
1940
|
-
mainServer.headersTimeout = 600000;
|
|
1941
|
-
|
|
1942
|
-
mainServer.listen(PUBLISHER_PORT, PUBLISHER_HOST, async () => {
|
|
1943
|
-
const address = mainServer.address() as AddressInfo;
|
|
1944
|
-
logger.info(
|
|
1945
|
-
`Publisher server listening at http://${address.address}:${address.port}`,
|
|
1946
|
-
);
|
|
1947
|
-
if (isDevelopment) {
|
|
1948
|
-
logger.info(
|
|
1949
|
-
"Running in development mode - proxying to React dev server at http://localhost:5173",
|
|
1950
|
-
);
|
|
1951
|
-
}
|
|
1952
|
-
// If `--watch-env <name>` (or PUBLISHER_WATCH=name1,name2) was passed,
|
|
1953
|
-
// wait for env initialization to settle, then start watch mode for each
|
|
1954
|
-
// named env. Packages in those envs are already mounted in-place via the
|
|
1955
|
-
// EnvironmentStore in-place path (see `loadEnvironmentIntoDisk`), so the
|
|
1956
|
-
// chokidar watcher will see edits to your source repo and fan them out
|
|
1957
|
-
// to any connected SSE clients.
|
|
1958
|
-
const watchEnvList = (process.env.PUBLISHER_WATCH || "")
|
|
1959
|
-
.split(",")
|
|
1960
|
-
.map((s) => s.trim())
|
|
1961
|
-
.filter((s) => s.length > 0);
|
|
1962
|
-
if (watchEnvList.length > 0) {
|
|
1963
|
-
// The watcher tracks exactly one env at a time (`WatchModeController`
|
|
1964
|
-
// holds a single chokidar instance). Every env in PUBLISHER_WATCH is
|
|
1965
|
-
// still mounted in place (live source) by the EnvironmentStore, but only
|
|
1966
|
-
// the first is watched, so the others do not auto-reload.
|
|
1967
|
-
if (watchEnvList.length > 1) {
|
|
1968
|
-
logger.warn(
|
|
1969
|
-
`Multiple watch environments requested (${watchEnvList.join(
|
|
1970
|
-
", ",
|
|
1971
|
-
)}); watch mode auto-reloads one at a time. Watching "${
|
|
1972
|
-
watchEnvList[0]
|
|
1973
|
-
}". 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.`,
|
|
1974
|
-
);
|
|
1975
|
-
}
|
|
1976
|
-
const envName = watchEnvList[0];
|
|
1977
|
-
try {
|
|
1978
|
-
await environmentStore.finishedInitialization;
|
|
1979
|
-
await watchModeController.ensureWatching(envName);
|
|
1980
|
-
logger.info(
|
|
1981
|
-
`Watch mode active for environment "${envName}" (in-place mount, source-edit live reload).`,
|
|
1982
|
-
);
|
|
1983
|
-
} catch (error) {
|
|
1984
|
-
logger.error(
|
|
1985
|
-
`Failed to start watch mode for environment "${envName}"`,
|
|
1986
|
-
{ error },
|
|
1987
|
-
);
|
|
1988
|
-
}
|
|
1989
|
-
}
|
|
1990
|
-
});
|
|
1991
|
-
const mcpServer = mcpApp.listen(
|
|
1992
|
-
MCP_PORT,
|
|
1993
|
-
PUBLISHER_HOST,
|
|
1994
|
-
function (this: import("net").Server) {
|
|
1995
|
-
// Read back rather than reusing MCP_PORT, which is only what was requested.
|
|
1996
|
-
// `--mcp_port 0` asks for any free port, and under bun a non-numeric value
|
|
1997
|
-
// binds an ephemeral one too, so the requested value can be 0 or NaN while
|
|
1998
|
-
// a real port is listening. The listening line uses it as well, which is
|
|
1999
|
-
// why it no longer reads `http://127.0.0.1:0`.
|
|
2000
|
-
const boundPort = resolveBoundPort(this.address(), MCP_PORT);
|
|
2001
|
-
// The BIND address, bracketed when it is an IPv6 literal so the URL
|
|
2002
|
-
// parses. Deliberately not resolveClientHost: create-malloy-package's
|
|
2003
|
-
// README and AGENTS template both tell readers these two listening lines
|
|
2004
|
-
// are "the addresses it really bound", and use them to catch a mistyped
|
|
2005
|
-
// --hostt that silently falls back to 0.0.0.0. Mapping the wildcard to
|
|
2006
|
-
// loopback here would confirm the mistake instead of revealing it. The
|
|
2007
|
-
// dialable form belongs in .mcp.json and in the advice, not here.
|
|
2008
|
-
const bound = this.address();
|
|
2009
|
-
const boundHost =
|
|
2010
|
-
typeof bound === "object" && bound ? bound.address : PUBLISHER_HOST;
|
|
2011
|
-
logger.info(
|
|
2012
|
-
`MCP server listening at http://${boundHost.includes(":") ? `[${boundHost}]` : boundHost}:${boundPort}`,
|
|
2013
|
-
);
|
|
2014
|
-
// Checked before process.cwd(), which can throw: someone who turned the
|
|
2015
|
-
// feature off should not get a warning about it.
|
|
2016
|
-
if (MCP_CONFIG_ENABLED) {
|
|
2017
|
-
// ensureMcpConfig cannot throw, but its arguments can: process.cwd()
|
|
2018
|
-
// raises ENOENT once the working directory has been removed. A throw
|
|
2019
|
-
// here is an uncaught exception inside a listen callback, which would
|
|
2020
|
-
// kill a server that has already bound both ports. Everything the call
|
|
2021
|
-
// needs is built inside the try for that reason, including the
|
|
2022
|
-
// endpoint: it is the newest and least-exercised code in this block.
|
|
2023
|
-
try {
|
|
2024
|
-
// The host an agent should dial, which is NOT `localhost`: that name
|
|
2025
|
-
// resolves to both loopback families while the server binds only one,
|
|
2026
|
-
// so another local process can hold the same port on the other family
|
|
2027
|
-
// and receive the agent's traffic instead.
|
|
2028
|
-
const endpoint = mcpEndpoint(
|
|
2029
|
-
resolveClientHost(this.address(), PUBLISHER_HOST),
|
|
2030
|
-
boundPort,
|
|
2031
|
-
);
|
|
2032
|
-
// cwd, not server_root: the file is for whoever opens an agent here.
|
|
2033
|
-
logMcpConfigOutcome(
|
|
2034
|
-
ensureMcpConfig({
|
|
2035
|
-
dir: process.cwd(),
|
|
2036
|
-
endpoint,
|
|
2037
|
-
requestedPort: MCP_PORT,
|
|
2038
|
-
boundPort,
|
|
2039
|
-
}),
|
|
2040
|
-
);
|
|
2041
|
-
} catch (error) {
|
|
2042
|
-
logger.info(
|
|
2043
|
-
`Could not set up ${MCP_CONFIG_FILENAME} (${error instanceof Error ? error.message : String(error)}). To connect an agent, run: ${addCommand(mcpEndpoint(resolveClientHost(this.address(), PUBLISHER_HOST), boundPort))}`,
|
|
2044
|
-
);
|
|
2045
|
-
}
|
|
2046
|
-
}
|
|
2047
|
-
},
|
|
2048
|
-
);
|
|
2049
|
-
|
|
2050
|
-
mcpServer.timeout = 600000;
|
|
2051
|
-
mcpServer.keepAliveTimeout = 600000;
|
|
2052
|
-
mcpServer.headersTimeout = 600000;
|
|
2053
|
-
|
|
2054
|
-
registerSignalHandlers(
|
|
2055
|
-
mainServer,
|
|
2056
|
-
mcpServer,
|
|
2057
|
-
SHUTDOWN_DRAIN_DURATION_SECONDS,
|
|
2058
|
-
SHUTDOWN_GRACEFUL_CLOSE_TIMEOUT_SECONDS,
|
|
2059
|
-
);
|