Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
8154719
Unify cloud, self-host, and local apps onto one provider-pluggable ar…
May 30, 2026
ee722dc
Make app file layouts consistent across cloud, self-host, and local
May 30, 2026
7d5d594
Add Cloudflare host + D1 hardening; share MCP session store
May 31, 2026
59ad872
fumadb: batch createMany by bound-parameter count
May 31, 2026
13e7916
Fix correctness bugs found by audit (Phase 0)
May 31, 2026
d61b5bf
Share the in-process MCP host wiring (dedup self-host ↔ cloudflare)
May 31, 2026
67963d2
host-cloudflare: add test harness + R2 offload round-trip tests
May 31, 2026
3e8b7da
host-selfhost: lean Docker runtime + fail-fast startup
May 31, 2026
a7f1103
host-cloudflare: add full-stack e2e test (workerd/miniflare)
May 31, 2026
537a2d6
Restore repo-wide format + lint green
May 31, 2026
9e52479
host-cloudflare e2e: add MCP tool-invocation (tools/call execute)
May 31, 2026
f6d7520
host-cloudflare e2e: cover R2 offload + param-batching on real workerd
May 31, 2026
cd69441
host-cloudflare: give Access service tokens a stable identity
May 31, 2026
8df6d3b
Extract shared @executor-js/cloudflare package; move generic MCP prim…
May 31, 2026
c526029
Move response-peek into @executor-js/cloudflare with injected error seam
May 31, 2026
1a932e4
Extract the DO-dispatcher McpSessionStore into the shared package
May 31, 2026
cb91499
Extract McpSessionDOBase abstract class into the shared package
May 31, 2026
47700af
host-cloudflare: serve MCP through the shared session Durable Object
May 31, 2026
f58fbc6
Narrow the DO stub cast through a single unknown hop
May 31, 2026
28985f1
Derive the web base URL from the request when none is configured
May 31, 2026
74f8594
Merge origin/main into refactor/unified-provider-architecture
May 31, 2026
fe379bf
Stop tracking self-host dev runtime (secret.key + dev SQLite DB)
May 31, 2026
5f445cb
Route /extensions/* to the app handler (fixes billing UI 404s)
May 31, 2026
d9251ae
Add realistic reachability smoke test for the composed cloud handler
May 31, 2026
19e189a
Serve cloud extension routes under /api, not a /extensions namespace
Jun 1, 2026
75bf01e
Fix CI: cloud client bundle, embedded-migrations path, host-cf e2e as…
Jun 1, 2026
47b341c
Fix preview binary: bundle libSQL native into the compiled CLI
Jun 1, 2026
2cdf27c
Merge origin/main into refactor/unified-provider-architecture
Jun 1, 2026
24ec99b
Remove TanStack Start from the cloud auth chain; delete core-shared-s…
Jun 1, 2026
a1d3860
Split SessionCookies into its own service (drop the Session.cookies n…
Jun 1, 2026
6562efc
gitignore: ignore all .executor-* data dirs + any secret.key
Jun 2, 2026
296e3b9
Cut the dead plugins->schema path: collectTables() is plugin-independent
Jun 2, 2026
bb1c98a
Add browser hydration smoke test for the cloud app
Jun 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
10 changes: 10 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
**/node_modules
.git
.reference
.turbo
**/dist
**/.executor*
**/coverage
**/.next
**/*.log
.claude
13 changes: 12 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,25 @@ personal-notes/
*.har.executor
executor.har
.executor/
apps/local/.executor-dev/
# Per-app dev runtime data dirs (local SQLite DB + generated secret.key) — never
# commit. Glob covers .executor-dev (cloud/cloudflare dev) AND .executor-selfhost
# (the self-host data dir) and any future .executor-<role> dir.
.executor-*/
# Belt-and-suspenders: never commit a generated session/at-rest key, wherever it lands.
secret.key

# desktop app build artifacts
apps/desktop/resources/

# cloud local dev database
.pglite
apps/cloud/.dev-db/
apps/cloud/.e2e-db/

