← back to Open Seo
alchemy.run.ts
311 lines
import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as Config from "effect/Config";
import * as Effect from "effect/Effect";
import { Redacted } from "effect";
import { unstable_readConfig } from "wrangler";
import { z } from "zod";
import {
HOSTED_PROD_STAGE,
readWorkersSubdomain,
workerName,
} from "./alchemy.access.ts";
// Preview hostnames are `open-seo-<stage>.<WORKERS_SUBDOMAIN>` — the naming
// lives in alchemy.access.ts, shared with the Access wildcard the security
// boundary depends on. The shell copy in .github/workflows/pr-preview.yml
// must be kept in sync by hand.
// Alchemy v2 stack for SaaS deployments — previews and prod. Stage semantics,
// security model, and credentials are documented once in
// docs/PREVIEW_DEPLOYMENTS.md.
//
// - Any stage except "hosted-prod": fresh stage-suffixed resources. Previews
// deploy via `pnpm deploy:preview --stage <name>`.
// - Stage "hosted-prod": names the EXISTING openseo.so production resources
// so `--adopt` imports them. Deploy via `pnpm deploy:postgres` (--adopt and
// the stage baked in).
//
// Self-hosting still deploys through wrangler (wrangler.jsonc); an
// alchemy-based self-host path is a planned fast-follow. Local dev and Docker
// self-host do NOT use this stack (wrangler.jsonc + @cloudflare/vite-plugin).
// This stack deploys the PREBUILT `vite build` output — Alchemy never runs Vite.
// The worker's runtime contract — compatibility date/flags, crons,
// observability, placement, DO/workflow classes — has one source of truth:
// wrangler.jsonc (what local dev and Docker self-host already run). Only
// stage-dependent values (names, domains, env) live in this file.
// unstable_readConfig ships types too loose to lint; validate what we consume.
const wrangler = z
.object({
compatibility_date: z.string(),
compatibility_flags: z.array(z.string()),
triggers: z.object({ crons: z.array(z.string()) }),
observability: z
.object({
enabled: z.boolean().optional(),
traces: z.object({ enabled: z.boolean().optional() }).optional(),
})
.optional(),
placement: z.object({ mode: z.enum(["off", "smart"]) }).optional(),
durable_objects: z.object({
bindings: z.array(z.object({ name: z.string(), class_name: z.string() })),
}),
workflows: z.array(
z.object({
binding: z.string(),
name: z.string(),
class_name: z.string(),
}),
),
})
.parse(unstable_readConfig({ config: "wrangler.jsonc" }));
// Physical names of the wrangler-era production resources (see git history of
// wrangler.jsonc). Adoption matches on these exact names/titles.
const PROD_NAMES = {
d1: "open-seo",
r2: "open-seo",
kv: "every-super-seo",
oauthKv: "OAUTH_KV",
hyperdrive: "openseo",
} as const;
const makeResources = (stage: string) => {
const prod = stage === HOSTED_PROD_STAGE;
// Prod adopts the LIVE resources; retain makes `alchemy destroy --stage
// hosted-prod` (or an orphaning refactor) forget state instead of deleting
// them.
const keep = Alchemy.RemovalPolicy.retain(prod);
return {
DB: Cloudflare.D1.Database("DB", {
name: prod ? PROD_NAMES.d1 : `open-seo-db-${stage}`,
// drizzle-generated SQL migrations; tracked in the same
// wrangler-compatible table prod already uses.
migrationsDir: "drizzle",
migrationsTable: "d1_migrations",
}).pipe(keep),
R2: Cloudflare.R2.Bucket("R2", {
name: prod ? PROD_NAMES.r2 : `open-seo-r2-${stage}`,
}).pipe(keep),
KV: Cloudflare.KV.Namespace("KV", {
title: prod ? PROD_NAMES.kv : `open-seo-kv-${stage}`,
}).pipe(keep),
OAUTH_KV: Cloudflare.KV.Namespace("OAUTH_KV", {
title: prod ? PROD_NAMES.oauthKv : `open-seo-oauth-kv-${stage}`,
}).pipe(keep),
};
};
/**
* Prod-only: the existing Hyperdrive config pooling connections to the
* production Postgres. Origin credentials come from the env file — Cloudflare
* never returns them, so alchemy must know them to manage the config.
*/
const makeHyperdrive = () =>
Cloudflare.Hyperdrive.Connection("HYPERDRIVE", {
name: PROD_NAMES.hyperdrive,
origin: Config.all([
Config.string("HYPERDRIVE_ORIGIN_HOST"),
Config.string("HYPERDRIVE_ORIGIN_PORT").pipe(Config.withDefault("5432")),
Config.string("HYPERDRIVE_ORIGIN_DATABASE"),
Config.string("HYPERDRIVE_ORIGIN_USER"),
Config.redacted("HYPERDRIVE_ORIGIN_PASSWORD"),
]).pipe(
Config.map(([host, port, database, user, password]) => ({
scheme: "postgres" as const,
host,
port: Number(port),
database,
user,
password,
})),
),
// Prod runs with Hyperdrive caching OFF (no write invalidation for a SaaS
// with per-user reads-after-writes).
caching: { disabled: true },
}).pipe(Alchemy.RemovalPolicy.retain());
const optionalVar = (name: string) =>
Config.string(name).pipe(
Config.withDefault(""),
Config.map((value) => value.trim()),
);
const optionalSecret = (name: string) =>
Config.redacted(name).pipe(Config.withDefault(Redacted.make("")));
// Secrets/vars resolve from the env file passed to `alchemy deploy`
// (`Config.redacted` → Cloudflare `secret_text`, `Config.string` → plaintext
// var). NOTE: the alchemy CLI loads `--env-file` into the Config environment,
// NOT into process.env — a process.env read here silently yields "".
const dataEnv = {
// AUTH_MODE, DATABASE_PROVIDER, BETTER_AUTH_URL, TEAM_DOMAIN, and
// POLICY_AUD are stage-dependent and set in the stack body below.
DATAFORSEO_API_KEY: Config.redacted("DATAFORSEO_API_KEY"),
BYPASS_EMAIL_VERIFICATION: optionalVar("BYPASS_EMAIL_VERIFICATION"),
BETTER_AUTH_SECRET: optionalSecret("BETTER_AUTH_SECRET"),
GOOGLE_CLIENT_ID: optionalVar("GOOGLE_CLIENT_ID"),
GOOGLE_CLIENT_SECRET: optionalSecret("GOOGLE_CLIENT_SECRET"),
OPENROUTER_API_KEY: optionalSecret("OPENROUTER_API_KEY"),
OPENROUTER_MODEL: optionalVar("OPENROUTER_MODEL"),
AUTUMN_SECRET_KEY: optionalSecret("AUTUMN_SECRET_KEY"),
AUTUMN_WEBHOOK_SECRET: optionalSecret("AUTUMN_WEBHOOK_SECRET"),
LOOPS_API_KEY: optionalSecret("LOOPS_API_KEY"),
LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID: optionalVar(
"LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID",
),
LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID: optionalVar(
"LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID",
),
POSTHOG_PUBLIC_KEY: optionalVar("POSTHOG_PUBLIC_KEY"),
POSTHOG_HOST: optionalVar("POSTHOG_HOST"),
REDDIT_PIXEL_ID: optionalSecret("REDDIT_PIXEL_ID"),
REDDIT_CONVERSIONS_ACCESS_TOKEN: optionalSecret(
"REDDIT_CONVERSIONS_ACCESS_TOKEN",
),
TURNSTILE_SECRET_KEY: optionalSecret("TURNSTILE_SECRET_KEY"),
TURNSTILE_SITE_KEY: optionalVar("TURNSTILE_SITE_KEY"),
};
export default Alchemy.Stack(
"open-seo",
{
providers: Cloudflare.providers(),
// Durable state in the Cloudflare state store (an `alchemy-state-store`
// Worker on this account; one-time `pnpm alchemy cloudflare bootstrap`).
// CI fetches its auth token from the account Secrets Store each run.
state: Cloudflare.state(),
},
Effect.gen(function* () {
const stage = yield* Alchemy.Stage;
const prod = stage === HOSTED_PROD_STAGE;
// Fail closed: an unset AUTH_MODE gets the Access-gated mode (matching the
// app's own default in src/lib/auth-mode.ts), never public hosted signup.
// hosted/local_noauth must be set explicitly.
const authMode = yield* Config.string("AUTH_MODE").pipe(
Config.withDefault("cloudflare_access"),
);
const databaseProvider = yield* optionalVar("DATABASE_PROVIDER");
const workersSubdomain = yield* readWorkersSubdomain({ required: false });
// Auth needs an absolute BETTER_AUTH_URL. Prod sets it explicitly;
// previews always derive it from the deterministic worker name — a wrong
// WORKERS_SUBDOMAIN surfaces in CI's post-deploy Access verify step.
let authUrl: string;
if (prod) {
authUrl = yield* optionalVar("BETTER_AUTH_URL");
if (!authUrl) {
return yield* Effect.die(
new Error(
"Set BETTER_AUTH_URL (https://app.openseo.so) in .env.production.",
),
);
}
// Prod must say which database it runs on. A silently-defaulted "d1"
// would deploy cleanly against the stale pre-Postgres data.
if (databaseProvider !== "postgres" && databaseProvider !== "d1") {
return yield* Effect.die(
new Error(
"Set DATABASE_PROVIDER explicitly in .env.production (prod runs postgres).",
),
);
}
} else if (workersSubdomain) {
authUrl = `https://${workerName(stage)}.${workersSubdomain}`;
} else if (authMode === "hosted") {
return yield* Effect.die(
new Error(
"Hosted previews derive BETTER_AUTH_URL from WORKERS_SUBDOMAIN — set it to the account's full workers.dev subdomain (shown under Workers & Pages).",
),
);
} else {
// local_noauth / cloudflare_access never read BETTER_AUTH_URL —
// src/lib/auth.ts uses a placeholder baseURL off the hosted path.
authUrl = "";
}
// cloudflare_access self-host reads these; hosted/local_noauth leave them
// empty. (Deriving/provisioning the Access application is a follow-up PR.)
const teamDomain = yield* optionalVar("TEAM_DOMAIN");
const policyAud = yield* optionalVar("POLICY_AUD");
const app = yield* Cloudflare.Worker("open-seo", {
name: workerName(stage),
// Prod serves the real domains; the zone is inferred from the hostname.
domain: prod ? ["app.openseo.so", "www.app.openseo.so"] : undefined,
// Prebuilt worker from `vite build` (@cloudflare/vite-plugin). The entry
// exports the DO + WorkflowEntrypoint classes (re-exported by
// src/server.ts), which `bundle: false` requires. Sibling chunks under
// assets/ are uploaded as-is by the default module rules.
main: "./dist/server/index.js",
bundle: false,
assets: {
directory: "./dist/client",
},
compatibility: {
date: wrangler.compatibility_date,
flags: wrangler.compatibility_flags,
},
// Site audits parse and persist batches of HTML inside Workflow steps.
// Paid Workers permit up to five minutes; keep headroom for unusually
// link-heavy sites after bounding page bodies and bulk-writing links.
limits: { cpuMs: 300_000 },
observability: {
enabled: wrangler.observability?.enabled ?? true,
traces: { enabled: wrangler.observability?.traces?.enabled ?? false },
},
placement:
wrangler.placement?.mode === "smart" ? { mode: "smart" } : undefined,
// Scheduled rank checks — src/server.ts `scheduled` handler.
crons: wrangler.triggers.crons,
env: {
...makeResources(stage),
...dataEnv,
AUTH_MODE: authMode,
DATABASE_PROVIDER: databaseProvider || "d1",
BETTER_AUTH_URL: authUrl,
TEAM_DOMAIN: teamDomain,
POLICY_AUD: policyAud,
// Prod-only: pooled Postgres via the existing Hyperdrive config.
...(prod ? { HYPERDRIVE: makeHyperdrive() } : {}),
// Durable Objects (Agents SDK chat agents). Alchemy backs new DO
// classes with SQLite storage, which both require.
...Object.fromEntries(
wrangler.durable_objects.bindings.map((binding) => [
binding.name,
Cloudflare.DurableObject(binding.name, {
className: binding.class_name,
}),
]),
),
// Cloudflare Workflows (upstream props-only form for prebuilt
// workers). Workflow names are ACCOUNT-scoped: prod owns the
// unsuffixed names; previews carry the stage suffix so concurrent
// stages can't repoint each other's workflows (registration is a
// PUT-as-upsert on the name).
...Object.fromEntries(
wrangler.workflows.map((workflow) => [
workflow.binding,
Cloudflare.Workflow(
prod ? workflow.name : `${workflow.name}-${stage}`,
{ className: workflow.class_name },
),
]),
),
},
}).pipe(
// Prod adopts the live worker serving app.openseo.so; never delete it
// on destroy. (Workflow registrations aren't individually retainable —
// they're created inside the worker provider — but re-registering them
// is a lossless upsert, unlike deleting the data-bearing resources.)
Alchemy.RemovalPolicy.retain(prod),
);
return { url: app.url.as<string>() };
}),
);