mirror of
https://gh-proxy.org/https://github.com/klinsmaya/open-connector.git
synced 2026-11-08 01:16:52 +08:00
perf(binary): halve the single-file executable's resident memory (#490)
The Bun single-file executable idled at 378 MiB RSS on macOS (306 MiB
with `OOMOL_CONNECT_CATALOG_LAZY_SCHEMAS=1`) because it parsed all 1463
provider executors at startup, read every embedded catalog file into
memory, evaluated optional dependencies it never used, and kept the
module-graph pages pinned for the process lifetime. This branch removes
each of those costs in its own commit, measured before and after, and
lands the lazy-schema executable at 100 to 167 MiB depending on the
platform, with the default mode roughly halved. No API body, ETag,
status code, error text, SDK/CLI wire shape or Cloudflare Workers
behaviour changes, and no new environment variable is added.
Resident memory after `GET /health` (medians of 3 to 5 alternating
rounds, same harness for both binaries, `main` = d11f0e5f):
| platform | measure | default before | default after | lazy before |
lazy after |
|---|---|---|---|---|---|
| macOS darwin-arm64, native | `ps` RSS | 378.0 MiB | 293.1 MiB | 306.5
MiB | 166.8 MiB |
| Debian 13 linux-x64, native | VmRSS | 343.6 MB | 183.8 MB | 269.8 MB |
99.5 MB |
| Linux arm64, Docker | VmRSS | 325.2 MB | 179.0 MB | 283.1 MB | 109.9
MB |
| Windows 11 x64, native | WorkingSet | 345.1 MiB | 225.5 MiB | 259.5
MiB | 113.6 MiB |
Startup to the first `/health` also drops from about 520 ms to 212 ms on
macOS and from 823 ms to 379 ms on linux-x64. On Linux the peak (VmHWM)
falls as well (370.6 to 305.1 MB default, 301.9 to 197.9 MB lazy), so a
cgroup limit sees the improvement too. macOS `ps` counts 55 to 66 MiB of
freed-but-resident allocator pages and the executable's clean `__TEXT`
pages, which is why the default mode stays above 256 MiB there while the
same binary sits at 184 MB on Linux.
What each commit does, with the macOS delta it was measured with:
- `perf(binary)`: _scripts/build-binary.ts_ passes `splitting: true` to
`Bun.build`, so the 1463 provider executors reached through `import()`
in _src/providers/registry.generated.ts_ become embedded chunks parsed
on first use instead of one 30 MB entry module parsed at startup.
Default 378.7 to 324.2 MiB, lazy 306.6 to 240.8 MiB. The smoke script
now POSTs a no-auth `quickchart` action so every target in the
build-binary matrix proves a chunk loads at runtime.
- `perf(catalog)`: `npm run generate:catalog` also writes
_catalog/apps-index.json_ (the catalog without schemas, 4.9 MB, written
atomically) and the executable embeds it. In lazy mode startup reads
that one file instead of all 1463 provider files, which on the
executable kept 55 MB of embedded pages resident. Lazy 240.1 to 182.5
MiB, default untouched. The index rides on the existing lazy flag, a
missing index falls back to the old scan with a warning, and a stale
index refuses to start with a message naming the fix.
_scripts/ensure-generated.ts_ regenerates when the index is missing or
older than the provider files, so existing checkouts regenerate once.
- `perf(server)`: `@aws-sdk/client-s3`, `pg`, `jose`,
`@modelcontextprotocol/server` with `zod`, and
`@scalar/hono-api-reference` load only when their feature is configured
or first requested, through a one-slot `PromiseCache` (the former
`IsolatePromiseCache`, moved to _src/core/promise-cache.ts_).
`createRuntimeJwtVerifier()` becomes async. Workers keep evaluating them
at app creation through `preloadOptionalServerModules()`, and a new
guard test fails if a static import pins any of them back into the
startup graph. Default 324.9 to 305.0 MiB, lazy 181.6 to 167.1 MiB.
- `perf(catalog)`: `providerSummariesJson` is kept as UTF-8 bytes
instead of a JS string (the catalog contains code points above U+00FF,
so JavaScriptCore stored the 6.5 MB body as 15 MB of UTF-16). The ETag
is still computed from the string, so `W/"62be35-aa331d99"` and the body
bytes are unchanged. Retained JSC heap 26.4 to 17.2 MiB in lazy mode.
- `perf(catalog)`: default mode reads provider files four at a time
instead of `Promise.all` over all 1463, which bounded the parse peak the
allocator kept resident. Default 305.9 to 292.8 MiB, startup +9 ms.
- `perf(server)`: `main()` in _src/server/index.ts_ resolves once
`serve()` has requested the listener and a promise chain owns shutdown,
so the entry module finishes evaluating. Bun issues its one-time
`madvise(MADV_DONTNEED)` over the embedded module graph only at that
point, and the top-level `await main()` had kept it from ever firing.
Linux only: 254.7 to 180.2 MB default and 175.4 to 110.3 MB lazy on the
same binary, RssFile 116 to 41 MB, macOS unchanged.
- `build(binary)` and `chore(binary)`: the smoke script reads the lazy
flag with the server's truthiness rule, asserts the second instance on a
port conflict exits with an address-in-use error, and no longer
hardcodes the MCP tool count. `assertBuildInputs()` refuses to build
when _dist/web/catalog_ (the Workers static chunks written by
_scripts/copy-catalog-assets.ts_) exists, because `compile.assets`
embeds _dist/web_ wholesale and that directory silently added 31 MB to a
binary during this work. Lint ignores are anchored to `/catalog` and the
documented executable size range moves to 150 to 175 MiB (every target
grows by the 4.9 MB index).
Two behaviours of the executable change without a flag, both to match
`node src/server/index.ts`, and both are documented in
_docs/single-binary.md_: a listen failure such as `EADDRINUSE` now exits
with code 1 instead of hanging inside Bun's entry-module wait, and an
uncaught exception thrown while serving now ends the process with exit
code 1 instead of being printed and survived. The other un-flagged
deltas are internal timing: the first use of a provider parses its chunk
(2 to 20 ms once per provider), the first `/mcp` request loads the MCP
module (about 20 to 30 ms once per process), and default-mode startup
pays about 9 ms for the batched reads.
`BUN_FEATURE_FLAG_DISABLE_STANDALONE_MADVISE=1` is Bun's own escape
hatch for the page-release hint.
Not done here, on purpose: pre-compressing `/api/providers` or streaming
`/api/actions` (net zero for the startup metric and both need a flag),
minifying the bundle (about 2 MiB after splitting), and packing the
catalog into a single embedded blob (Bun still touches about one page
per embedded file, roughly 18 MB on macOS and 35 MB on Linux).
Verified on every commit with `npm run fix-check`, `npm test` (184
files, 1789 tests), a darwin-arm64 build and `scripts/smoke-binary.ts`
in default and lazy mode. The final binaries were additionally smoked
natively on Debian 13 x64, Windows 11 x64 and macOS, and `GET
/api/providers` returns the same ETag and byte-identical body on every
platform and mode as the `main` build.
---------
Signed-off-by: Kevin Cui <[email protected]>
This commit is contained in:
@@ -16,7 +16,9 @@ name: Build Binaries
|
||||
# smoke-tests its own linux-x64 output three times: against SQLite, against
|
||||
# SQLite with the lazy catalog schemas, and against the PostgreSQL service
|
||||
# container (service containers are Linux-only, which is one reason the smoke
|
||||
# matrix is a separate job).
|
||||
# matrix is a separate job). Every smoke run also calls one no_auth action so
|
||||
# that an embedded provider chunk is imported and executed on each of the six
|
||||
# targets (the executable is built with Bun code splitting).
|
||||
#
|
||||
# The smoke matrix mixes runner vendors because Blacksmith offers no Windows
|
||||
# ARM64 and no macOS Intel runners; those two targets run on GitHub-hosted
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"ignorePatterns": ["node_modules", "dist", "coverage", "catalog/apps", "deploy/helm/open-connector/templates"],
|
||||
"ignorePatterns": ["node_modules", "dist", "coverage", "/catalog", "deploy/helm/open-connector/templates"],
|
||||
"printWidth": 120,
|
||||
"trailingComma": "all",
|
||||
"sortImports": {
|
||||
|
||||
+1
-1
@@ -14,5 +14,5 @@
|
||||
"builtin": true
|
||||
},
|
||||
"globals": {},
|
||||
"ignorePatterns": ["dist", "node_modules", "catalog/apps", "deploy/helm/open-connector/templates"]
|
||||
"ignorePatterns": ["dist", "node_modules", "/catalog", "deploy/helm/open-connector/templates"]
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# Catalog Format
|
||||
|
||||
Provider definitions in `src/providers/<service>/definition.ts` are the source of truth.
|
||||
Catalog JSON in `catalog/apps` is generated local runtime data and used by the server at startup.
|
||||
Generated registry and catalog files are ignored by git. `npm install`, `npm run dev`, and
|
||||
`npm start` create them when they are missing or stale.
|
||||
`npm run generate:catalog` writes `catalog/apps/<service>.json` and the startup index
|
||||
`catalog/apps-index.json` together; both are generated local runtime data used by the server at
|
||||
startup, and neither must be hand-edited. Generated registry and catalog files are ignored by git.
|
||||
`npm install`, `npm run dev`, and `npm start` create them when they are missing or stale.
|
||||
|
||||
Provider executors live in `src/providers/<service>/executors.ts` and are loaded only when an action is executed.
|
||||
|
||||
|
||||
@@ -250,11 +250,19 @@ back to the default. Set it higher when one server serves many providers repeate
|
||||
the tightest machines. Responses are byte-identical to the default mode, so this is a
|
||||
memory-for-reads trade and not an API change.
|
||||
|
||||
`npm run generate:catalog` also writes `catalog/apps-index.json`, the catalog without its schemas.
|
||||
With lazy schemas on, the server reads that one file at startup instead of every provider file (on
|
||||
the single-file executable this is what keeps the embedded provider files from being paged in);
|
||||
schemas still come from the provider files on access. An index that does not describe the provider
|
||||
files next to it is refused at startup, and a missing index falls back to reading the provider files
|
||||
with a warning; both are fixed by regenerating the catalog.
|
||||
|
||||
What changes operationally:
|
||||
|
||||
- The catalog directory must stay readable while the server runs. Replacing or deleting it under a
|
||||
running server makes later schema reads fail. The single-file executable reads the catalog from
|
||||
its own embedded filesystem, so it needs nothing extra.
|
||||
- Keep `catalog/apps-index.json` next to `catalog/apps/` when copying a generated catalog.
|
||||
- Reading or running one action reads one provider file. `GET /api/actions`, which serializes every
|
||||
action, and action search across many services re-read files as they go.
|
||||
- This is a Node and single-binary setting. Cloudflare Workers load the catalog from asset chunks
|
||||
|
||||
+14
-1
@@ -6,6 +6,11 @@ provider catalog, the database migrations, and the built web console, so it runs
|
||||
without a Node.js installation, a checkout, or `node_modules`. Nothing is extracted to disk at
|
||||
runtime.
|
||||
|
||||
Provider code is split into chunks inside the executable, and each provider's chunk is loaded the
|
||||
first time that provider is used, so the process only parses the server itself at startup. This is
|
||||
what keeps the binary's resident memory well under what a single bundle needs, and it is invisible
|
||||
to clients: the same routes, responses, and ETags.
|
||||
|
||||
## Build
|
||||
|
||||
Building requires Node.js for the npm scripts and the Bun version pinned in `.bun-version`; the
|
||||
@@ -30,7 +35,7 @@ dist/open-connector-windows-x64.exe
|
||||
dist/open-connector-windows-arm64.exe
|
||||
```
|
||||
|
||||
Each file is roughly 145 to 170 MiB. To build a subset, pass one or more target names after `--`:
|
||||
Each file is roughly 150 to 175 MiB. To build a subset, pass one or more target names after `--`:
|
||||
|
||||
```bash
|
||||
npm run build:binary -- linux-x64 darwin-arm64
|
||||
@@ -107,3 +112,11 @@ prints a notice that SQLite migrations are applied automatically and exits.
|
||||
- On Windows, stopping the process from a process manager or `taskkill` terminates it immediately;
|
||||
the graceful shutdown hook that closes the HTTP server and the database on Linux and macOS does
|
||||
not run. This matches `node src/server/index.ts` on Windows.
|
||||
- The binary exits with code 1 when it cannot listen (for example when `PORT` is already in use)
|
||||
and when an uncaught exception is thrown while serving, as `node src/server/index.ts` always
|
||||
has. Earlier binaries printed the error and kept running; after a listen failure they hung
|
||||
without a server.
|
||||
- On Linux, Bun releases the pages of the embedded bundle and catalog once startup has finished,
|
||||
so resident memory after startup is lower than the startup peak. Pages touched later, such as a
|
||||
provider's first use or an on-demand schema read, fault back in, bounded by the size of the
|
||||
embedded section. `BUN_FEATURE_FLAG_DISABLE_STANDALONE_MADVISE=1` disables the release.
|
||||
|
||||
+38
-8
@@ -1,6 +1,7 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
import { catalogIndexFileName } from "../src/catalog-index.ts";
|
||||
|
||||
// Build the single-file server executables with Bun.
|
||||
//
|
||||
@@ -40,12 +41,18 @@ function assertPinnedBunVersion(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** The embedded directories are generated; refuse to build a binary that would ship an empty catalog or console. */
|
||||
/**
|
||||
* The embedded directories are generated; refuse to build a binary that would ship an empty catalog or console, or
|
||||
* the Cloudflare Workers catalog chunks on top of the catalog it already embeds.
|
||||
*/
|
||||
function assertBuildInputs(): void {
|
||||
const problems: string[] = [];
|
||||
if (listFiles(join(rootDir, "catalog/apps"), ".json").length === 0) {
|
||||
problems.push("catalog/apps contains no .json files");
|
||||
}
|
||||
if (!existsSync(join(rootDir, "catalog", catalogIndexFileName))) {
|
||||
problems.push(`catalog/${catalogIndexFileName} is missing`);
|
||||
}
|
||||
if (listFiles(join(rootDir, "migrations"), ".sql").length === 0) {
|
||||
problems.push("migrations contains no .sql files");
|
||||
}
|
||||
@@ -60,6 +67,13 @@ function assertBuildInputs(): void {
|
||||
`Build inputs are missing:\n - ${problems.join("\n - ")}\nRun "npm run build:binary" so the catalog and the web console are generated before the binary is built.`,
|
||||
);
|
||||
}
|
||||
// scripts/copy-catalog-assets.ts writes the Workers catalog chunks under dist/web, and compile.assets embeds
|
||||
// dist/web wholesale, so a leftover copy would ship the whole catalog a second time inside the executable.
|
||||
if (existsSync(join(rootDir, "dist/web/catalog"))) {
|
||||
fail(
|
||||
'dist/web/catalog exists (Cloudflare Workers catalog chunks written by scripts/copy-catalog-assets.ts) and would be embedded into the binary. Remove that directory or rebuild the console with "npm run build:web".',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function listFiles(directory: string, extension: string): string[] {
|
||||
@@ -92,10 +106,14 @@ async function buildTarget(binaryTarget: BinaryTarget): Promise<void> {
|
||||
rmSync(`${outfile}.exe`, { force: true });
|
||||
|
||||
const result = await runBunBuild(binaryTarget.target, outfile);
|
||||
const [artifact] = result.outputs;
|
||||
if (!artifact) {
|
||||
fail(`Bun.build produced no output for ${binaryTarget.name}.`);
|
||||
// Code splitting keeps every chunk inside the executable, so exactly one output is expected. A future Bun that
|
||||
// reports chunks as outputs must not have signDarwinBinary sign, or the size log measure, a chunk.
|
||||
if (result.outputs.length !== 1) {
|
||||
fail(
|
||||
`Bun.build produced ${result.outputs.length} outputs for ${binaryTarget.name}; expected the single executable.`,
|
||||
);
|
||||
}
|
||||
const [artifact] = result.outputs;
|
||||
|
||||
if (binaryTarget.target.startsWith("bun-darwin-")) {
|
||||
signDarwinBinary(artifact.path);
|
||||
@@ -112,6 +130,12 @@ async function runBunBuild(target: Bun.Build.CompileTarget, outfile: string): Pr
|
||||
entrypoints: [join(rootDir, "src/server/index.ts")],
|
||||
target: "bun",
|
||||
format: "esm",
|
||||
// Every provider executor is reached only through the `import()` in src/providers/registry.generated.ts. With
|
||||
// splitting those imports become chunks embedded next to the entry module (/$bunfs/root/chunk-<hash>.js, or
|
||||
// B:/~BUN/root/chunk-<hash>.js on Windows; Bun rewrites the paths per target) that JavaScriptCore reads, parses
|
||||
// and keeps resident only when a provider is first used, instead of one ~30 MB entry module parsed at startup.
|
||||
// compile.outfile still produces exactly one file; no chunk files are written to dist/.
|
||||
splitting: true,
|
||||
// ali-oss depends on urllib, which lazily `require("proxy-agent")`, an optional peer dependency that is not
|
||||
// installed here. The bundler cannot resolve it, so it stays a runtime require that is never reached.
|
||||
external: ["proxy-agent"],
|
||||
@@ -122,10 +146,16 @@ async function runBunBuild(target: Bun.Build.CompileTarget, outfile: string): Pr
|
||||
compile: {
|
||||
target,
|
||||
outfile,
|
||||
// Each directory is embedded under its basename next to the bundle: migrations/, apps/ and web/.
|
||||
// catalog/apps rather than catalog/: an interrupted `npm run generate:catalog` leaves catalog/.apps-<pid>-<ts>
|
||||
// temp directories behind, and they must never end up inside a release.
|
||||
assets: [join(rootDir, "migrations"), join(rootDir, "catalog/apps"), join(rootDir, "dist/web")],
|
||||
// Each directory is embedded under its basename next to the bundle (migrations/, apps/ and web/), and the
|
||||
// catalog index file under its basename beside them (apps-index.json). catalog/apps rather than catalog/: an
|
||||
// interrupted `npm run generate:catalog` leaves catalog/.apps-<pid>-<ts> temp files behind, and they must
|
||||
// never end up inside a release.
|
||||
assets: [
|
||||
join(rootDir, "migrations"),
|
||||
join(rootDir, "catalog/apps"),
|
||||
join(rootDir, "catalog", catalogIndexFileName),
|
||||
join(rootDir, "dist/web"),
|
||||
],
|
||||
// `node src/server/index.ts` reads neither .env nor bunfig.toml; keep the binary's configuration surface the same.
|
||||
autoloadDotenv: false,
|
||||
autoloadBunfig: false,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readdir, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { catalogIndexFileName } from "../src/catalog-index.ts";
|
||||
|
||||
const rootDir = process.cwd();
|
||||
const generatedPaths = [
|
||||
@@ -9,6 +10,7 @@ const generatedPaths = [
|
||||
join(process.cwd(), "src/providers/action-contracts.generated.ts"),
|
||||
];
|
||||
const catalogDir = join(process.cwd(), "catalog/apps");
|
||||
const catalogIndexFile = join(process.cwd(), "catalog", catalogIndexFileName);
|
||||
const sourcePaths = [
|
||||
join(rootDir, "src/core"),
|
||||
join(rootDir, "src/providers"),
|
||||
@@ -76,10 +78,14 @@ async function isFreshCatalog(sourceMtimeMs: number): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
const mtimes = await Promise.all(
|
||||
jsonFiles.map(async (entry) => (await stat(join(catalogDir, entry.name))).mtimeMs),
|
||||
);
|
||||
return Math.min(...mtimes) >= sourceMtimeMs;
|
||||
const [indexMtimeMs, ...providerMtimes] = await Promise.all([
|
||||
// A missing index throws ENOENT and is treated like a missing catalog, so the two are regenerated together.
|
||||
stat(catalogIndexFile).then((stats) => stats.mtimeMs),
|
||||
...jsonFiles.map(async (entry) => (await stat(join(catalogDir, entry.name))).mtimeMs),
|
||||
]);
|
||||
// The generator writes the index after the provider files; an older index was left behind by a generator run
|
||||
// that did not write one and would be refused at startup in lazy mode.
|
||||
return Math.min(indexMtimeMs, ...providerMtimes) >= sourceMtimeMs && indexMtimeMs >= Math.max(...providerMtimes);
|
||||
} catch (error) {
|
||||
if (isNotFoundError(error)) {
|
||||
return false;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { CatalogIndexSource } from "../src/catalog-index.ts";
|
||||
|
||||
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { catalogIndexFileName, createCatalogIndex } from "../src/catalog-index.ts";
|
||||
import { sortProviders } from "../src/core/catalog.ts";
|
||||
import { assertProviderId } from "../src/core/provider-id.ts";
|
||||
import { generateProviderRegistries } from "./generate-provider-registry.ts";
|
||||
@@ -8,6 +11,10 @@ import { loadProviderSources } from "./provider-source.ts";
|
||||
const outputDir = join(process.cwd(), "catalog/apps");
|
||||
const catalogRootDir = join(process.cwd(), "catalog");
|
||||
const tempOutputDir = join(catalogRootDir, `.apps-${process.pid}-${Date.now()}`);
|
||||
// The index lives beside catalog/apps/, not inside it, so nothing that treats every apps/*.json as a provider
|
||||
// needs an exclusion. It is compact JSON: pretty-printing would roughly double it for no reader.
|
||||
const indexFile = join(catalogRootDir, catalogIndexFileName);
|
||||
const tempIndexFile = join(catalogRootDir, `.apps-index-${process.pid}-${Date.now()}.json`);
|
||||
const providerSources = await loadProviderSources();
|
||||
await generateProviderRegistries(providerSources);
|
||||
const providers = providerSources.map((source) => source.definition);
|
||||
@@ -17,15 +24,27 @@ await mkdir(catalogRootDir, { recursive: true });
|
||||
|
||||
try {
|
||||
await mkdir(tempOutputDir, { recursive: true });
|
||||
const sources: CatalogIndexSource[] = [];
|
||||
for (const app of apps) {
|
||||
assertProviderId(app.service, "catalog app service");
|
||||
await writeFile(join(tempOutputDir, `${app.service}.json`), `${JSON.stringify(app, null, 2)}\n`);
|
||||
const file = `${app.service}.json`;
|
||||
const content = `${JSON.stringify(app, null, 2)}\n`;
|
||||
await writeFile(join(tempOutputDir, file), content);
|
||||
sources.push({ file, bytes: Buffer.byteLength(content), provider: app });
|
||||
}
|
||||
await writeFile(tempIndexFile, `${JSON.stringify(createCatalogIndex(sources))}\n`);
|
||||
// Remove the old index before the apps swap: an interrupted run must never leave an index of the previous
|
||||
// catalog beside the new files. Both outputs are renamed into place, so neither is ever partially written.
|
||||
await rm(indexFile, { force: true });
|
||||
await rm(outputDir, { recursive: true, force: true });
|
||||
await rename(tempOutputDir, outputDir);
|
||||
await rename(tempIndexFile, indexFile);
|
||||
} catch (error) {
|
||||
await rm(tempOutputDir, { recursive: true, force: true });
|
||||
await rm(tempIndexFile, { force: true });
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log(`Generated ${apps.length} apps and ${apps.reduce((sum, app) => sum + app.actions.length, 0)} actions.`);
|
||||
console.log(
|
||||
`Generated ${apps.length} apps and ${apps.reduce((sum, app) => sum + app.actions.length, 0)} actions, and the startup index ${indexFile}.`,
|
||||
);
|
||||
|
||||
+165
-13
@@ -9,11 +9,14 @@ import { join, resolve } from "node:path";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
|
||||
// Start a built single-file executable against a fresh data directory and check that its embedded catalog,
|
||||
// web console, migrations and shutdown path work.
|
||||
// web console, migrations, provider executor chunks, lazily loaded MCP and docs modules, exit on a port conflict
|
||||
// and shutdown path work.
|
||||
//
|
||||
// Usage: `node scripts/smoke-binary.ts <path-to-binary>`. Set OOMOL_CONNECT_DATABASE_URL to run the PostgreSQL
|
||||
// mode and OOMOL_CONNECT_CATALOG_LAZY_SCHEMAS to run the lazy catalog mode; every other OOMOL_CONNECT_* variable is
|
||||
// removed from the server's environment. Uses only Node built-ins so the smoke runners need no `npm ci`.
|
||||
// mode and OOMOL_CONNECT_CATALOG_LAZY_SCHEMAS to a value the server reads as true (1, true, yes or on) to run the
|
||||
// lazy catalog mode, which also proves that the embedded catalog index was found and used at startup; every other
|
||||
// OOMOL_CONNECT_* variable is removed from the server's environment. Uses only Node built-ins so the smoke runners
|
||||
// need no `npm ci`.
|
||||
|
||||
interface ProcessExit {
|
||||
code: number | null;
|
||||
@@ -109,6 +112,7 @@ class ServerProcess {
|
||||
const binaryPath = await resolveBinaryPath(process.argv[2]);
|
||||
const databaseUrl = process.env.OOMOL_CONNECT_DATABASE_URL?.trim() || undefined;
|
||||
const mode = databaseUrl ? "postgresql" : "sqlite";
|
||||
const lazyCatalogSchemas = isTruthyEnv(process.env.OOMOL_CONNECT_CATALOG_LAZY_SCHEMAS);
|
||||
const startedAt = performance.now();
|
||||
// Probe the port before creating the data directory so a probe failure cannot leak the temp directory.
|
||||
const port = await findFreePort();
|
||||
@@ -123,8 +127,15 @@ try {
|
||||
await checkConsoleAssets(baseUrl, indexHtml);
|
||||
await checkProviders(baseUrl);
|
||||
await checkActionSchema(baseUrl);
|
||||
if (lazyCatalogSchemas) {
|
||||
checkCatalogIndexUsed(server);
|
||||
}
|
||||
await checkApps(baseUrl);
|
||||
await checkProviderExecutor(baseUrl);
|
||||
await checkMcp(baseUrl);
|
||||
await checkDocs(baseUrl);
|
||||
await checkDatabaseBackend(server, dataDir, databaseUrl);
|
||||
await checkPortConflictExit(binaryPath, port, databaseUrl);
|
||||
await checkGracefulShutdown(server);
|
||||
await removeDataDir(dataDir);
|
||||
console.log(
|
||||
@@ -172,15 +183,26 @@ function buildServerEnvironment(options: ServerProcessOptions): NodeJS.ProcessEn
|
||||
if (options.databaseUrl) {
|
||||
env.OOMOL_CONNECT_DATABASE_URL = options.databaseUrl;
|
||||
}
|
||||
// Forwarded on purpose: it is what makes a smoke run read action schemas from the embedded catalog on demand.
|
||||
const lazyCatalogSchemas = process.env.OOMOL_CONNECT_CATALOG_LAZY_SCHEMAS;
|
||||
if (lazyCatalogSchemas !== undefined) {
|
||||
env.OOMOL_CONNECT_CATALOG_LAZY_SCHEMAS = lazyCatalogSchemas;
|
||||
// Forwarded verbatim on purpose: it is what makes a smoke run read action schemas from the embedded catalog on
|
||||
// demand, and the server applies its own reading of the value.
|
||||
const lazyCatalogSchemasValue = process.env.OOMOL_CONNECT_CATALOG_LAZY_SCHEMAS;
|
||||
if (lazyCatalogSchemasValue !== undefined) {
|
||||
env.OOMOL_CONNECT_CATALOG_LAZY_SCHEMAS = lazyCatalogSchemasValue;
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the server reads an OOMOL_CONNECT_* value as true. Mirrors parseBooleanEnv in src/server/index.ts token
|
||||
* for token (1, true, yes, on; trimmed, case-insensitive). It is a copy rather than an import because this script
|
||||
* must stay free of src imports so the smoke runners can run it with nothing but Node.
|
||||
*/
|
||||
function isTruthyEnv(value: string | undefined): boolean {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
||||
}
|
||||
|
||||
function findFreePort(): Promise<number> {
|
||||
return new Promise((resolvePort, reject) => {
|
||||
const probe = createServer();
|
||||
@@ -293,19 +315,110 @@ async function checkActionSchema(baseUrl: string): Promise<void> {
|
||||
assert(Object.keys(data.inputSchema).length > 0, `/v1/actions/${actionId} inputSchema is empty`);
|
||||
}
|
||||
|
||||
/**
|
||||
* In lazy catalog mode the server reads the embedded catalog index (scripts/build-binary.ts embeds
|
||||
* catalog/apps-index.json under its basename) instead of every provider file and reports that in its "catalog
|
||||
* loaded" line. The line is what proves the embed name and the resolver name in src/server/server-assets.ts still
|
||||
* agree inside a real binary; a missing index only warns and falls back, so /health alone would not catch it.
|
||||
*/
|
||||
function checkCatalogIndexUsed(server: ServerProcess): void {
|
||||
assert(
|
||||
server.stdout.includes('"catalogIndex":true'),
|
||||
'server log has no "catalogIndex":true line; lazy catalog mode did not use the embedded catalog index',
|
||||
);
|
||||
}
|
||||
|
||||
async function checkApps(baseUrl: string): Promise<void> {
|
||||
const data = await fetchEnvelopeData(baseUrl, "/v1/apps");
|
||||
assert(Array.isArray(data), "/v1/apps data is not an array");
|
||||
}
|
||||
|
||||
/** Return `data` of a `{ success: true, data }` envelope. */
|
||||
async function fetchEnvelopeData(baseUrl: string, path: string): Promise<unknown> {
|
||||
const response = await fetch(`${baseUrl}${path}`, { signal: AbortSignal.timeout(requestTimeoutMs) });
|
||||
/**
|
||||
* Provider executors are separate chunks inside the executable (scripts/build-binary.ts builds with code splitting),
|
||||
* so the catalog checks above prove nothing about them. quickchart.build_chart_url is a no_auth action whose executor
|
||||
* assembles the URL locally, so one call imports and runs a provider chunk with no credential, no network egress and a
|
||||
* fixed answer. A chunk that fails to import surfaces as a 500 internal_error envelope (ActionRunner), never as 200.
|
||||
* The URL is checked structurally rather than as a literal so the provider's own serialization details stay its own.
|
||||
*/
|
||||
async function checkProviderExecutor(baseUrl: string): Promise<void> {
|
||||
const actionId = "quickchart.build_chart_url";
|
||||
const chart = { type: "bar" };
|
||||
const data = await fetchEnvelopeData(baseUrl, `/v1/actions/${actionId}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ input: { chart } }),
|
||||
});
|
||||
assert(isRecord(data), `POST /v1/actions/${actionId} data is not an object`);
|
||||
assert(typeof data.url === "string", `POST /v1/actions/${actionId} url is ${JSON.stringify(data.url)}`);
|
||||
const url = new URL(data.url);
|
||||
assert.equal(`${url.origin}${url.pathname}`, "https://quickchart.io/chart", `POST /v1/actions/${actionId} url`);
|
||||
const chartParameter = url.searchParams.get("chart");
|
||||
assert(chartParameter !== null, `POST /v1/actions/${actionId} url has no chart parameter: ${data.url}`);
|
||||
assert.deepEqual(JSON.parse(chartParameter), chart, `POST /v1/actions/${actionId} chart parameter`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The MCP module (@modelcontextprotocol/server and zod) is imported on the first /mcp request rather than at
|
||||
* startup, so /health proves nothing about it. One stateless initialize round trip imports and evaluates that chunk
|
||||
* inside the binary; the body is matched as text because the transport may frame the JSON-RPC result as SSE.
|
||||
* GET /mcp/tools then reads the tool summaries from the same module.
|
||||
*/
|
||||
async function checkMcp(baseUrl: string): Promise<void> {
|
||||
const response = await fetch(`${baseUrl}/mcp`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "smoke", version: "0" } },
|
||||
}),
|
||||
signal: AbortSignal.timeout(requestTimeoutMs),
|
||||
});
|
||||
const body = await response.text();
|
||||
assert(response.status === 200, `GET ${path} returned ${response.status}`);
|
||||
assert(response.status === 200, `POST /mcp initialize returned ${response.status}: ${body.slice(0, 300)}`);
|
||||
assert(
|
||||
body.includes('"name":"oomol-connect"'),
|
||||
`POST /mcp initialize body has no server info: ${body.slice(0, 300)}`,
|
||||
);
|
||||
|
||||
const toolsResponse = await fetch(`${baseUrl}/mcp/tools`, { signal: AbortSignal.timeout(requestTimeoutMs) });
|
||||
const toolsBody = await toolsResponse.text();
|
||||
assert(toolsResponse.status === 200, `GET /mcp/tools returned ${toolsResponse.status}: ${toolsBody.slice(0, 300)}`);
|
||||
const payload: unknown = JSON.parse(toolsBody);
|
||||
assert(isRecord(payload) && Array.isArray(payload.tools), "GET /mcp/tools did not return a tools array");
|
||||
const tools: unknown[] = payload.tools;
|
||||
assert(tools.length > 0, "GET /mcp/tools returned an empty tools array");
|
||||
const names = tools.map((tool) => (isRecord(tool) && typeof tool.name === "string" ? tool.name : undefined));
|
||||
const unnamed = names.indexOf(undefined);
|
||||
assert(unnamed === -1, `GET /mcp/tools entry ${unnamed} has no string name: ${toolsBody.slice(0, 300)}`);
|
||||
// src/mcp.ts registers more tools than these; only the two that cannot go away are pinned, so adding an MCP tool
|
||||
// later does not break the release smoke.
|
||||
for (const expected of ["search_actions", "execute_action"]) {
|
||||
assert(names.includes(expected), `GET /mcp/tools does not list ${expected}: ${names.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** The Scalar API reference package is imported on the first /docs request; one page render proves that chunk. */
|
||||
async function checkDocs(baseUrl: string): Promise<void> {
|
||||
const response = await fetch(`${baseUrl}/docs`, { signal: AbortSignal.timeout(requestTimeoutMs) });
|
||||
const body = await response.text();
|
||||
assert(response.status === 200, `GET /docs returned ${response.status}: ${body.slice(0, 300)}`);
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
assert(contentType.startsWith("text/html"), `GET /docs content-type is ${contentType || "missing"}`);
|
||||
assert(body.includes("OOMOL Connect API Reference"), "GET /docs body does not contain the API reference title");
|
||||
}
|
||||
|
||||
/** Return `data` of a `{ success: true, data }` envelope. */
|
||||
async function fetchEnvelopeData(baseUrl: string, path: string, init: RequestInit = {}): Promise<unknown> {
|
||||
const method = init.method ?? "GET";
|
||||
const response = await fetch(`${baseUrl}${path}`, { ...init, signal: AbortSignal.timeout(requestTimeoutMs) });
|
||||
const body = await response.text();
|
||||
// The body excerpt is what makes a failed provider chunk import (a 500 internal_error envelope) diagnosable.
|
||||
assert(response.status === 200, `${method} ${path} returned ${response.status}: ${body.slice(0, 300)}`);
|
||||
const payload: unknown = JSON.parse(body);
|
||||
assert(isRecord(payload), `GET ${path} did not return a JSON object`);
|
||||
assert(payload.success === true, `GET ${path} envelope success is ${JSON.stringify(payload.success)}`);
|
||||
assert(isRecord(payload), `${method} ${path} did not return a JSON object`);
|
||||
assert(payload.success === true, `${method} ${path} envelope success is ${JSON.stringify(payload.success)}`);
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
@@ -330,6 +443,45 @@ async function checkDatabaseBackend(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A second instance on a port that is already in use must exit instead of hanging. src/server/index.ts finishes
|
||||
* evaluating once the server listens; with a top-level await spanning the server's lifetime, Bun printed the listen
|
||||
* failure and then waited forever on that promise, so the binary hung on EADDRINUSE while Node exited with code 1.
|
||||
* The first server is still listening here, and the second one only reaches the conflict after a full startup of its
|
||||
* own, which is why the wait is sized like a startup rather than a shutdown.
|
||||
*/
|
||||
async function checkPortConflictExit(binaryPath: string, port: number, databaseUrl: string | undefined): Promise<void> {
|
||||
const conflictDataDir = await mkdtemp(join(tmpdir(), "open-connector-smoke-conflict-"));
|
||||
const second = new ServerProcess({ binaryPath, dataDir: conflictDataDir, port, databaseUrl });
|
||||
try {
|
||||
const exit = await second.waitForExit(healthTimeoutMs);
|
||||
if (!exit) {
|
||||
throw new Error(
|
||||
`second instance did not exit within ${healthTimeoutMs} ms on a port conflict\n--- second instance stderr ---\n${second.stderr}`,
|
||||
);
|
||||
}
|
||||
|
||||
// A spawn failure also reports code null, so requiring exactly 1 still excludes it. The documented contract is
|
||||
// exit code 1: Bun's and Node's unhandled 'error' event both end the process with 1, verified on macOS, Linux and
|
||||
// Windows, so any other code means the listen failure was not what ended the process.
|
||||
assert(
|
||||
exit.signal === null && exit.code === 1,
|
||||
`second instance on a busy port ended with ${second.describeExit()}; expected exit code 1`,
|
||||
);
|
||||
// Bun prints "Failed to start server. Is port N in use?" on every platform, Windows included, and Node "listen
|
||||
// EADDRINUSE"; without this, a crash for an unrelated reason would pass as the port conflict exit.
|
||||
const stderr = second.stderr.trim();
|
||||
assert(
|
||||
/EADDRINUSE|in use/i.test(stderr),
|
||||
`second instance on a busy port exited with code ${exit.code} but its stderr does not mention the port conflict\n--- second instance stderr ---\n${stderr.slice(0, 1000) || "(empty)"}`,
|
||||
);
|
||||
console.log(`port conflict: second instance exited with code ${exit.code}`);
|
||||
} finally {
|
||||
await second.forceStop();
|
||||
await removeDataDir(conflictDataDir);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkGracefulShutdown(server: ServerProcess): Promise<void> {
|
||||
server.kill("SIGTERM");
|
||||
const exit = await server.waitForExit(shutdownTimeoutMs);
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { ProviderDefinition } from "./core/types.ts";
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { catalogIndexFileName, createCatalogIndex, readCatalogIndex } from "./catalog-index.ts";
|
||||
import { loadCatalog } from "./catalog-store.ts";
|
||||
import { executorModules } from "./providers/registry.generated.ts";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("createCatalogIndex", () => {
|
||||
it("keeps every action key in file order with null schemas", () => {
|
||||
const provider = providerFixture("example", ["ping", "pong"]);
|
||||
|
||||
const index = createCatalogIndex([{ file: "example.json", bytes: 123, provider }]);
|
||||
|
||||
expect(index.version).toBe(1);
|
||||
expect(index.providers).toHaveLength(1);
|
||||
const [entry] = index.providers;
|
||||
expect(entry?.file).toBe("example.json");
|
||||
expect(entry?.bytes).toBe(123);
|
||||
expect(Object.keys(entry!.provider)).toEqual(Object.keys(provider));
|
||||
for (const [position, action] of entry!.provider.actions.entries()) {
|
||||
expect(Object.keys(action)).toEqual(Object.keys(provider.actions[position]!));
|
||||
expect(action.inputSchema).toBeNull();
|
||||
expect(action.outputSchema).toBeNull();
|
||||
expect(action.followUpActions).toEqual(provider.actions[position]!.followUpActions);
|
||||
expect(action.asyncLifecycle).toEqual(provider.actions[position]!.asyncLifecycle);
|
||||
}
|
||||
// The source provider is copied, not mutated.
|
||||
expect(provider.actions[0]!.inputSchema).toEqual({ type: "object", properties: { ping: { type: "string" } } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("readCatalogIndex", () => {
|
||||
it("returns every provider with the path of the file that owns its schemas", async () => {
|
||||
const providers = [providerFixture("example", ["ping"]), providerFixture("remote", ["ping"])];
|
||||
const { catalogDir, indexFile } = await writeIndexedCatalog(providers);
|
||||
|
||||
const indexed = await readCatalogIndex({ indexFile, catalogDir, fileNames: ["example.json", "remote.json"] });
|
||||
|
||||
expect(indexed.map((entry) => entry.filePath)).toEqual([
|
||||
join(catalogDir, "example.json"),
|
||||
join(catalogDir, "remote.json"),
|
||||
]);
|
||||
expect(indexed[0]?.provider.actions[0]?.inputSchema).toBeNull();
|
||||
expect(indexed[0]?.provider.actions[0]?.id).toBe("example.ping");
|
||||
});
|
||||
|
||||
it("rejects an index that does not describe the directory", async () => {
|
||||
const providers = [providerFixture("example", ["ping"]), providerFixture("remote", ["ping"])];
|
||||
const { catalogDir, indexFile } = await writeIndexedCatalog(providers);
|
||||
|
||||
// A provider file the index does not list.
|
||||
await expect(
|
||||
readCatalogIndex({ indexFile, catalogDir, fileNames: ["example.json", "remote.json", "third.json"] }),
|
||||
).rejects.toThrow(/not indexed: third\.json; no longer present: none.*run npm run generate:catalog/);
|
||||
// A listed provider file that is gone.
|
||||
await expect(readCatalogIndex({ indexFile, catalogDir, fileNames: ["example.json"] })).rejects.toThrow(
|
||||
/not indexed: none; no longer present: remote\.json.*run npm run generate:catalog/,
|
||||
);
|
||||
// A listed provider file whose size changed after the index was written.
|
||||
await writeProviderFile(catalogDir, providerFixture("remote", ["ping", "pong"]));
|
||||
await expect(
|
||||
readCatalogIndex({ indexFile, catalogDir, fileNames: ["example.json", "remote.json"] }),
|
||||
).rejects.toThrow(/was written for a different remote\.json \(\d+ bytes, now \d+\); run npm run generate:catalog/);
|
||||
});
|
||||
|
||||
it("rejects an unknown version and an index that is not JSON", async () => {
|
||||
const { catalogDir, indexFile } = await writeIndexedCatalog([providerFixture("example", ["ping"])]);
|
||||
|
||||
await writeFile(indexFile, JSON.stringify({ version: 2, providers: [] }));
|
||||
await expect(readCatalogIndex({ indexFile, catalogDir, fileNames: ["example.json"] })).rejects.toThrow(
|
||||
`Catalog index ${indexFile} is not a version 1 index; run npm run generate:catalog`,
|
||||
);
|
||||
await writeFile(indexFile, JSON.stringify({ version: 1, providers: [{ file: "example.json" }] }));
|
||||
await expect(readCatalogIndex({ indexFile, catalogDir, fileNames: ["example.json"] })).rejects.toThrow(
|
||||
"is not a version 1 index; run npm run generate:catalog",
|
||||
);
|
||||
// An interrupted write leaves a truncated file behind; the fix must be named rather than a bare SyntaxError.
|
||||
await writeFile(indexFile, '{"version":1,"providers":[');
|
||||
await expect(readCatalogIndex({ indexFile, catalogDir, fileNames: ["example.json"] })).rejects.toThrow(
|
||||
`Catalog index ${indexFile} is not valid JSON; run npm run generate:catalog`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generated catalog index", () => {
|
||||
const catalogDir = join(process.cwd(), "catalog/apps");
|
||||
const indexFile = join(process.cwd(), "catalog", catalogIndexFileName);
|
||||
|
||||
// catalog/ is generated by postinstall, so this runs in CI and after any local `npm run generate:catalog`. It is
|
||||
// the byte-identity gate on production data: the index-backed store must be indistinguishable from the eager one.
|
||||
it.skipIf(!existsSync(indexFile))("reproduces the generated catalog", async () => {
|
||||
const executableServices = Object.keys(executorModules);
|
||||
|
||||
const eager = await loadCatalog(catalogDir, { executableServices });
|
||||
const indexed = await loadCatalog(catalogDir, {
|
||||
executableServices,
|
||||
lazySchemas: true,
|
||||
lazySchemaIndexFile: indexFile,
|
||||
});
|
||||
|
||||
// Compared as text: `toEqual` on the two 6 MB byte arrays walks every element and exceeds the test timeout.
|
||||
expect(new TextDecoder().decode(indexed.providerSummariesJson)).toBe(
|
||||
new TextDecoder().decode(eager.providerSummariesJson),
|
||||
);
|
||||
expect(indexed.providerSummariesEtag).toBe(eager.providerSummariesEtag);
|
||||
expect(indexed.executableActionIds).toEqual(eager.executableActionIds);
|
||||
expect([...indexed.actionsById.keys()]).toEqual([...eager.actionsById.keys()]);
|
||||
expect(indexed.actions.map((action) => Object.keys(action))).toEqual(
|
||||
eager.actions.map((action) => Object.keys(action)),
|
||||
);
|
||||
// One action serialized in full exercises the lazy accessors without reading every provider file back.
|
||||
const actionId = "slack.add_reaction";
|
||||
expect(JSON.stringify(indexed.actionsById.get(actionId))).toBe(JSON.stringify(eager.actionsById.get(actionId)));
|
||||
});
|
||||
});
|
||||
|
||||
interface IndexedCatalogFixture {
|
||||
catalogDir: string;
|
||||
indexFile: string;
|
||||
}
|
||||
|
||||
/** Write `<tmp>/apps/<service>.json` for every provider and the index that describes them. */
|
||||
async function writeIndexedCatalog(providers: ProviderDefinition[]): Promise<IndexedCatalogFixture> {
|
||||
const root = await mkdtemp(join(tmpdir(), "catalog-index-"));
|
||||
temporaryDirectories.push(root);
|
||||
const catalogDir = join(root, "apps");
|
||||
await mkdir(catalogDir);
|
||||
const sources = [];
|
||||
for (const provider of providers) {
|
||||
const file = `${provider.service}.json`;
|
||||
await writeProviderFile(catalogDir, provider);
|
||||
sources.push({ file, bytes: (await stat(join(catalogDir, file))).size, provider });
|
||||
}
|
||||
const indexFile = join(root, catalogIndexFileName);
|
||||
await writeFile(indexFile, JSON.stringify(createCatalogIndex(sources)));
|
||||
|
||||
return { catalogDir, indexFile };
|
||||
}
|
||||
|
||||
function writeProviderFile(catalogDir: string, provider: ProviderDefinition): Promise<void> {
|
||||
return writeFile(join(catalogDir, `${provider.service}.json`), JSON.stringify(provider));
|
||||
}
|
||||
|
||||
/** Actions keep keys on both sides of the schemas, like generated catalog actions do. */
|
||||
function providerFixture(service: string, actionNames: string[]): ProviderDefinition {
|
||||
return {
|
||||
service,
|
||||
displayName: service,
|
||||
categories: ["Developer Tools"],
|
||||
authTypes: ["no_auth"],
|
||||
auth: [{ type: "no_auth" }],
|
||||
actions: actionNames.map((name) => ({
|
||||
id: `${service}.${name}`,
|
||||
service,
|
||||
name,
|
||||
description: `${name} action.`,
|
||||
requiredScopes: [],
|
||||
providerPermissions: [],
|
||||
inputSchema: { type: "object", properties: { [name]: { type: "string" } } },
|
||||
outputSchema: { type: "object", properties: { ok: { type: "boolean" } } },
|
||||
followUpActions: [`${service}.${actionNames[0]}`],
|
||||
asyncLifecycle: {
|
||||
startActionId: `${service}.${actionNames[0]}`,
|
||||
statusActionId: `${service}.${name}`,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { ActionDefinition, ProviderDefinition } from "./core/types.ts";
|
||||
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* Startup index of the generated catalog: every provider file without its action schemas.
|
||||
*
|
||||
* `npm run generate:catalog` writes it next to `catalog/apps/` as `catalog/apps-index.json`, and the
|
||||
* single-file executable embeds it beside `apps/`. With `OOMOL_CONNECT_CATALOG_LAZY_SCHEMAS` the
|
||||
* server reads this one file at startup instead of every provider file; the schemas are still read
|
||||
* from the provider files on access. Default mode never reads it.
|
||||
*/
|
||||
export interface CatalogIndex {
|
||||
version: 1;
|
||||
providers: CatalogIndexEntry[];
|
||||
}
|
||||
|
||||
/** Name of the index file inside the catalog directory that holds `apps/`. */
|
||||
export const catalogIndexFileName = "apps-index.json";
|
||||
|
||||
/**
|
||||
* An action as the index stores it: every key at the position it has in the provider file, with the
|
||||
* two schema keys holding `null`. The lazy loader defines its accessors at the key position it finds,
|
||||
* and real actions carry `followUpActions` and `asyncLifecycle` after the schemas, so dropping the
|
||||
* keys instead would reorder every serialized action.
|
||||
*/
|
||||
export type CatalogIndexAction = Omit<ActionDefinition, "inputSchema" | "outputSchema"> & {
|
||||
inputSchema: null;
|
||||
outputSchema: null;
|
||||
};
|
||||
|
||||
/** A provider as the index stores it: file key order, schema-free actions. */
|
||||
export type CatalogIndexProvider = Omit<ProviderDefinition, "actions"> & {
|
||||
actions: CatalogIndexAction[];
|
||||
};
|
||||
|
||||
export interface CatalogIndexEntry {
|
||||
/** Basename of the provider file under the catalog directory, which still owns the action schemas. */
|
||||
file: string;
|
||||
/**
|
||||
* UTF-8 length of that file as the generator wrote it. Compared with the directory at load time so
|
||||
* an index left beside a regenerated or hand-edited catalog is refused instead of served.
|
||||
*/
|
||||
bytes: number;
|
||||
provider: CatalogIndexProvider;
|
||||
}
|
||||
|
||||
/** One provider file as the generator wrote it. */
|
||||
export interface CatalogIndexSource {
|
||||
file: string;
|
||||
bytes: number;
|
||||
provider: ProviderDefinition;
|
||||
}
|
||||
|
||||
/** A provider read from the index together with the file its schemas are read from. */
|
||||
export interface IndexedProvider {
|
||||
filePath: string;
|
||||
provider: CatalogIndexProvider;
|
||||
}
|
||||
|
||||
export interface ReadCatalogIndexOptions {
|
||||
indexFile: string;
|
||||
/** Directory of provider files the index must describe. */
|
||||
catalogDir: string;
|
||||
/** The `.json` entries of `catalogDir`. */
|
||||
fileNames: string[];
|
||||
}
|
||||
|
||||
/** Build the index the generator writes; `sources` must be in catalog order. */
|
||||
export function createCatalogIndex(sources: CatalogIndexSource[]): CatalogIndex {
|
||||
return {
|
||||
version: 1,
|
||||
providers: sources.map((source) => ({
|
||||
file: source.file,
|
||||
bytes: source.bytes,
|
||||
provider: {
|
||||
...source.provider,
|
||||
actions: source.provider.actions.map(withoutSchemas),
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function withoutSchemas(action: ActionDefinition): CatalogIndexAction {
|
||||
const indexed: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(action)) {
|
||||
indexed[key] = key === "inputSchema" || key === "outputSchema" ? null : action[key as keyof ActionDefinition];
|
||||
}
|
||||
|
||||
return indexed as CatalogIndexAction;
|
||||
}
|
||||
|
||||
const regenerate = "run npm run generate:catalog";
|
||||
|
||||
/**
|
||||
* Read the index and check it against the provider files actually present in the catalog directory.
|
||||
*
|
||||
* The index must list exactly the given files with the byte lengths they have now; anything else means
|
||||
* the two were not written by the same `npm run generate:catalog` run, and serving one generation's
|
||||
* metadata with another's schemas would produce responses no generation ever had.
|
||||
*/
|
||||
export async function readCatalogIndex(options: ReadCatalogIndexOptions): Promise<IndexedProvider[]> {
|
||||
const { indexFile, catalogDir, fileNames } = options;
|
||||
const content = await readFile(indexFile, "utf8");
|
||||
let index: unknown;
|
||||
try {
|
||||
index = JSON.parse(content);
|
||||
} catch (error) {
|
||||
throw new Error(`Catalog index ${indexFile} is not valid JSON; ${regenerate}`, { cause: error });
|
||||
}
|
||||
if (!isCatalogIndex(index)) {
|
||||
throw new Error(`Catalog index ${indexFile} is not a version 1 index; ${regenerate}`);
|
||||
}
|
||||
|
||||
const indexedFiles = new Set(index.providers.map((entry) => entry.file));
|
||||
const unindexed = fileNames.filter((name) => !indexedFiles.has(name));
|
||||
const present = new Set(fileNames);
|
||||
const removed = index.providers.filter((entry) => !present.has(entry.file)).map((entry) => entry.file);
|
||||
if (unindexed.length > 0 || removed.length > 0 || indexedFiles.size !== index.providers.length) {
|
||||
throw new Error(
|
||||
`Catalog index ${indexFile} does not describe ${catalogDir} (not indexed: ${describeNames(unindexed)}; no longer present: ${describeNames(removed)}); ${regenerate}`,
|
||||
);
|
||||
}
|
||||
|
||||
const providers: IndexedProvider[] = [];
|
||||
for (const entry of index.providers) {
|
||||
const filePath = join(catalogDir, entry.file);
|
||||
const { size } = await stat(filePath);
|
||||
if (size !== entry.bytes) {
|
||||
throw new Error(
|
||||
`Catalog index ${indexFile} was written for a different ${entry.file} (${entry.bytes} bytes, now ${size}); ${regenerate}`,
|
||||
);
|
||||
}
|
||||
providers.push({ filePath, provider: entry.provider });
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
|
||||
function isCatalogIndex(value: unknown): value is CatalogIndex {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return false;
|
||||
}
|
||||
const { version, providers } = value as Partial<CatalogIndex>;
|
||||
return (
|
||||
version === 1 &&
|
||||
Array.isArray(providers) &&
|
||||
providers.every(
|
||||
(entry) =>
|
||||
typeof entry === "object" &&
|
||||
entry !== null &&
|
||||
typeof entry.file === "string" &&
|
||||
Number.isInteger(entry.bytes) &&
|
||||
typeof entry.provider === "object" &&
|
||||
entry.provider !== null,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function describeNames(names: string[]): string {
|
||||
if (names.length === 0) {
|
||||
return "none";
|
||||
}
|
||||
const shown = names.slice(0, 5).join(", ");
|
||||
return names.length > 5 ? `${shown} and ${names.length - 5} more` : shown;
|
||||
}
|
||||
+37
-10
@@ -1,3 +1,4 @@
|
||||
import type { CatalogIndexAction, CatalogIndexProvider, IndexedProvider } from "./catalog-index.ts";
|
||||
import type { ActionDefinition, JsonSchema, ProviderDefinition } from "./core/types.ts";
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
@@ -25,9 +26,10 @@ export const defaultLazySchemaCacheFiles = 64;
|
||||
/**
|
||||
* Read provider catalog files while keeping every `inputSchema`/`outputSchema` on disk.
|
||||
*
|
||||
* Files are read one at a time so peak memory holds a single fully parsed provider, and each action
|
||||
* keeps schema-shaped accessors at their original key positions, so a lazily loaded catalog
|
||||
* serializes byte-for-byte like an eagerly loaded one.
|
||||
* Each action keeps schema-shaped accessors at their original key positions, so a lazily loaded
|
||||
* catalog serializes byte-for-byte like an eagerly loaded one, and an accessor reads its schemas
|
||||
* back from the file that owns them. Because every parsed provider is reduced to its metadata
|
||||
* before the next file is read, peak memory here holds a single fully parsed provider.
|
||||
*/
|
||||
export async function readProvidersWithLazySchemas(
|
||||
filePaths: string[],
|
||||
@@ -37,23 +39,48 @@ export async function readProvidersWithLazySchemas(
|
||||
const providers: ProviderDefinition[] = [];
|
||||
for (const filePath of filePaths) {
|
||||
const provider = JSON.parse(await readFile(filePath, "utf8")) as ProviderDefinition;
|
||||
const source = new FileActionSchemaSource(filePath, loader);
|
||||
providers.push({
|
||||
...provider,
|
||||
actions: provider.actions.map((action) => toFileBackedAction(action, source)),
|
||||
});
|
||||
providers.push(toFileBackedProvider(provider, filePath, loader));
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach lazy schema accessors to providers read from the catalog index, which already carries every
|
||||
* action without its schemas. Nothing is read here: each provider file is opened only when one of its
|
||||
* schemas is first accessed.
|
||||
*/
|
||||
export function indexedProvidersWithLazySchemas(
|
||||
providers: IndexedProvider[],
|
||||
cacheFiles: number = defaultLazySchemaCacheFiles,
|
||||
): ProviderDefinition[] {
|
||||
const loader = new FileActionSchemaLoader(cacheFiles);
|
||||
return providers.map((entry) => toFileBackedProvider(entry.provider, entry.filePath, loader));
|
||||
}
|
||||
|
||||
function toFileBackedProvider(
|
||||
provider: ProviderDefinition | CatalogIndexProvider,
|
||||
filePath: string,
|
||||
loader: FileActionSchemaLoader,
|
||||
): ProviderDefinition {
|
||||
const source = new FileActionSchemaSource(filePath, loader);
|
||||
return {
|
||||
...provider,
|
||||
actions: provider.actions.map((action) => toFileBackedAction(action, source)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy one parsed action without its schemas, replacing them with enumerable accessors defined at
|
||||
* the key position they had in the file. Real catalog actions carry `followUpActions` and
|
||||
* `asyncLifecycle` after the schemas, so appending the accessors instead would reorder the keys and
|
||||
* change every serialized response.
|
||||
* change every serialized response. An index action holds `null` at those positions, which the copy
|
||||
* never reads.
|
||||
*/
|
||||
function toFileBackedAction(action: ActionDefinition, source: FileActionSchemaSource): ActionDefinition {
|
||||
function toFileBackedAction(
|
||||
action: ActionDefinition | CatalogIndexAction,
|
||||
source: FileActionSchemaSource,
|
||||
): ActionDefinition {
|
||||
const fileBacked: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(action)) {
|
||||
if (key === "inputSchema" || key === "outputSchema") {
|
||||
|
||||
+142
-30
@@ -1,10 +1,11 @@
|
||||
import type { CatalogStore, ProviderSummaryDefinition } from "./catalog-store.ts";
|
||||
import type { JsonSchema, ProviderDefinition } from "./core/types.ts";
|
||||
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { catalogIndexFileName, createCatalogIndex } from "./catalog-index.ts";
|
||||
import { defaultLazySchemaCacheFiles } from "./catalog-lazy-schemas.ts";
|
||||
import { createCatalogStore, loadCatalog, resolveExecutableActionIds } from "./catalog-store.ts";
|
||||
|
||||
@@ -68,7 +69,9 @@ describe("catalog store", () => {
|
||||
];
|
||||
|
||||
const catalog = createCatalogStore(providers, { executableActionIds: ["example.ping"] });
|
||||
const [summary] = JSON.parse(catalog.providerSummariesJson) as ProviderSummaryDefinition[];
|
||||
const [summary] = JSON.parse(
|
||||
new TextDecoder().decode(catalog.providerSummariesJson),
|
||||
) as ProviderSummaryDefinition[];
|
||||
const summarizedAction = summary?.actions[0];
|
||||
|
||||
expect(summarizedAction).not.toHaveProperty("inputSchema");
|
||||
@@ -85,6 +88,29 @@ describe("catalog store", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps provider summaries as UTF-8 bytes while the ETag still describes the JSON string", () => {
|
||||
const providers: ProviderDefinition[] = [
|
||||
{
|
||||
service: "example",
|
||||
displayName: "示例",
|
||||
categories: ["Developer Tools"],
|
||||
authTypes: ["no_auth"],
|
||||
auth: [{ type: "no_auth" }],
|
||||
actions: [],
|
||||
},
|
||||
];
|
||||
|
||||
const catalog = createCatalogStore(providers);
|
||||
const json = new TextDecoder().decode(catalog.providerSummariesJson);
|
||||
|
||||
expect(catalog.providerSummariesJson).toBeInstanceOf(Uint8Array);
|
||||
expect(json).toBe(JSON.stringify(catalog.providers));
|
||||
// The non-Latin1 display name takes more UTF-8 bytes than UTF-16 code units, so the length
|
||||
// field below can only match if the ETag still describes the string.
|
||||
expect(catalog.providerSummariesJson.byteLength).toBeGreaterThan(json.length);
|
||||
expect(catalog.providerSummariesEtag).toBe(`W/"${json.length.toString(16)}-a56a243b"`);
|
||||
});
|
||||
|
||||
it("resolves every action from executable services alongside explicit action ids", () => {
|
||||
const providers = [providerFixture("example", ["ping", "pong"]), providerFixture("remote", ["ping"])];
|
||||
|
||||
@@ -112,6 +138,23 @@ describe("loadCatalog", () => {
|
||||
expect(descriptor.value).toEqual(schemaFor("ping", 1));
|
||||
});
|
||||
|
||||
it("loads every provider file by default when the catalog spans several read batches", async () => {
|
||||
// More files than two full read batches (see providerReadBatchSize in catalog-store.ts), so the
|
||||
// batched loop has to carry results across batch boundaries and through a partial last batch.
|
||||
const providers = Array.from({ length: 9 }, (_, index) =>
|
||||
providerFixture(`service${String(index).padStart(2, "0")}`, ["ping", "pong"]),
|
||||
);
|
||||
const catalogDir = await writeCatalogDir(providers);
|
||||
|
||||
const catalog = await loadCatalog(catalogDir);
|
||||
|
||||
expect(catalog.providers.map((provider) => provider.service)).toEqual(
|
||||
providers.map((provider) => provider.service),
|
||||
);
|
||||
expect(catalog.actions).toHaveLength(18);
|
||||
expect(catalog.actionsById.get("service08.pong")?.inputSchema).toEqual(schemaFor("pong", 1));
|
||||
});
|
||||
|
||||
it("reads lazy schemas from disk on access, so a change to an uncached file is picked up", async () => {
|
||||
const catalogDir = await writeCatalogDir([providerFixture("example", ["ping"])]);
|
||||
|
||||
@@ -224,34 +267,41 @@ describe("loadCatalog", () => {
|
||||
});
|
||||
|
||||
it("serializes a lazily loaded catalog exactly like an eagerly loaded one", async () => {
|
||||
const catalogDir = await writeCatalogDir([
|
||||
providerFixture("example", ["ping", "pong"]),
|
||||
providerFixture("remote", ["ping"]),
|
||||
]);
|
||||
const providers = [providerFixture("example", ["ping", "pong"]), providerFixture("remote", ["ping"])];
|
||||
const catalogDir = await writeCatalogDir(providers);
|
||||
const lazySchemaIndexFile = await writeCatalogIndex(catalogDir, providers);
|
||||
|
||||
const eager = await loadCatalog(catalogDir, { executableServices: ["example"] });
|
||||
const lazy = await loadCatalog(catalogDir, { executableServices: ["example"], lazySchemas: true });
|
||||
const indexed = await loadCatalog(catalogDir, {
|
||||
executableServices: ["example"],
|
||||
lazySchemas: true,
|
||||
lazySchemaIndexFile,
|
||||
});
|
||||
|
||||
expect(lazy.providerSummariesJson).toBe(eager.providerSummariesJson);
|
||||
expect(lazy.providerSummariesEtag).toBe(eager.providerSummariesEtag);
|
||||
expect(JSON.stringify(lazy.actions)).toBe(JSON.stringify(eager.actions));
|
||||
expect([...lazy.actionsById.keys()]).toEqual([...eager.actionsById.keys()]);
|
||||
expect(JSON.stringify([...lazy.actionsById.values()])).toBe(JSON.stringify([...eager.actionsById.values()]));
|
||||
expect(actionKeys(lazy)).toEqual(actionKeys(eager));
|
||||
expect(actionKeys(lazy)[0]).toEqual([
|
||||
"id",
|
||||
"service",
|
||||
"name",
|
||||
"description",
|
||||
"requiredScopes",
|
||||
"providerPermissions",
|
||||
"inputSchema",
|
||||
"outputSchema",
|
||||
"followUpActions",
|
||||
"asyncLifecycle",
|
||||
"execution",
|
||||
]);
|
||||
for (const summaries of [eager.providerSummariesJson, lazy.providerSummariesJson]) {
|
||||
for (const store of [lazy, indexed]) {
|
||||
expect(store.providerSummariesJson).toEqual(eager.providerSummariesJson);
|
||||
expect(store.providerSummariesEtag).toBe(eager.providerSummariesEtag);
|
||||
expect(JSON.stringify(store.actions)).toBe(JSON.stringify(eager.actions));
|
||||
expect([...store.actionsById.keys()]).toEqual([...eager.actionsById.keys()]);
|
||||
expect(JSON.stringify([...store.actionsById.values()])).toBe(JSON.stringify([...eager.actionsById.values()]));
|
||||
expect(actionKeys(store)).toEqual(actionKeys(eager));
|
||||
expect(actionKeys(store)[0]).toEqual([
|
||||
"id",
|
||||
"service",
|
||||
"name",
|
||||
"description",
|
||||
"requiredScopes",
|
||||
"providerPermissions",
|
||||
"inputSchema",
|
||||
"outputSchema",
|
||||
"followUpActions",
|
||||
"asyncLifecycle",
|
||||
"execution",
|
||||
]);
|
||||
}
|
||||
for (const store of [eager, lazy, indexed]) {
|
||||
const summaries = new TextDecoder().decode(store.providerSummariesJson);
|
||||
expect(summaries).not.toContain("inputSchema");
|
||||
expect(summaries).not.toContain("outputSchema");
|
||||
}
|
||||
@@ -263,21 +313,61 @@ describe("loadCatalog", () => {
|
||||
const catalog = await loadCatalog(catalogDir, { lazySchemas: true });
|
||||
await rm(catalogDir, { recursive: true, force: true });
|
||||
|
||||
const [summary] = JSON.parse(catalog.providerSummariesJson) as ProviderSummaryDefinition[];
|
||||
const [summary] = JSON.parse(
|
||||
new TextDecoder().decode(catalog.providerSummariesJson),
|
||||
) as ProviderSummaryDefinition[];
|
||||
expect(summary?.actions[0]?.id).toBe("example.ping");
|
||||
// Nothing was cached during store construction, so the first schema access has to hit the
|
||||
// deleted file.
|
||||
expect(() => catalog.actionsById.get("example.ping")!.inputSchema).toThrow();
|
||||
});
|
||||
|
||||
it("reads only the index at startup when one is given", async () => {
|
||||
const providers = [providerFixture("example", ["ping"]), providerFixture("remote", ["ping"])];
|
||||
const catalogDir = await writeCatalogDir(providers);
|
||||
const lazySchemaIndexFile = await writeCatalogIndex(catalogDir, providers);
|
||||
const eager = await loadCatalog(catalogDir);
|
||||
// Spaces of the same byte length keep the index's size check passing while any read of a provider
|
||||
// file, at startup or on schema access, fails to parse.
|
||||
await blankProviderFiles(catalogDir);
|
||||
|
||||
await expect(loadCatalog(catalogDir, { lazySchemas: true })).rejects.toThrow(SyntaxError);
|
||||
const catalog = await loadCatalog(catalogDir, { lazySchemas: true, lazySchemaIndexFile });
|
||||
|
||||
expect(catalog.providerSummariesJson).toEqual(eager.providerSummariesJson);
|
||||
const action = catalog.actionsById.get("example.ping")!;
|
||||
expect(Object.getOwnPropertyDescriptor(action, "inputSchema")?.get).toBeTypeOf("function");
|
||||
expect(() => action.inputSchema).toThrow(SyntaxError);
|
||||
});
|
||||
|
||||
it("refuses an index that no longer describes the catalog files", async () => {
|
||||
const providers = [providerFixture("example", ["ping"]), providerFixture("remote", ["ping"])];
|
||||
const catalogDir = await writeCatalogDir(providers);
|
||||
const lazySchemaIndexFile = await writeCatalogIndex(catalogDir, providers);
|
||||
await writeProviderFile(catalogDir, {
|
||||
...providerFixture("remote", ["ping"]),
|
||||
description: "A longer description than the index was written for.",
|
||||
});
|
||||
|
||||
await expect(loadCatalog(catalogDir, { lazySchemas: true, lazySchemaIndexFile })).rejects.toThrow(
|
||||
/remote\.json \(\d+ bytes, now \d+\); run npm run generate:catalog/,
|
||||
);
|
||||
// Both file-scanning modes keep working: only the index-backed startup refuses a mismatched catalog.
|
||||
await expect(loadCatalog(catalogDir, { lazySchemas: true })).resolves.toBeDefined();
|
||||
await expect(loadCatalog(catalogDir)).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
function actionKeys(catalog: CatalogStore): string[][] {
|
||||
return catalog.actions.map((action) => Object.keys(action));
|
||||
}
|
||||
|
||||
/** Write the providers to `<tmp>/apps/`, the layout the index sits next to, and return that apps directory. */
|
||||
async function writeCatalogDir(providers: ProviderDefinition[]): Promise<string> {
|
||||
const catalogDir = await mkdtemp(join(tmpdir(), "catalog-store-"));
|
||||
temporaryDirectories.push(catalogDir);
|
||||
const root = await mkdtemp(join(tmpdir(), "catalog-store-"));
|
||||
temporaryDirectories.push(root);
|
||||
const catalogDir = join(root, "apps");
|
||||
await mkdir(catalogDir);
|
||||
for (const provider of providers) {
|
||||
await writeProviderFile(catalogDir, provider);
|
||||
}
|
||||
@@ -285,10 +375,32 @@ async function writeCatalogDir(providers: ProviderDefinition[]): Promise<string>
|
||||
return catalogDir;
|
||||
}
|
||||
|
||||
/** Write the index for the provider files already in `catalogDir` beside it, as the generator lays it out. */
|
||||
async function writeCatalogIndex(catalogDir: string, providers: ProviderDefinition[]): Promise<string> {
|
||||
const sources = await Promise.all(
|
||||
providers.map(async (provider) => {
|
||||
const file = `${provider.service}.json`;
|
||||
return { file, bytes: (await stat(join(catalogDir, file))).size, provider };
|
||||
}),
|
||||
);
|
||||
const indexFile = join(dirname(catalogDir), catalogIndexFileName);
|
||||
await writeFile(indexFile, JSON.stringify(createCatalogIndex(sources)));
|
||||
|
||||
return indexFile;
|
||||
}
|
||||
|
||||
function writeProviderFile(catalogDir: string, provider: ProviderDefinition): Promise<void> {
|
||||
return writeFile(join(catalogDir, `${provider.service}.json`), JSON.stringify(provider));
|
||||
}
|
||||
|
||||
/** Overwrite every provider file in `catalogDir` with spaces of the same byte length, so only its size survives. */
|
||||
async function blankProviderFiles(catalogDir: string): Promise<void> {
|
||||
for (const name of await readdir(catalogDir)) {
|
||||
const filePath = join(catalogDir, name);
|
||||
await writeFile(filePath, " ".repeat((await stat(filePath)).size));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generated catalog actions carry `followUpActions` and `asyncLifecycle` after the schemas, so the
|
||||
* fixture keeps keys on both sides of them.
|
||||
|
||||
+65
-21
@@ -2,7 +2,8 @@ import type { ActionDefinition, AuthType, ProviderDefinition, ProviderScenario }
|
||||
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { readProvidersWithLazySchemas } from "./catalog-lazy-schemas.ts";
|
||||
import { readCatalogIndex } from "./catalog-index.ts";
|
||||
import { indexedProvidersWithLazySchemas, readProvidersWithLazySchemas } from "./catalog-lazy-schemas.ts";
|
||||
import { sortProviders } from "./core/catalog.ts";
|
||||
import { resolveProviderScenario } from "./core/provider-scenarios.ts";
|
||||
|
||||
@@ -52,17 +53,20 @@ export type ProviderSummaryDefinition = Omit<RuntimeProviderDefinition, "actions
|
||||
export type CatalogStore = {
|
||||
providers: RuntimeProviderDefinition[];
|
||||
/**
|
||||
* Schema-free view of `providers`, pre-serialized once because the catalog is
|
||||
* immutable at runtime. Served verbatim by `/api/providers` so the dashboard
|
||||
* does not download every action schema on load, and so the response is
|
||||
* neither re-serialized per request nor able to drift from
|
||||
* {@link providerSummariesEtag}.
|
||||
* Schema-free view of `providers`, serialized once as UTF-8 bytes because the
|
||||
* catalog is immutable at runtime. Served verbatim by `/api/providers` so the
|
||||
* dashboard does not download every action schema on load, and so the
|
||||
* response is neither re-serialized per request nor able to drift from
|
||||
* {@link providerSummariesEtag}. Bytes rather than a string: the catalog
|
||||
* contains code points above U+00FF, which makes the engine keep a JS string
|
||||
* of it as UTF-16 at twice the size.
|
||||
*/
|
||||
providerSummariesJson: string;
|
||||
providerSummariesJson: Uint8Array<ArrayBuffer>;
|
||||
/**
|
||||
* Stable ETag for `providerSummariesJson`. The catalog is immutable at
|
||||
* runtime, so this is computed once and lets `/api/providers` answer
|
||||
* conditional requests with `304 Not Modified`.
|
||||
* Stable ETag for `providerSummariesJson`, computed from the JSON string
|
||||
* before it is encoded so it stays what earlier releases sent. The catalog is
|
||||
* immutable at runtime, so this is computed once and lets `/api/providers`
|
||||
* answer conditional requests with `304 Not Modified`.
|
||||
*/
|
||||
providerSummariesEtag: string;
|
||||
actions: RuntimeActionDefinition[];
|
||||
@@ -89,6 +93,12 @@ export interface LoadCatalogOptions extends ExecutableActionOptions {
|
||||
* `OOMOL_CONNECT_CATALOG_SCHEMA_CACHE_FILES`.
|
||||
*/
|
||||
lazySchemaCacheFiles?: number;
|
||||
/**
|
||||
* Startup index written by `npm run generate:catalog` next to the catalog directory
|
||||
* (`catalog/apps-index.json`). Given together with `lazySchemas`, startup reads it instead of every
|
||||
* provider file; ignored otherwise.
|
||||
*/
|
||||
lazySchemaIndexFile?: string;
|
||||
}
|
||||
|
||||
export function createCatalogStore(
|
||||
@@ -127,7 +137,8 @@ export function createCatalogStore(
|
||||
|
||||
return {
|
||||
providers: runtimeProviders,
|
||||
providerSummariesJson,
|
||||
// TextEncoder rather than Buffer: the Cloudflare Workers build shares this function.
|
||||
providerSummariesJson: new TextEncoder().encode(providerSummariesJson),
|
||||
providerSummariesEtag: weakEtag(providerSummariesJson),
|
||||
actions,
|
||||
actionsById: new Map(actions.map((action) => [action.id, action])),
|
||||
@@ -139,7 +150,9 @@ export function createCatalogStore(
|
||||
* Content-derived ETag using a pure-JS FNV-1a hash. Runtime-agnostic (no
|
||||
* `node:crypto`, so the Cloudflare Workers build shares this path) and computed
|
||||
* once per catalog. Emitted as a weak validator because the response body may
|
||||
* be gzip-transformed downstream.
|
||||
* be gzip-transformed downstream. It hashes UTF-16 code units and reports the
|
||||
* UTF-16 length, so it takes the JSON string rather than the UTF-8 bytes the
|
||||
* store keeps; hashing the bytes would change every ETag clients have cached.
|
||||
*/
|
||||
function weakEtag(content: string): string {
|
||||
let hash = 0x811c_9dc5;
|
||||
@@ -179,20 +192,26 @@ function toActionSummary(action: RuntimeActionDefinition): ActionSummaryDefiniti
|
||||
/**
|
||||
* Load generated provider catalog files from disk.
|
||||
*
|
||||
* With `lazySchemas`, files are read one at a time and each action keeps only its metadata; the
|
||||
* schemas stay on disk behind accessors backed by a small per-file cache, which trades a
|
||||
* synchronous read on schema access for a much smaller resident catalog.
|
||||
* By default every file is read and parsed in full, a few files at a time (see `readProviderFiles`).
|
||||
* With `lazySchemas`, each action keeps only its metadata; the schemas stay on disk behind accessors
|
||||
* backed by a small per-file cache, which trades a synchronous read on schema access for a much
|
||||
* smaller resident catalog. With `lazySchemaIndexFile` as well, the metadata comes from that one
|
||||
* index file and no provider file is read at startup.
|
||||
*/
|
||||
export async function loadCatalog(catalogDir: string, options: LoadCatalogOptions = {}): Promise<CatalogStore> {
|
||||
const entries = await readdir(catalogDir, { withFileTypes: true });
|
||||
const filePaths = entries
|
||||
const fileNames = entries
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
||||
.map((entry) => join(catalogDir, entry.name));
|
||||
.map((entry) => entry.name);
|
||||
const filePaths = fileNames.map((name) => join(catalogDir, name));
|
||||
const providers = options.lazySchemas
|
||||
? await readProvidersWithLazySchemas(filePaths, options.lazySchemaCacheFiles)
|
||||
: await Promise.all(
|
||||
filePaths.map(async (filePath) => JSON.parse(await readFile(filePath, "utf8")) as ProviderDefinition),
|
||||
);
|
||||
? options.lazySchemaIndexFile
|
||||
? indexedProvidersWithLazySchemas(
|
||||
await readCatalogIndex({ indexFile: options.lazySchemaIndexFile, catalogDir, fileNames }),
|
||||
options.lazySchemaCacheFiles,
|
||||
)
|
||||
: await readProvidersWithLazySchemas(filePaths, options.lazySchemaCacheFiles)
|
||||
: await readProviderFiles(filePaths);
|
||||
return createCatalogStore(providers, {
|
||||
executableActionIds: resolveExecutableActionIds(providers, options),
|
||||
});
|
||||
@@ -229,3 +248,28 @@ function createActionExecutionStatus(
|
||||
needsCredential: !provider.authTypes.includes("no_auth"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Provider files whose reads `readProviderFiles` keeps in flight before it parses them. */
|
||||
const providerReadBatchSize = 4;
|
||||
|
||||
/**
|
||||
* Read and parse provider files a few at a time, in directory order.
|
||||
*
|
||||
* Starting every read at once lets the reads outrun the parses that consume them, so the text of
|
||||
* most of the catalog is alive at the same moment, and the allocator keeps that peak resident long
|
||||
* after startup. A small batch bounds the unparsed text to a few files while still overlapping
|
||||
* enough reads that startup does not wait on one file at a time.
|
||||
*/
|
||||
async function readProviderFiles(filePaths: string[]): Promise<ProviderDefinition[]> {
|
||||
const providers: ProviderDefinition[] = [];
|
||||
for (let start = 0; start < filePaths.length; start += providerReadBatchSize) {
|
||||
const batch = filePaths.slice(start, start + providerReadBatchSize);
|
||||
providers.push(
|
||||
...(await Promise.all(
|
||||
batch.map(async (filePath) => JSON.parse(await readFile(filePath, "utf8")) as ProviderDefinition),
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
|
||||
+9
-9
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { IsolatePromiseCache } from "./isolate-promise-cache.ts";
|
||||
import { PromiseCache } from "./promise-cache.ts";
|
||||
|
||||
describe("IsolatePromiseCache", () => {
|
||||
describe("PromiseCache", () => {
|
||||
it("creates once per key and reuses the resolved promise", async () => {
|
||||
const cache = new IsolatePromiseCache<string>();
|
||||
const cache = new PromiseCache<string>();
|
||||
let creations = 0;
|
||||
const create = async (): Promise<string> => {
|
||||
creations++;
|
||||
@@ -16,7 +16,7 @@ describe("IsolatePromiseCache", () => {
|
||||
});
|
||||
|
||||
it("shares one in-flight promise between concurrent callers", async () => {
|
||||
const cache = new IsolatePromiseCache<string>();
|
||||
const cache = new PromiseCache<string>();
|
||||
let creations = 0;
|
||||
let release = (): void => {};
|
||||
const create = (): Promise<string> => {
|
||||
@@ -35,7 +35,7 @@ describe("IsolatePromiseCache", () => {
|
||||
});
|
||||
|
||||
it("evicts a rejected promise so the next caller retries", async () => {
|
||||
const cache = new IsolatePromiseCache<string>();
|
||||
const cache = new PromiseCache<string>();
|
||||
let attempts = 0;
|
||||
const create = async (): Promise<string> => {
|
||||
attempts++;
|
||||
@@ -51,7 +51,7 @@ describe("IsolatePromiseCache", () => {
|
||||
});
|
||||
|
||||
it("keeps rejecting concurrent callers of the failed attempt, then retries once", async () => {
|
||||
const cache = new IsolatePromiseCache<string>();
|
||||
const cache = new PromiseCache<string>();
|
||||
let attempts = 0;
|
||||
const create = async (): Promise<string> => {
|
||||
attempts++;
|
||||
@@ -69,7 +69,7 @@ describe("IsolatePromiseCache", () => {
|
||||
});
|
||||
|
||||
it("replaces the slot when the key changes", async () => {
|
||||
const cache = new IsolatePromiseCache<string>();
|
||||
const cache = new PromiseCache<string>();
|
||||
const create = (value: string) => async (): Promise<string> => value;
|
||||
|
||||
await expect(cache.get("a", create("first"))).resolves.toBe("first");
|
||||
@@ -78,7 +78,7 @@ describe("IsolatePromiseCache", () => {
|
||||
});
|
||||
|
||||
it("does not evict a newer key when an older promise rejects afterwards", async () => {
|
||||
const cache = new IsolatePromiseCache<string>();
|
||||
const cache = new PromiseCache<string>();
|
||||
let failOld = (): void => {};
|
||||
const oldEntry = cache.get(
|
||||
"old",
|
||||
@@ -103,7 +103,7 @@ describe("IsolatePromiseCache", () => {
|
||||
});
|
||||
|
||||
it("does not report an unhandled rejection when nobody awaits the evicted promise", async () => {
|
||||
const cache = new IsolatePromiseCache<string>();
|
||||
const cache = new PromiseCache<string>();
|
||||
const unhandled: unknown[] = [];
|
||||
const onUnhandled = (reason: unknown): void => {
|
||||
unhandled.push(reason);
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* A one-slot promise cache for work that should run once per process and be shared by every caller.
|
||||
*
|
||||
* Boot work such as loading a catalog, deriving a secret codec or importing an optional module is
|
||||
* memoized in module state so many requests pay for it once. Memoizing a *rejected* promise would
|
||||
* turn one transient failure into sustained errors for as long as the process (or Workers isolate)
|
||||
* lives, with no self-healing in between. Each slot therefore drops itself as soon as its promise
|
||||
* rejects, so the next caller retries, while concurrent callers still share the in-flight promise
|
||||
* and do the work once.
|
||||
*/
|
||||
export class PromiseCache<T> {
|
||||
private entry: PromiseCacheEntry<T> | undefined;
|
||||
|
||||
/**
|
||||
* Return the cached promise for `key`, creating and memoizing it when the slot holds another key
|
||||
* or is empty. `create` runs synchronously on a miss, so a burst of concurrent callers arriving
|
||||
* before the first one settles all share the same promise.
|
||||
*/
|
||||
get(key: string, create: () => Promise<T>): Promise<T> {
|
||||
if (this.entry?.key === key) {
|
||||
return this.entry.value;
|
||||
}
|
||||
|
||||
const entry: PromiseCacheEntry<T> = { key, value: create() };
|
||||
this.entry = entry;
|
||||
// Evict only while this entry still owns the slot: a different key may have replaced it while
|
||||
// the promise was in flight, and that newer entry must survive.
|
||||
void entry.value.catch(() => {
|
||||
if (this.entry === entry) {
|
||||
this.entry = undefined;
|
||||
}
|
||||
});
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
interface PromiseCacheEntry<T> {
|
||||
key: string;
|
||||
value: Promise<T>;
|
||||
}
|
||||
+13
-1
@@ -7,7 +7,7 @@ import type { ActionRunner, ActionRunResult } from "./server/actions/action-runn
|
||||
import type { RuntimeGrant } from "./server/storage/runtime-token-service.ts";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/server";
|
||||
|
||||
import { McpServer } from "@modelcontextprotocol/server";
|
||||
import { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
|
||||
import * as z from "zod/v4";
|
||||
import { ConnectionError } from "./connection-service.ts";
|
||||
import { createActionSearchIndexProvider, searchActions as searchActionIndex } from "./core/action-search.ts";
|
||||
@@ -164,6 +164,18 @@ export function createMcpServer(options: IMcpServerOptions): McpServer {
|
||||
return server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve one Streamable HTTP MCP request statelessly: a fresh server per request, JSON responses, closed afterwards.
|
||||
*/
|
||||
export async function handleMcpRequest(request: Request, options: IMcpServerOptions): Promise<Response> {
|
||||
const handler = createMcpHandler(() => createMcpServer(options), { legacy: "stateless", responseMode: "json" });
|
||||
try {
|
||||
return await handler.fetch(request);
|
||||
} finally {
|
||||
await handler.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function listConnections(options: IMcpServerOptions, service: string | undefined): Promise<ToolPayload> {
|
||||
let policy: ActionPolicySnapshot;
|
||||
try {
|
||||
|
||||
@@ -43,9 +43,9 @@ afterAll(async () => {
|
||||
});
|
||||
|
||||
describe("createRuntimeJwtVerifier", () => {
|
||||
it("stays disabled when no JWT settings are configured", () => {
|
||||
expect(createRuntimeJwtVerifier({})).toBeUndefined();
|
||||
expect(createRuntimeJwtVerifier({ jwksUri: " ", issuer: " ", audience: " " })).toBeUndefined();
|
||||
it("stays disabled when no JWT settings are configured", async () => {
|
||||
await expect(createRuntimeJwtVerifier({})).resolves.toBeUndefined();
|
||||
await expect(createRuntimeJwtVerifier({ jwksUri: " ", issuer: " ", audience: " " })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -61,14 +61,14 @@ describe("createRuntimeJwtVerifier", () => {
|
||||
config: { jwksUri: "https://issuer.example.com/jwks", issuer },
|
||||
missing: "OOMOL_CONNECT_JWT_AUDIENCE",
|
||||
},
|
||||
])("rejects incomplete settings missing $missing", ({ config, missing }) => {
|
||||
expect(() => createRuntimeJwtVerifier(config)).toThrow(missing);
|
||||
])("rejects incomplete settings missing $missing", async ({ config, missing }) => {
|
||||
await expect(createRuntimeJwtVerifier(config)).rejects.toThrow(missing);
|
||||
});
|
||||
|
||||
it.each(["not a URL", "file:///tmp/jwks.json", "http://idp.example.com/jwks", "http://10.0.0.1/jwks"])(
|
||||
"rejects invalid or insecure JWKS URI %s",
|
||||
(value) => {
|
||||
expect(() => createRuntimeJwtVerifier({ jwksUri: value, issuer, audience })).toThrow(
|
||||
async (value) => {
|
||||
await expect(createRuntimeJwtVerifier({ jwksUri: value, issuer, audience })).rejects.toThrow(
|
||||
"OOMOL_CONNECT_JWKS_URI must be a valid HTTPS URL or HTTP loopback URL.",
|
||||
);
|
||||
},
|
||||
@@ -80,19 +80,19 @@ describe("createRuntimeJwtVerifier", () => {
|
||||
"http://localhost./jwks",
|
||||
"http://127.0.0.2/jwks",
|
||||
"http://[::1]/jwks",
|
||||
])("accepts secure or loopback JWKS URI %s", (value) => {
|
||||
expect(createRuntimeJwtVerifier({ jwksUri: value, issuer, audience })).toBeTypeOf("function");
|
||||
])("accepts secure or loopback JWKS URI %s", async (value) => {
|
||||
await expect(createRuntimeJwtVerifier({ jwksUri: value, issuer, audience })).resolves.toBeTypeOf("function");
|
||||
});
|
||||
|
||||
it("accepts a signed token with the expected issuer and audience", async () => {
|
||||
const verifier = configuredVerifier();
|
||||
const verifier = await configuredVerifier();
|
||||
const token = await signToken();
|
||||
|
||||
await expect(verifier(token)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("accepts an audience array containing the expected audience", async () => {
|
||||
const verifier = configuredVerifier();
|
||||
const verifier = await configuredVerifier();
|
||||
const token = await signToken({ tokenAudience: ["https://other.example.com", audience] });
|
||||
|
||||
await expect(verifier(token)).resolves.toBe(true);
|
||||
@@ -140,11 +140,11 @@ describe("createRuntimeJwtVerifier", () => {
|
||||
token: async () => await signToken({ notBefore: "1h" }),
|
||||
},
|
||||
])("rejects $name", async ({ token }) => {
|
||||
await expect(configuredVerifier()(await token())).resolves.toBe(false);
|
||||
await expect((await configuredVerifier())(await token())).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("fails closed when the JWKS response is invalid", async () => {
|
||||
const verifier = createRuntimeJwtVerifier({
|
||||
const verifier = await createRuntimeJwtVerifier({
|
||||
jwksUri: jwksUri.replace("/jwks", "/invalid"),
|
||||
issuer,
|
||||
audience,
|
||||
@@ -183,8 +183,8 @@ async function signToken(options: SignTokenOptions = {}): Promise<string> {
|
||||
return await token.sign(options.key ?? privateKey);
|
||||
}
|
||||
|
||||
function configuredVerifier(): (token: string) => Promise<boolean> {
|
||||
const verifier = createRuntimeJwtVerifier({ jwksUri, issuer, audience });
|
||||
async function configuredVerifier(): Promise<(token: string) => Promise<boolean>> {
|
||||
const verifier = await createRuntimeJwtVerifier({ jwksUri, issuer, audience });
|
||||
if (!verifier) {
|
||||
throw new Error("JWT verifier was not configured.");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { createRemoteJWKSet, jwtVerify } from "jose";
|
||||
|
||||
export interface RuntimeJwtConfig {
|
||||
jwksUri?: string;
|
||||
issuer?: string;
|
||||
@@ -10,8 +8,9 @@ export type RuntimeJwtVerifier = (token: string) => Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Creates a JWT access-token verifier when all runtime JWT settings are configured.
|
||||
* jose is imported only once the settings pass validation, so a runtime without JWT authentication never loads it.
|
||||
*/
|
||||
export function createRuntimeJwtVerifier(config: RuntimeJwtConfig): RuntimeJwtVerifier | undefined {
|
||||
export async function createRuntimeJwtVerifier(config: RuntimeJwtConfig): Promise<RuntimeJwtVerifier | undefined> {
|
||||
const jwksUri = config.jwksUri?.trim();
|
||||
const issuer = config.issuer?.trim();
|
||||
const audience = config.audience?.trim();
|
||||
@@ -40,6 +39,7 @@ export function createRuntimeJwtVerifier(config: RuntimeJwtConfig): RuntimeJwtVe
|
||||
throw new Error("OOMOL_CONNECT_JWKS_URI must be a valid HTTPS URL or HTTP loopback URL.");
|
||||
}
|
||||
|
||||
const { createRemoteJWKSet, jwtVerify } = await import("jose");
|
||||
const jwks = createRemoteJWKSet(url);
|
||||
return async (token) => {
|
||||
try {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Logger } from "./logger.ts";
|
||||
import type { ISecretCodec } from "./secrets/secret-codec-core.ts";
|
||||
|
||||
import { ActionPolicyService, parseActionPolicyList } from "../core/action-policy.ts";
|
||||
import { PromiseCache } from "../core/promise-cache.ts";
|
||||
import {
|
||||
parseEgressTrustedHosts,
|
||||
parsePrivateNetworkAccessFlag,
|
||||
@@ -17,8 +18,8 @@ import { executorModules } from "../providers/registry.cloudflare.generated.ts";
|
||||
import { isConsoleShellPath } from "./api/console-paths.ts";
|
||||
import { loadCatalogFromAssets } from "./cloudflare/catalog-assets.ts";
|
||||
import { readPositiveInteger, resolvePublicOrigin } from "./cloudflare/cloudflare-env.ts";
|
||||
import { IsolatePromiseCache } from "./cloudflare/isolate-promise-cache.ts";
|
||||
import { createConnectApp } from "./connect-app.ts";
|
||||
import { preloadOptionalServerModules } from "./connect-server.ts";
|
||||
import { KVTransitFileService } from "./files/kv-transit-files.ts";
|
||||
import { R2TransitFileService } from "./files/r2-transit-files.ts";
|
||||
import { createWorkerSecretCodec } from "./secrets/worker-secret-codec.ts";
|
||||
@@ -30,9 +31,9 @@ interface CloudflareExecutionContext {
|
||||
passThroughOnException(): void;
|
||||
}
|
||||
|
||||
const catalogCache = new IsolatePromiseCache<CatalogStore>();
|
||||
const secretCodecCache = new IsolatePromiseCache<ISecretCodec>();
|
||||
const appCache = new IsolatePromiseCache<ConnectApp>();
|
||||
const catalogCache = new PromiseCache<CatalogStore>();
|
||||
const secretCodecCache = new PromiseCache<ISecretCodec>();
|
||||
const appCache = new PromiseCache<ConnectApp>();
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: CloudflareEnv, _ctx: CloudflareExecutionContext): Promise<Response> {
|
||||
@@ -54,6 +55,10 @@ async function createCloudflareApp(env: CloudflareEnv, publicOrigin: string): Pr
|
||||
if (!assets) {
|
||||
throw new Error("Cloudflare ASSETS binding is required to load the catalog");
|
||||
}
|
||||
// The Node server defers the MCP and docs modules to their first request so its startup graph stays small.
|
||||
// Workers keep paying their evaluation here, at app creation, so the first /mcp or /docs request of an isolate
|
||||
// is served the same way as every later one.
|
||||
await preloadOptionalServerModules();
|
||||
const secretCodec = await createSecretCodec(env.OOMOL_CONNECT_ENCRYPTION_KEY);
|
||||
return await createConnectApp({
|
||||
catalog: await loadCatalogOnce(assets),
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* A one-slot promise cache scoped to a Workers isolate.
|
||||
*
|
||||
* An isolate is reused across many requests, so boot work is memoized in module state. Memoizing a
|
||||
* *rejected* promise turns one transient failure into sustained errors until the isolate is
|
||||
* recycled, with no self-healing in between. Each slot therefore drops itself as soon as its
|
||||
* promise rejects, so the next request retries, while concurrent callers still share the in-flight
|
||||
* promise and do the work once.
|
||||
*/
|
||||
export class IsolatePromiseCache<T> {
|
||||
private entry: IsolateCacheEntry<T> | undefined;
|
||||
|
||||
/**
|
||||
* Return the cached promise for `key`, creating and memoizing it when the slot holds another key
|
||||
* or is empty. `create` runs synchronously on a miss, so a burst of concurrent callers arriving
|
||||
* before the first one settles all share the same promise.
|
||||
*/
|
||||
get(key: string, create: () => Promise<T>): Promise<T> {
|
||||
if (this.entry?.key === key) {
|
||||
return this.entry.value;
|
||||
}
|
||||
|
||||
const entry: IsolateCacheEntry<T> = { key, value: create() };
|
||||
this.entry = entry;
|
||||
// Evict only while this entry still owns the slot: a different key may have replaced it while
|
||||
// the promise was in flight, and that newer entry must survive.
|
||||
void entry.value.catch(() => {
|
||||
if (this.entry === entry) {
|
||||
this.entry = undefined;
|
||||
}
|
||||
});
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
interface IsolateCacheEntry<T> {
|
||||
key: string;
|
||||
value: Promise<T>;
|
||||
}
|
||||
@@ -14,18 +14,16 @@ import type { IIdempotencyStore } from "./storage/idempotency-store.ts";
|
||||
import type { IRuntimePolicyStore } from "./storage/runtime-policy-store.ts";
|
||||
import type { RunLogCaller, RunLogListInput } from "./storage/runtime-store.ts";
|
||||
import type { RuntimeGrant, RuntimeTokenService } from "./storage/runtime-token-service.ts";
|
||||
import type { Context } from "hono";
|
||||
import type { Context, MiddlewareHandler } from "hono";
|
||||
|
||||
import { createMcpHandler } from "@modelcontextprotocol/server";
|
||||
import { Scalar } from "@scalar/hono-api-reference";
|
||||
import { Hono } from "hono";
|
||||
import { compress } from "hono/compress";
|
||||
import { ConnectionError, defaultConnectionName } from "../connection-service.ts";
|
||||
import { ActionPolicyService, emptyPolicyRules } from "../core/action-policy.ts";
|
||||
import { DEFAULT_ACTION_SEARCH_LIMIT, createActionSearchIndexProvider, searchActions } from "../core/action-search.ts";
|
||||
import { optionalRecord, optionalString, requiredString, requiredStringArray } from "../core/cast.ts";
|
||||
import { PromiseCache } from "../core/promise-cache.ts";
|
||||
import { MarketplaceError } from "../marketplace/marketplace-service.ts";
|
||||
import { createMcpServer, listMcpToolSummaries } from "../mcp.ts";
|
||||
import { OAuthClientConfigError, OAuthClientConfigService } from "../oauth/oauth-client-config-service.ts";
|
||||
import { OAuthFlowError, OAuthFlowService } from "../oauth/oauth-flow-service.ts";
|
||||
import {
|
||||
@@ -61,6 +59,50 @@ import { ProxyRunner } from "./proxy/proxy-runner.ts";
|
||||
import { decodeRunLogCursor } from "./storage/runtime-store.ts";
|
||||
import { summarizeRuntimeToken } from "./storage/runtime-token-service.ts";
|
||||
|
||||
type McpModule = typeof import("../mcp.ts");
|
||||
|
||||
/**
|
||||
* The MCP module pulls in @modelcontextprotocol/server and zod, which no other startup path needs, so it is
|
||||
* loaded on the first /mcp request. The cache shares one in-flight import; a rejected import is evicted so
|
||||
* transient resolution failures retry, while evaluation errors rethrow identically.
|
||||
*/
|
||||
const mcpModule = new PromiseCache<McpModule>();
|
||||
|
||||
function loadMcpModule(): Promise<McpModule> {
|
||||
return mcpModule.get("", () => import("../mcp.ts"));
|
||||
}
|
||||
|
||||
/** The Scalar API reference is only rendered for /docs, so its package is loaded on the first request. */
|
||||
const docsHandler = new PromiseCache<MiddlewareHandler>();
|
||||
|
||||
function loadDocsHandler(): Promise<MiddlewareHandler> {
|
||||
return docsHandler.get("", async () => {
|
||||
const { Scalar } = await import("@scalar/hono-api-reference");
|
||||
return Scalar({
|
||||
pageTitle: "OOMOL Connect API Reference",
|
||||
url: "/openapi.json",
|
||||
theme: "default",
|
||||
darkMode: false,
|
||||
forceDarkModeState: "light",
|
||||
customCss: `
|
||||
:root {
|
||||
--scalar-color-accent: rgb(59, 99, 251);
|
||||
--scalar-background-accent: rgba(59, 99, 251, 0.12);
|
||||
}
|
||||
`,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Import and evaluate the modules the /mcp and /docs routes defer to their first request. The Node server never
|
||||
* calls this, so its startup graph stays small; Cloudflare Workers call it at app creation so an isolate keeps
|
||||
* paying module evaluation up front rather than on its first MCP or docs request.
|
||||
*/
|
||||
export async function preloadOptionalServerModules(): Promise<void> {
|
||||
await Promise.all([loadMcpModule(), loadDocsHandler()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dependencies required to construct the local connector server.
|
||||
*/
|
||||
@@ -166,22 +208,7 @@ export class ConnectServer {
|
||||
}),
|
||||
),
|
||||
);
|
||||
app.get(
|
||||
"/docs",
|
||||
Scalar({
|
||||
pageTitle: "OOMOL Connect API Reference",
|
||||
url: "/openapi.json",
|
||||
theme: "default",
|
||||
darkMode: false,
|
||||
forceDarkModeState: "light",
|
||||
customCss: `
|
||||
:root {
|
||||
--scalar-color-accent: rgb(59, 99, 251);
|
||||
--scalar-background-accent: rgba(59, 99, 251, 0.12);
|
||||
}
|
||||
`,
|
||||
}),
|
||||
);
|
||||
app.get("/docs", async (context, next) => (await loadDocsHandler())(context, next));
|
||||
|
||||
// Schema-free listing. The action detail view loads full schemas on demand
|
||||
// from /api/actions/:actionId. The catalog is immutable at runtime, so the
|
||||
@@ -227,7 +254,7 @@ export class ConnectServer {
|
||||
app.post("/mcp", (context) => this.handleMcp(context));
|
||||
app.get("/mcp", (context) => this.rejectMcpMethod(context));
|
||||
app.delete("/mcp", (context) => this.rejectMcpMethod(context));
|
||||
app.get("/mcp/tools", (context) => context.json({ tools: listMcpToolSummaries() }));
|
||||
app.get("/mcp/tools", async (context) => context.json({ tools: (await loadMcpModule()).listMcpToolSummaries() }));
|
||||
|
||||
this.options.registerStaticRoutes?.(app);
|
||||
app.onError((error, context) => {
|
||||
@@ -790,24 +817,16 @@ export class ConnectServer {
|
||||
}
|
||||
|
||||
private async handleMcp(context: Context): Promise<Response> {
|
||||
const handler = createMcpHandler(
|
||||
() =>
|
||||
createMcpServer({
|
||||
catalog: this.options.catalog,
|
||||
connections: this.options.connections,
|
||||
actions: this.options.actions,
|
||||
actionSearch: this.actionSearch,
|
||||
getPolicySnapshot: () => this.getPolicySnapshot(context),
|
||||
runtimeGrant: readRuntimeGrant(context),
|
||||
signal: context.req.raw.signal,
|
||||
}),
|
||||
{ legacy: "stateless", responseMode: "json" },
|
||||
);
|
||||
try {
|
||||
return await handler.fetch(context.req.raw);
|
||||
} finally {
|
||||
await handler.close();
|
||||
}
|
||||
const { handleMcpRequest } = await loadMcpModule();
|
||||
return await handleMcpRequest(context.req.raw, {
|
||||
catalog: this.options.catalog,
|
||||
connections: this.options.connections,
|
||||
actions: this.options.actions,
|
||||
actionSearch: this.actionSearch,
|
||||
getPolicySnapshot: () => this.getPolicySnapshot(context),
|
||||
runtimeGrant: readRuntimeGrant(context),
|
||||
signal: context.req.raw.signal,
|
||||
});
|
||||
}
|
||||
|
||||
private rejectMcpMethod(context: Context): Response {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// Packages that only a configured feature or a request path needs. They must stay behind a dynamic import so the
|
||||
// default startup graph, and therefore resident memory, does not pay for them: connect-server.ts loads the MCP
|
||||
// module and Scalar on first use, index.ts the S3 backend, node-runtime-database.ts pg, runtime-jwt.ts jose.
|
||||
// @modelcontextprotocol/client depends on zod and jose, so pinning it statically would pull both back in.
|
||||
const lazyOnlyPackages = [
|
||||
"@modelcontextprotocol/server",
|
||||
"@modelcontextprotocol/client",
|
||||
"zod",
|
||||
"jose",
|
||||
"pg",
|
||||
"@aws-sdk/client-s3",
|
||||
"@scalar/hono-api-reference",
|
||||
];
|
||||
|
||||
// The eager graph is a few dozen first-party files; far fewer means an edge pattern stopped matching and the
|
||||
// assertion below would pass vacuously.
|
||||
const minimumEagerFiles = 40;
|
||||
|
||||
const importEdgePattern = /^import\s+(?!type\s)[^"']*?from\s+["']([^"']+)["']|^import\s+["']([^"']+)["']/gm;
|
||||
const reexportEdgePattern = /^export\s+(?:\*(?:\s+as\s+[\w$]+)?|\{[^}]*\})\s+from\s+["']([^"']+)["']/gm;
|
||||
|
||||
interface EagerGraph {
|
||||
files: Set<string>;
|
||||
packages: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* First-party modules and bare package specifiers reachable from `entry` through static, non-type `import` and
|
||||
* `export ... from` edges. Dynamic `import()` edges are not followed: they are what keeps a module lazy.
|
||||
*/
|
||||
function collectEagerGraph(entry: string): EagerGraph {
|
||||
const files = new Set<string>();
|
||||
const packages = new Set<string>();
|
||||
const queue = [entry];
|
||||
while (queue.length > 0) {
|
||||
const file = queue.pop()!;
|
||||
if (files.has(file)) {
|
||||
continue;
|
||||
}
|
||||
files.add(file);
|
||||
const source = readFileSync(file, "utf8");
|
||||
for (const specifier of staticSpecifiers(source)) {
|
||||
if (specifier.startsWith(".")) {
|
||||
queue.push(resolve(dirname(file), specifier));
|
||||
} else if (!specifier.startsWith("node:") && !specifier.startsWith("bun:")) {
|
||||
packages.add(packageName(specifier));
|
||||
}
|
||||
}
|
||||
}
|
||||
return { files, packages };
|
||||
}
|
||||
|
||||
function staticSpecifiers(source: string): string[] {
|
||||
const specifiers: string[] = [];
|
||||
for (const match of source.matchAll(importEdgePattern)) {
|
||||
specifiers.push(match[1] ?? match[2]);
|
||||
}
|
||||
for (const match of source.matchAll(reexportEdgePattern)) {
|
||||
specifiers.push(match[1]);
|
||||
}
|
||||
return specifiers;
|
||||
}
|
||||
|
||||
function packageName(specifier: string): string {
|
||||
const segments = specifier.split("/");
|
||||
return segments.slice(0, specifier.startsWith("@") ? 2 : 1).join("/");
|
||||
}
|
||||
|
||||
describe("eager import graph", () => {
|
||||
it.each(["index.ts", "cloudflare.ts"])("keeps feature-only packages out of the static graph of %s", (entry) => {
|
||||
const graph = collectEagerGraph(resolve(import.meta.dirname, entry));
|
||||
expect(graph.files.size).toBeGreaterThan(minimumEagerFiles);
|
||||
expect(graph.packages.has("hono")).toBe(true);
|
||||
expect([...graph.packages].filter((name) => lazyOnlyPackages.includes(name))).toEqual([]);
|
||||
});
|
||||
|
||||
it("follows re-export edges", () => {
|
||||
const graph = collectEagerGraph(resolve(import.meta.dirname, "index.ts"));
|
||||
// secret-codec.ts reaches secret-codec-core.ts only through `export { ... } from`.
|
||||
expect(graph.files.has(resolve(import.meta.dirname, "secrets/secret-codec-core.ts"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { S3TransitFileService } from "./s3-transit-files.ts";
|
||||
import { createS3TransitClient, S3TransitFileService } from "./s3-transit-files.ts";
|
||||
|
||||
describe("S3TransitFileService", () => {
|
||||
it("shares transit files across service instances", async () => {
|
||||
@@ -116,6 +116,43 @@ describe("S3TransitFileService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("createS3TransitClient", () => {
|
||||
it("configures the client from the server settings and keeps checksums opt-in", async () => {
|
||||
const client = createS3TransitClient({
|
||||
region: "eu-west-1",
|
||||
endpoint: "http://127.0.0.1:9000",
|
||||
forcePathStyle: true,
|
||||
credentials: { accessKeyId: "id", secretAccessKey: "secret", sessionToken: "token" },
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(client.config.region()).resolves.toBe("eu-west-1");
|
||||
expect(client.config.forcePathStyle).toBe(true);
|
||||
await expect(client.config.endpoint?.()).resolves.toMatchObject({ hostname: "127.0.0.1", port: 9000 });
|
||||
await expect(client.config.credentials()).resolves.toMatchObject({
|
||||
accessKeyId: "id",
|
||||
secretAccessKey: "secret",
|
||||
sessionToken: "token",
|
||||
});
|
||||
await expect(client.config.requestChecksumCalculation()).resolves.toBe("WHEN_REQUIRED");
|
||||
await expect(client.config.responseChecksumValidation()).resolves.toBe("WHEN_REQUIRED");
|
||||
} finally {
|
||||
client.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves endpoint and credentials to the SDK defaults when they are not configured", () => {
|
||||
const client = createS3TransitClient({ region: "us-east-1", forcePathStyle: false });
|
||||
|
||||
try {
|
||||
expect(client.config.endpoint).toBeUndefined();
|
||||
expect(client.config.forcePathStyle).toBe(false);
|
||||
} finally {
|
||||
client.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function createService(
|
||||
client: S3Client,
|
||||
options: { ttlSeconds?: number; maxBytes?: number } = {},
|
||||
|
||||
@@ -33,6 +33,36 @@ export interface S3TransitFileOptions {
|
||||
maxBytes: number;
|
||||
}
|
||||
|
||||
export interface S3TransitFileCredentials {
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
sessionToken?: string;
|
||||
}
|
||||
|
||||
/** S3 client settings as the Node server reads them from OOMOL_CONNECT_S3_*. */
|
||||
export interface S3TransitClientOptions {
|
||||
region: string;
|
||||
endpoint?: string;
|
||||
forcePathStyle: boolean;
|
||||
credentials?: S3TransitFileCredentials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the S3 client for the transit-file backend. Checksum calculation stays opt-in so S3-compatible stores
|
||||
* without CRC support keep working. The server imports this module only when OOMOL_CONNECT_TRANSIT_FILE_BACKEND=s3,
|
||||
* so the AWS SDK stays out of the default startup graph.
|
||||
*/
|
||||
export function createS3TransitClient(options: S3TransitClientOptions): S3Client {
|
||||
return new S3Client({
|
||||
region: options.region,
|
||||
endpoint: options.endpoint,
|
||||
forcePathStyle: options.forcePathStyle,
|
||||
requestChecksumCalculation: "WHEN_REQUIRED",
|
||||
responseChecksumValidation: "WHEN_REQUIRED",
|
||||
credentials: options.credentials,
|
||||
});
|
||||
}
|
||||
|
||||
export class S3TransitFileService implements IStagedTransitFileService {
|
||||
private readonly client: S3Client;
|
||||
private readonly bucket: string;
|
||||
|
||||
+61
-26
@@ -1,7 +1,6 @@
|
||||
import type { IStagedTransitFileService } from "./files/transit-file-store.ts";
|
||||
import type { ServerType } from "@hono/node-server";
|
||||
|
||||
import { S3Client } from "@aws-sdk/client-s3";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
@@ -20,7 +19,6 @@ import { createRuntimeJwtVerifier } from "./api/runtime-jwt.ts";
|
||||
import { registerStaticRoutes } from "./api/static-routes.ts";
|
||||
import { createConnectApp } from "./connect-app.ts";
|
||||
import { cleanupStagedTransitFiles, createNodeTransitFileUpload } from "./files/node-transit-file-upload.ts";
|
||||
import { S3TransitFileService } from "./files/s3-transit-files.ts";
|
||||
import { TransitFileService } from "./files/transit-files.ts";
|
||||
import { logger } from "./logger.ts";
|
||||
import { createSecretCodec } from "./secrets/secret-codec.ts";
|
||||
@@ -50,6 +48,8 @@ const [command, ...rest] = process.argv.slice(2);
|
||||
|
||||
try {
|
||||
if (command === undefined) {
|
||||
// Resolves once `serve()` has requested the listener, before it is up; a bind failure and the server's lifetime
|
||||
// are deliberately not awaited here, see `main`.
|
||||
await main();
|
||||
} else if (command === "migrate" && rest.length === 0) {
|
||||
await runMigrateCommand();
|
||||
@@ -58,10 +58,19 @@ try {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, command === "migrate" ? "migrate failed" : "connect server failed");
|
||||
process.exitCode = 1;
|
||||
reportFailure(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the server and resolve as soon as `serve()` returns, which only requests the listener: the bind completes
|
||||
* later, and a bind failure such as EADDRINUSE surfaces as the server's unhandled 'error' event, which ends the
|
||||
* process with exit code 1 without closing the runtime database, under Bun as under Node. Waiting for SIGINT/SIGTERM
|
||||
* and closing the runtime database afterwards belong to a promise chain that is deliberately not awaited, so this
|
||||
* entry module finishes evaluating while the server runs. A Bun single-file executable releases the executable's
|
||||
* module-graph pages (the bundle plus every embedded catalog file read during startup) with one
|
||||
* madvise(MADV_DONTNEED) once the entry module has evaluated; a top-level await spanning the server's lifetime keeps
|
||||
* about 85 MB of them resident on Linux, and under Bun also turns a listen error into a hang instead of an exit.
|
||||
*/
|
||||
async function main(): Promise<void> {
|
||||
setPrivateNetworkAccessAllowed(parsePrivateNetworkAccessFlag(process.env.OOMOL_CONNECT_ALLOW_PRIVATE_NETWORK));
|
||||
setEgressTrustedHosts(parseEgressTrustedHosts(process.env.OOMOL_CONNECT_EGRESS_TRUSTED_HOSTS));
|
||||
@@ -69,7 +78,7 @@ async function main(): Promise<void> {
|
||||
const secretCodec = createSecretCodec(process.env.OOMOL_CONNECT_ENCRYPTION_KEY);
|
||||
const adminToken = process.env.OOMOL_CONNECT_ADMIN_TOKEN;
|
||||
const runtimeToken = process.env.OOMOL_CONNECT_RUNTIME_TOKEN;
|
||||
const verifyRuntimeJwt = createRuntimeJwtVerifier({
|
||||
const verifyRuntimeJwt = await createRuntimeJwtVerifier({
|
||||
jwksUri: process.env.OOMOL_CONNECT_JWKS_URI,
|
||||
issuer: process.env.OOMOL_CONNECT_JWT_ISSUER,
|
||||
audience: process.env.OOMOL_CONNECT_JWT_AUDIENCE,
|
||||
@@ -84,14 +93,30 @@ async function main(): Promise<void> {
|
||||
|
||||
await mkdir(dataDir, { recursive: true });
|
||||
const assets = await resolveServerAssets();
|
||||
const lazySchemas = parseBooleanEnv("OOMOL_CONNECT_CATALOG_LAZY_SCHEMAS");
|
||||
if (lazySchemas && !assets.catalogIndexFile) {
|
||||
logger.warn(
|
||||
{ catalogDir: assets.catalogDir },
|
||||
"catalog index is missing; reading every provider file at startup. Run npm run generate:catalog to write catalog/apps-index.json",
|
||||
);
|
||||
}
|
||||
const catalog = await loadCatalog(assets.catalogDir, {
|
||||
executableServices: Object.keys(executorModules),
|
||||
lazySchemas: parseBooleanEnv("OOMOL_CONNECT_CATALOG_LAZY_SCHEMAS"),
|
||||
lazySchemas,
|
||||
lazySchemaCacheFiles: readPositiveIntegerEnv(
|
||||
"OOMOL_CONNECT_CATALOG_SCHEMA_CACHE_FILES",
|
||||
defaultLazySchemaCacheFiles,
|
||||
),
|
||||
lazySchemaIndexFile: assets.catalogIndexFile,
|
||||
});
|
||||
logger.info(
|
||||
{
|
||||
providers: catalog.providers.length,
|
||||
actions: catalog.actions.length,
|
||||
catalogIndex: lazySchemas && assets.catalogIndexFile !== undefined,
|
||||
},
|
||||
"catalog loaded",
|
||||
);
|
||||
const runtimeDatabase = databaseUrl
|
||||
? await createNodeRuntimeDatabase({
|
||||
backend: "postgresql",
|
||||
@@ -113,7 +138,7 @@ async function main(): Promise<void> {
|
||||
});
|
||||
|
||||
try {
|
||||
const transitFiles = createTransitFileService();
|
||||
const transitFiles = await createTransitFileService();
|
||||
const transitFileTempDir = join(dataDir, "tmp", "transit-files");
|
||||
await transitFiles.cleanupExpired();
|
||||
await cleanupStagedTransitFiles(transitFileTempDir, transitFileTtlSeconds * 1000);
|
||||
@@ -164,12 +189,23 @@ async function main(): Promise<void> {
|
||||
},
|
||||
);
|
||||
|
||||
await waitForShutdown(server);
|
||||
} finally {
|
||||
// A startup failure above closes the database in the catch. From here this chain owns it; a bind failure never
|
||||
// reaches it and ends the process through the server's unhandled 'error' event instead.
|
||||
waitForShutdown(server)
|
||||
.finally(() => runtimeDatabase.close())
|
||||
.catch(reportFailure);
|
||||
} catch (error) {
|
||||
await runtimeDatabase.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Log a startup, migrate, or shutdown failure; the process exits with code 1 once its handles are closed. */
|
||||
function reportFailure(error: unknown): void {
|
||||
logger.error({ err: error }, command === "migrate" ? "migrate failed" : "connect server failed");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
async function runMigrateCommand(): Promise<void> {
|
||||
if (!databaseUrl) {
|
||||
logger.info(sqliteMigrationsNotice);
|
||||
@@ -219,7 +255,7 @@ function readPositiveIntegerEnv(name: string, fallback: number): number {
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function createTransitFileService(): IStagedTransitFileService {
|
||||
async function createTransitFileService(): Promise<IStagedTransitFileService> {
|
||||
const backend = process.env.OOMOL_CONNECT_TRANSIT_FILE_BACKEND ?? "local";
|
||||
switch (backend) {
|
||||
case "local":
|
||||
@@ -238,23 +274,22 @@ function createTransitFileService(): IStagedTransitFileService {
|
||||
);
|
||||
}
|
||||
|
||||
const client = new S3Client({
|
||||
region: optionalEnv("OOMOL_CONNECT_S3_REGION") ?? "us-east-1",
|
||||
endpoint: optionalEnv("OOMOL_CONNECT_S3_ENDPOINT"),
|
||||
forcePathStyle: parseBooleanEnv("OOMOL_CONNECT_S3_FORCE_PATH_STYLE"),
|
||||
requestChecksumCalculation: "WHEN_REQUIRED",
|
||||
responseChecksumValidation: "WHEN_REQUIRED",
|
||||
credentials:
|
||||
accessKeyId && secretAccessKey
|
||||
? {
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
sessionToken: optionalEnv("OOMOL_CONNECT_S3_SESSION_TOKEN"),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
// @aws-sdk/client-s3 is loaded only for this backend; the default local backend never pays for it.
|
||||
const { createS3TransitClient, S3TransitFileService } = await import("./files/s3-transit-files.ts");
|
||||
return new S3TransitFileService({
|
||||
client,
|
||||
client: createS3TransitClient({
|
||||
region: optionalEnv("OOMOL_CONNECT_S3_REGION") ?? "us-east-1",
|
||||
endpoint: optionalEnv("OOMOL_CONNECT_S3_ENDPOINT"),
|
||||
forcePathStyle: parseBooleanEnv("OOMOL_CONNECT_S3_FORCE_PATH_STYLE"),
|
||||
credentials:
|
||||
accessKeyId && secretAccessKey
|
||||
? {
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
sessionToken: optionalEnv("OOMOL_CONNECT_S3_SESSION_TOKEN"),
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
bucket: requiredEnv("OOMOL_CONNECT_S3_BUCKET"),
|
||||
publicOrigin,
|
||||
ttlSeconds: transitFileTtlSeconds,
|
||||
|
||||
@@ -18,10 +18,13 @@ describe("resolveServerAssets", () => {
|
||||
const cwd = await createTempCwd();
|
||||
await mkdir(join(cwd, "dist", "web"), { recursive: true });
|
||||
await writeFile(join(cwd, "dist", "web", "index.html"), "<!doctype html>");
|
||||
await mkdir(join(cwd, "catalog", "apps"), { recursive: true });
|
||||
await writeFile(join(cwd, "catalog", "apps-index.json"), '{"version":1,"providers":[]}\n');
|
||||
|
||||
const assets = await resolveServerAssets();
|
||||
|
||||
expect(assets.catalogDir).toBe(join(cwd, "catalog/apps"));
|
||||
expect(assets.catalogIndexFile).toBe(join(cwd, "catalog/apps-index.json"));
|
||||
expect(assets.migrations).toBe(defaultMigrationSource);
|
||||
expect(assets.staticRoot).toBe(join(cwd, "dist/web"));
|
||||
expect(assets.embedded).toBe(false);
|
||||
@@ -39,6 +42,16 @@ describe("resolveServerAssets", () => {
|
||||
expect(assets.embedded).toBe(false);
|
||||
});
|
||||
|
||||
it("reports no catalog index when catalog/apps-index.json is missing", async () => {
|
||||
const cwd = await createTempCwd();
|
||||
await mkdir(join(cwd, "catalog", "apps"), { recursive: true });
|
||||
|
||||
const assets = await resolveServerAssets();
|
||||
|
||||
expect(assets.catalogDir).toBe(join(cwd, "catalog/apps"));
|
||||
expect(assets.catalogIndexFile).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads the tree embedded next to the module inside a Bun standalone executable", async () => {
|
||||
vi.stubGlobal("Bun", { isStandaloneExecutable: true });
|
||||
const root = import.meta.dirname;
|
||||
@@ -50,8 +63,9 @@ describe("resolveServerAssets", () => {
|
||||
// The directory source touches the filesystem lazily; the ENOENT it raises names the embedded directory.
|
||||
expect(assets.migrations).not.toBe(defaultMigrationSource);
|
||||
expect(() => assets.migrations.readMigrations("sqlite")).toThrow(join(root, "migrations"));
|
||||
// No web/index.html lives beside this module, so the console is reported as not built.
|
||||
// Neither web/index.html nor apps-index.json lives beside this module, so both are reported as absent.
|
||||
expect(assets.staticRoot).toBeUndefined();
|
||||
expect(assets.catalogIndexFile).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { MigrationSource } from "./storage/migration-source.ts";
|
||||
|
||||
import { access } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { catalogIndexFileName } from "../catalog-index.ts";
|
||||
import { createDirectoryMigrationSource, defaultMigrationSource } from "./storage/migration-source.ts";
|
||||
|
||||
/**
|
||||
@@ -11,6 +12,8 @@ import { createDirectoryMigrationSource, defaultMigrationSource } from "./storag
|
||||
export interface ServerAssets {
|
||||
/** Directory of generated provider catalog JSON files. */
|
||||
catalogDir: string;
|
||||
/** Schema-free startup index of `catalogDir`, or undefined when the generator did not write one. */
|
||||
catalogIndexFile: string | undefined;
|
||||
migrations: MigrationSource;
|
||||
/** Built web console directory, or undefined when the console is not built (index.html missing). */
|
||||
staticRoot: string | undefined;
|
||||
@@ -27,15 +30,17 @@ export interface ServerAssets {
|
||||
* embedded tree in a standalone executable, otherwise the working directory and
|
||||
* repository layout used by `npm start`.
|
||||
*
|
||||
* The build script embeds `migrations/`, `catalog/apps/` and `dist/web/` next
|
||||
* to the bundled entry point, so inside the executable they appear under
|
||||
* `import.meta.dirname` as `migrations/`, `apps/` and `web/`.
|
||||
* The build script embeds `migrations/`, `catalog/apps/`, `catalog/apps-index.json`
|
||||
* and `dist/web/` next to the bundled entry point, so inside the executable they
|
||||
* appear under `import.meta.dirname` as `migrations/`, `apps/`, `apps-index.json`
|
||||
* and `web/`.
|
||||
*/
|
||||
export async function resolveServerAssets(): Promise<ServerAssets> {
|
||||
if (isStandaloneExecutable()) {
|
||||
const root = import.meta.dirname;
|
||||
return {
|
||||
catalogDir: join(root, "apps"),
|
||||
catalogIndexFile: await resolveExistingFile(join(root, catalogIndexFileName)),
|
||||
migrations: createDirectoryMigrationSource(join(root, "migrations")),
|
||||
staticRoot: await resolveStaticRoot(join(root, "web")),
|
||||
embedded: true,
|
||||
@@ -45,6 +50,7 @@ export async function resolveServerAssets(): Promise<ServerAssets> {
|
||||
const cwd = process.cwd();
|
||||
return {
|
||||
catalogDir: join(cwd, "catalog/apps"),
|
||||
catalogIndexFile: await resolveExistingFile(join(cwd, "catalog", catalogIndexFileName)),
|
||||
migrations: defaultMigrationSource,
|
||||
staticRoot: await resolveStaticRoot(join(cwd, "dist/web")),
|
||||
embedded: false,
|
||||
@@ -57,9 +63,14 @@ function isStandaloneExecutable(): boolean {
|
||||
}
|
||||
|
||||
async function resolveStaticRoot(root: string): Promise<string | undefined> {
|
||||
const indexHtml = await resolveExistingFile(join(root, "index.html"));
|
||||
return indexHtml === undefined ? undefined : root;
|
||||
}
|
||||
|
||||
async function resolveExistingFile(path: string): Promise<string | undefined> {
|
||||
try {
|
||||
await access(join(root, "index.html"));
|
||||
return root;
|
||||
await access(path);
|
||||
return path;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -3,9 +3,7 @@ import type { ISecretCodec } from "../secrets/secret-codec-core.ts";
|
||||
import type { MigrationSource } from "./migration-source.ts";
|
||||
import type { RuntimeDatabase } from "./runtime-database.ts";
|
||||
|
||||
import { Pool } from "pg";
|
||||
import { migratePostgresDatabase } from "./postgres-migrations.ts";
|
||||
import { PostgresRuntimeDatabase } from "./postgres-runtime-store.ts";
|
||||
import { SqliteRuntimeDatabase } from "./sqlite-runtime-store.ts";
|
||||
|
||||
export interface NodeRuntimeDatabase extends RuntimeDatabase {
|
||||
@@ -49,6 +47,8 @@ export async function createNodeRuntimeDatabase(options: NodeRuntimeDatabaseOpti
|
||||
|
||||
const connectionString = options.connectionString.trim();
|
||||
assertPostgresDatabaseUrl(connectionString);
|
||||
// pg is loaded only when OOMOL_CONNECT_DATABASE_URL selects PostgreSQL; the SQLite default never pays for it.
|
||||
const { PostgresRuntimeDatabase } = await import("./postgres-runtime-store.ts");
|
||||
return await PostgresRuntimeDatabase.open(connectionString, options);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ export const sqliteMigrationsNotice =
|
||||
/** Validate the URL, open a single-connection pool named open-connector-migrate, apply pending migrations, and close the pool. */
|
||||
export async function migratePostgresRuntimeDatabase(options: MigratePostgresRuntimeDatabaseOptions): Promise<void> {
|
||||
assertPostgresDatabaseUrl(options.connectionString);
|
||||
const { Pool } = await import("pg");
|
||||
const pool = new Pool({
|
||||
application_name: "open-connector-migrate",
|
||||
connectionString: options.connectionString,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Pool } from "pg";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AesGcmSecretCodec } from "../secrets/secret-codec.ts";
|
||||
import { defaultMigrationSource } from "./migration-source.ts";
|
||||
import { createNodeRuntimeDatabase, migratePostgresRuntimeDatabase } from "./node-runtime-database.ts";
|
||||
import { assertPostgresSchemaReady, migratePostgresDatabase } from "./postgres-migrations.ts";
|
||||
import { PostgresRuntimeDatabase } from "./postgres-runtime-store.ts";
|
||||
import { RuntimeTokenService } from "./runtime-token-service.ts";
|
||||
@@ -126,6 +127,45 @@ describe("PostgreSQL migrations with a custom migration source", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("createNodeRuntimeDatabase with PostgreSQL", () => {
|
||||
let testServer: PGliteTestServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
testServer = await startPGliteTestServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testServer.server.stop();
|
||||
await testServer.database.close();
|
||||
});
|
||||
|
||||
it("migrates and opens the PostgreSQL runtime database through the lazily loaded driver", async () => {
|
||||
await migratePostgresRuntimeDatabase({
|
||||
connectionString: testServer.url,
|
||||
connectionTimeoutMs: 5_000,
|
||||
migrations: defaultMigrationSource,
|
||||
});
|
||||
|
||||
const database = await createNodeRuntimeDatabase({
|
||||
backend: "postgresql",
|
||||
connectionString: testServer.url,
|
||||
poolMax: 1,
|
||||
migrations: defaultMigrationSource,
|
||||
});
|
||||
try {
|
||||
await expect(database.connectionStore.list()).resolves.toEqual([]);
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a non-PostgreSQL URL before loading the driver", async () => {
|
||||
await expect(
|
||||
createNodeRuntimeDatabase({ backend: "postgresql", connectionString: "mysql://localhost/db" }),
|
||||
).rejects.toThrow("OOMOL_CONNECT_DATABASE_URL must use the postgres: or postgresql: scheme.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PostgresRuntimeDatabase with PGlite", () => {
|
||||
let testServer: PGliteTestServer;
|
||||
let database: PostgresRuntimeDatabase;
|
||||
|
||||
Reference in New Issue
Block a user