# playwright e2e artifacts
test-results/
playwright-report/
.last-run.json
.claude/
.nitro/
.output/
Expand Down
10 changes: 10 additions & 0 deletions .oxlintrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,16 @@
"executor/no-unknown-error-message": "off",
},
},
{
// Playwright e2e specs drive a real browser: they stringify browser-side
// errors and use console/promise APIs that the Effect-domain rules forbid.
"files": ["apps/cloud/e2e/**/*.{ts,tsx}"],
"rules": {
"executor/no-promise-catch": "off",
"executor/no-try-catch-or-throw": "off",
"executor/no-unknown-error-message": "off",
},
},
{
"files": ["apps/marketing/src/**/*.astro"],
"rules": {
Expand Down
48 changes: 47 additions & 1 deletion apps/cli/src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,45 @@ const resolveKeyringNative = (t: Target): string | null => {
}
};

/**
* Resolve the platform-specific `@libsql/<target>` native binding for a target.
*
* The local server's SQLite driver (libSQL) loads its `.node` via a dynamic
* `require('@libsql/<target>')`, which `bun build --compile` can't bundle into
* bunfs (same limitation as keyring). We copy the right `.node` next to the
* executor as `libsql.node`; main.ts redirects the bare require to it.
*/
const LIBSQL_NATIVE_VERSION = "0.5.29";
const resolveLibsqlNative = (t: Target): string | null => {
const platformMap: Record<string, string> = {
"darwin-arm64": "darwin-arm64",
"darwin-x64": "darwin-x64",
// The compiled binary runs on Bun, which libSQL's loader treats as glibc
// (its musl->gnu workaround), so non-musl linux targets need the -gnu binding.
"linux-arm64": "linux-arm64-gnu",
"linux-x64": "linux-x64-gnu",
"linux-arm64-musl": "linux-arm64-musl",
"linux-x64-musl": "linux-x64-musl",
"win32-arm64": "win32-arm64-msvc",
"win32-x64": "win32-x64-msvc",
};
const key = [t.os, t.arch, t.abi].filter(Boolean).join("-");
const target = platformMap[key];
if (!target) return null;
const pkg = `@libsql/${target}`;
try {
const req = createRequire(join(repoRoot, "apps/local", "package.json"));
const pkgJson = req.resolve(`${pkg}/package.json`);
return join(dirname(pkgJson), "index.node");
} catch {
const bunPath = join(
repoRoot,
`node_modules/.bun/${pkg.replace("/", "+")}@${LIBSQL_NATIVE_VERSION}/node_modules/${pkg}/index.node`,
);
return existsSync(bunPath) ? bunPath : null;
}
};

// ---------------------------------------------------------------------------
// Build mode
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -251,7 +290,7 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => {
const meta = await readMetadata();
const binaries: Record<string, string> = {};
const embeddedWebUIPath = join(cliRoot, "src/embedded-web-ui.gen.ts");
const embeddedMigrationsPath = join(webRoot, "src/server/embedded-migrations.gen.ts");
const embeddedMigrationsPath = join(webRoot, "src/db/embedded-migrations.gen.ts");

await rm(distDir, { recursive: true, force: true });

Expand Down Expand Up @@ -310,6 +349,13 @@ const buildBinaries = async (targets: Target[], mode: BuildMode) => {
await cp(keyringNative, join(binDir, "keyring.node"));
}

// Copy the libSQL native binding next to executor — same bunfs limitation
// as keyring; main.ts redirects `require('@libsql/<plat>')` to it.
const libsqlNative = resolveLibsqlNative(target);
if (libsqlNative && existsSync(libsqlNative)) {
await cp(libsqlNative, join(binDir, "libsql.node"));
}

// Smoke test on current platform
if (isCurrentPlatform(target)) {
const bin = join(binDir, binaryName(target));
Expand Down
22 changes: 4 additions & 18 deletions apps/cli/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
// MUST be first: publishes the colocated libSQL/keyring native `.node` paths
// before any import (e.g. `@executor-js/local` → libSQL) eagerly loads them.
import "./native-bindings";

import { randomUUID } from "node:crypto";
import { existsSync, realpathSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
Expand All @@ -8,24 +12,6 @@ if (process.env.PATH && !process.env.PATH.includes(execDir)) {
process.env.PATH = `${execDir}:${process.env.PATH}`;
}

// Point the keychain plugin at the colocated @napi-rs/keyring binding.
// bun --compile doesn't include .node files in bunfs, so the loader's
// normal `require('@napi-rs/keyring-<plat>-<arch>')` walk fails inside the
// binary. We can't use NAPI_RS_NATIVE_LIBRARY_PATH because @napi-rs/keyring
// 1.2.0 has a bug where the env-var branch assigns to a local variable that
// gets overwritten before the binding is returned. build.ts copies the
// platform .node next to the executor; the keychain plugin reads this var
// and loads the file directly via createRequire, bypassing the broken
// loader.
const keyringNodeOnDisk = join(execDir, "keyring.node");
if (
typeof Bun !== "undefined" &&
!process.env.EXECUTOR_KEYRING_NATIVE_PATH &&
(await Bun.file(keyringNodeOnDisk).exists())
) {
process.env.EXECUTOR_KEYRING_NATIVE_PATH = keyringNodeOnDisk;
}

// Pre-load QuickJS WASM for compiled binaries — must run before server imports
const wasmOnDisk = join(execDir, "emscripten-module.wasm");
if (typeof Bun !== "undefined" && (await Bun.file(wasmOnDisk).exists())) {
Expand Down
45 changes: 45 additions & 0 deletions apps/cli/src/native-bindings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// ---------------------------------------------------------------------------
// Native-binding bootstrap for the `bun build --compile` binary.
//
// `bun --compile` bundles JS into bunfs but does NOT include `.node` native
// addons, so a dynamic `require('@libsql/<platform>')` / keyring walk inside
// the binary fails. build.ts copies each platform's `.node` next to the
// executable (`libsql.node`, `keyring.node`); here we publish their on-disk
// paths via env vars the loaders read.
//
// This MUST be the FIRST import in main.ts. ES modules evaluate every import
// before the importer's own body, and libSQL resolves its native addon EAGERLY
// at module load (`const {...} = requireNative()` in `libsql/index.js`). So the
// env var has to be set as a side effect of an import that is ordered before
// the `@executor-js/local` → `@libsql/client` graph — setting it in main.ts's
// body would run too late, after libSQL had already tried (and failed) to load.
// ---------------------------------------------------------------------------

import { existsSync } from "node:fs";
import { dirname, join } from "node:path";

const execDir = dirname(process.execPath);

// libSQL: our `libsql` patch reads EXECUTOR_LIBSQL_NATIVE_PATH and loads the
// colocated binding directly, before its (in-bunfs, doomed) platform-package walk.
const libsqlNodeOnDisk = join(execDir, "libsql.node");
if (
typeof Bun !== "undefined" &&
!process.env.EXECUTOR_LIBSQL_NATIVE_PATH &&
existsSync(libsqlNodeOnDisk)
) {
process.env.EXECUTOR_LIBSQL_NATIVE_PATH = libsqlNodeOnDisk;
}

// keyring: the keychain plugin reads EXECUTOR_KEYRING_NATIVE_PATH (lazily, but
// set here alongside libSQL so all native colocation lives in one place). We
// can't use NAPI_RS_NATIVE_LIBRARY_PATH — @napi-rs/keyring 1.2.0's env-var
// branch assigns to a local that gets overwritten before the binding returns.
const keyringNodeOnDisk = join(execDir, "keyring.node");
if (
typeof Bun !== "undefined" &&
!process.env.EXECUTOR_KEYRING_NATIVE_PATH &&
existsSync(keyringNodeOnDisk)
) {
process.env.EXECUTOR_KEYRING_NATIVE_PATH = keyringNodeOnDisk;
}
93 changes: 93 additions & 0 deletions apps/cloud/e2e/client-entry-hydration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { expect, test } from "@playwright/test";

// ---------------------------------------------------------------------------
// Regression guard: the cloud SPA must HYDRATE in a real browser.
//
// Motivating failure — on launch the console showed:
//
// Uncaught (in promise) TypeError: Failed to fetch dynamically imported
// module: <origin>/@id/virtual:tanstack-start-client-entry
// TypeError: Cannot read properties of undefined (reading 'has')
//
// …with a swarm of `net::ERR_ABORTED` on in-flight module requests.
//
// Root cause: that is Vite's *cold-start dependency re-optimization reload*. The
// first load after the import graph changes makes Vite re-bundle a late-discovered
// dep and force a full page reload, which aborts the in-flight client-entry import.
// It self-heals on the next load (hydration then succeeds). So the warm-up
// navigation below deliberately absorbs that benign one-time reload; the MEASURED
// navigation must then come up clean.
//
// What this guards against is the *persistent* version: the client entry failing
// to load on a settled server, leaving the app permanently dead. That is invisible
// to a request-level test (every module serves a clean 200 to `curl`) — it only
// surfaces in a browser running the module graph. Hence Playwright, booted by
// playwright.config.ts's webServer against a stub-env Vite dev + throwaway PGlite.
// ---------------------------------------------------------------------------

// Only Vite's own dev module-graph URLs — the client entry and everything it
// statically/dynamically imports. Deliberately excludes third-party scripts
// (e.g. analytics under /api/a/static) that have their own, unrelated lifecycle.
const isViteModuleRequest = (url: string) =>
url.includes("/@id/") || url.includes("/@fs/") || url.includes("/node_modules/.vite/");

test("the client entry hydrates — the SPA mounts, no dynamic-import failure", async ({ page }) => {
// Warm-up: the first cold load may trigger Vite's one-time dep re-optimize +
// reload. Swallow it here so the measured pass below sees a settled server.
await page.goto("/", { waitUntil: "load" });
await page.waitForTimeout(1500);

const fatal: string[] = [];
const abortedModules: string[] = [];

// A persistent hydration failure surfaces as an unhandled rejection ("Failed to
// fetch dynamically imported module") and/or a thrown TypeError; capture both.
await page.addInitScript(() => {
window.addEventListener("unhandledrejection", (event) => {
console.error(`UNHANDLED_REJECTION: ${String(event.reason)}`);
});
});
page.on("console", (message) => {
const text = message.text();
if (
/failed to fetch dynamically imported module/i.test(text) ||
/tanstack-start-client-entry/i.test(text) ||
/UNHANDLED_REJECTION/i.test(text)
) {
fatal.push(`[console.${message.type()}] ${text}`);
}
});
page.on("pageerror", (error) => fatal.push(`[pageerror] ${String(error)}`));
page.on("requestfailed", (request) => {
const failure = request.failure()?.errorText ?? "";
if (/ERR_ABORTED/i.test(failure) && isViteModuleRequest(request.url())) {
abortedModules.push(`${failure} ${request.url()}`);
}
});

// Measured pass against the now-settled server.
await page.goto("/", { waitUntil: "load" });
await page.waitForTimeout(2500);

// The SSR shell always carries the title; that alone does NOT prove hydration.
await expect(page).toHaveTitle(/Executor/i);

// (1) No dynamic-import / hydration crash.
expect(fatal, `client-entry/hydration errors:\n${fatal.join("\n")}`).toEqual([]);

// (2) No aborted module fetches — the signature of the client entry failing to
// load (a stuck re-optimize, a boundary leak, a broken transform).
expect(
abortedModules,
`module requests were aborted (client entry did not load cleanly):\n${abortedModules.join("\n")}`,
).toEqual([]);

// (3) The client runtime actually booted: TanStack Start/Router installs its
// router on `window` during hydration. This is true regardless of auth state
// (the stub session is unauthenticated, so there's little rendered text to
// assert on — but a mounted client always exposes the router).
const hydrated = await page.evaluate(
() => Reflect.has(window, "__TSR_ROUTER__") || Reflect.has(window, "__TSR__"),
);
expect(hydrated, "TanStack Start router never mounted — the SPA did not hydrate").toBe(true);
});
67 changes: 67 additions & 0 deletions apps/cloud/e2e/e2e-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// ---------------------------------------------------------------------------
// Boots the cloud app's Vite dev server for the Playwright e2e suite — the SAME
// dev stack a developer runs (`bun run dev`), minus 1Password / real WorkOS.
//
// Everything here is a STUB: fake WorkOS creds, a fixed cookie/encryption key,
// and a throwaway PGlite on its own port (so it never collides with a running
// `bun dev`). That's deliberate — what the spec guards (the TanStack Start client
// entry hydrating) is a CLIENT-side module-graph concern that doesn't depend on
// any of these values, so the stub config is sufficient and the harness stays
// runnable in CI with no secrets.
//
// Used by `playwright.config.ts`'s `webServer`. Spawns the dev DB + Vite, wires
// their stdout through, and tears both down on exit.
// ---------------------------------------------------------------------------

import { spawn, type ChildProcess } from "node:child_process";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const appDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");

const PORT = process.env.E2E_PORT ?? "4798";
const DB_PORT = process.env.E2E_DB_PORT ?? "5435";
const ORIGIN = `http://127.0.0.1:${PORT}`;

const stubEnv: NodeJS.ProcessEnv = {
...process.env,
// WorkOS — never contacted during the hydration path; just has to be present.
WORKOS_API_KEY: "sk_e2e_stub",
WORKOS_CLIENT_ID: "client_e2e_stub",
WORKOS_COOKIE_PASSWORD: "e2e_cookie_password_0123456789abcdef0123456789abcdef",
AUTUMN_SECRET_KEY: "am_e2e_stub",
// 32-byte hex at-rest key (only used lazily on secret writes, not on render).
ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
// Direct connection to the throwaway PGlite (no Hyperdrive in dev).
DATABASE_URL: `postgresql://postgres:postgres@127.0.0.1:${DB_PORT}/postgres`,
EXECUTOR_DIRECT_DATABASE_URL: "true",
CLOUDFLARE_INCLUDE_PROCESS_ENV: "true",
VITE_PUBLIC_SITE_URL: ORIGIN,
MCP_AUTHKIT_DOMAIN: "https://example.com",
MCP_RESOURCE_ORIGIN: ORIGIN,
// Throwaway dev DB on its own port + dir so it never fights a running `bun dev`.
DEV_DB_PORT: DB_PORT,
DEV_DB_PATH: resolve(appDir, ".e2e-db"),
};

const children: ChildProcess[] = [];
const start = (cmd: string, args: string[]) => {
const child = spawn(cmd, args, { cwd: appDir, env: stubEnv, stdio: "inherit" });
child.on("exit", (code) => {
// If either process dies, take the whole harness down so Playwright fails fast.
if (code !== 0 && code !== null) {
shutdown(code);
}
});
children.push(child);
};

const shutdown = (code = 0) => {
for (const child of children) child.kill("SIGTERM");
process.exit(code);
};
process.on("SIGINT", () => shutdown(0));
process.on("SIGTERM", () => shutdown(0));

start("bun", ["run", "scripts/dev-db.ts"]);
start("bunx", ["vite", "dev", "--port", PORT, "--strictPort", "--host", "127.0.0.1"]);
7 changes: 5 additions & 2 deletions apps/cloud/executor.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@ import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-w
// Single source of truth for the cloud app's plugin list.
//
// Consumed by:
// - FumaDB schema wiring (calls `plugins({})`)
// - the host runtime (calls `plugins({ workosCredentials })` per request)
// - the build/UI tooling (the vite plugin calls `plugins()` no-arg, reads
// `plugin.packageName` only)
// - the test harness (calls `plugins({ workosVaultClient })` per test)
// (NOT by schema generation — the executor table set is fixed and
// plugin-independent, see `collectTables()`.)
//
// `TDeps` is inferred directly from the factory parameter annotation —
// no global `declare module "@executor-js/sdk"` augmentation. Each
// caller (runtime / schema wiring / tests) passes whatever subset of the deps
// caller (runtime / build tooling / tests) passes whatever subset of the deps
// it has; all fields are optional so `plugins({})` keeps working.
//
// Cloud only ships plugins safe to run in a multi-tenant setting — no
Expand Down
Loading
Loading