Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 35 additions & 6 deletions alchemy.run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ const devEnv = devApps
MAPLE_API_BASE_URL: Portless.routeUrl("api"),
MAPLE_APP_BASE_URL: Portless.routeUrl("web"),
MAPLE_ELECTRIC_SYNC_URL: Portless.routeUrl("electric-sync"),
MAPLE_INGEST_URL: Portless.routeUrl("ingest"),
}
: undefined

Expand All @@ -112,7 +113,17 @@ const MapleStackLive = Layer.effect(
domains,
urls: {
api: devEnv?.MAPLE_API_BASE_URL ?? (yield* resolveUrl(domains.api, "MAPLE_API_BASE_URL")),
ingest: yield* resolveUrl(domains.ingest, "VITE_INGEST_URL", "https://ingest.maple.dev"),
// The dev branch matters here for the same reason it does above, and it
// was missing: on a dev stage this resolved to the PRODUCTION ingest
// host. It went unnoticed while web's dev bundle took its
// `VITE_INGEST_URL` from `vite.config.ts`'s `PORTLESS_URL` sibling
// lookup instead of from here. Now that web is a vite-source Worker
// whose props feed the bundle, this value IS what the browser SDK
// posts to, and a default of `ingest.maple.dev` would send local
// telemetry to production.
ingest:
devEnv?.MAPLE_INGEST_URL ??
(yield* resolveUrl(domains.ingest, "VITE_INGEST_URL", "https://ingest.maple.dev")),
electricSync:
devEnv?.MAPLE_ELECTRIC_SYNC_URL ??
(yield* resolveUrl(domains.sync, "MAPLE_ELECTRIC_SYNC_URL")),
Expand All @@ -138,6 +149,19 @@ const createDevProcess = (app: DevApp, route: Portless.Route) =>
PORT: Output.map(Output.asOutput(route.port), String),
PORTLESS_URL: Portless.routeUrl(app),
MAPLE_API_URL: Portless.routeUrl("api"),
// The alchemy CLI sets NODE_ENV=production for its own renderer, and a
// `Command.Dev` child inherits it. A vite or astro dev server reads that
// variable for `import.meta.env.DEV`/`PROD` rather than taking it from
// `--mode`, so without this a child serves `MODE: "development"` next to
// `DEV: false, PROD: true`: every dev-only branch dead and every
// production branch live, on a dev server.
//
// Alchemy strips it for the vite servers IT spawns, and says why
// (`Cloudflare/Workers/ViteChild.ts`), but that spawner only runs for a
// Worker with a vite source. These apps are not that, so they need it
// said here. `env` is the only lever `Command.Dev` offers: undefined
// values are dropped rather than unset, and `extendEnv` is not a prop.
NODE_ENV: "development",
},
})

Expand Down Expand Up @@ -256,12 +280,17 @@ export default Alchemy.Stack(
const electricSync = yield* ElectricSync
yield* serveWorker("electric-sync", electricSync)

// See `isDevServer`: each of these three is gated on a production
// `Command.Build`, so including them would make `alchemy dev` build the
// whole frontend before serving anything. web's props bind the api
// Worker (its `API` service binding), handed over as `ApiWorker`.
const web = isDevServer ? undefined : yield* Effect.provideService(Web, ApiWorker, api)
// web is a `Cloudflare.Website.Vite` Worker, so alchemy owns its vite build
// AND its dev server: it is served here like any other Worker rather than
// spawned beside the stack, and `alchemy dev` runs vite rather than the
// production build. Its props bind the api Worker (its `API` service
// binding), handed over as `ApiWorker`.
const web = yield* Effect.provideService(Web, ApiWorker, api)
yield* serveWorker("web", web)

// Still gated on a production `Command.Build`, so including them would make
// `alchemy dev` build the whole frontend before serving anything. Both run
// their own dev script under `Command.Dev` instead (`DEV_PROCESS_APPS`).
const landing = isDevServer ? undefined : yield* Landing

const localUi = isDevServer ? undefined : yield* LocalUi
Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/worker-entry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* The deployed Worker's entry, as a plain module.
*
* `Cloudflare.Website.Vite` builds this through the Cloudflare Vite plugin and
* deploys it as the Worker, which is why it is a module rather than the Effect
* implementation the Worker class used to carry as its third argument: a vite
* source owns the entry, and that argument has nowhere to go.
*
* Nothing was lost in the move. The handler was already a plain async function
* in `./handler`, and the Effect wrapper around it only unwrapped the request,
* read three bindings off the environment and converted the response back.
*/
import { handleRequest } from "./handler"
import type { WebWorkerEnv } from "./worker-env"

export default {
fetch: (request: Request, env: WebWorkerEnv): Promise<Response> => handleRequest(request, env),
}
8 changes: 5 additions & 3 deletions apps/web/src/worker-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
* `worker.ts`, which is what keeps the two in step.
*
* Everything except `ASSETS` is optional because absence is a real runtime
* state, not a type error: this worker only exists on deployed stages (`bun
* dev` serves the SPA through Vite, not through this worker), and a dev-stage
* deploy without an api domain binds neither key.
* state, not a type error: a dev-stage deploy without an api domain binds
* neither key. Since web became a `Cloudflare.Website.Vite` Worker this handler
* also runs under `bun dev`, in workerd, in front of the vite dev server, so
* the share-preview and OG paths are exercisable locally rather than only on a
* deployed stage.
*/
export interface WebWorkerEnv {
readonly ASSETS: { fetch: (request: Request) => Promise<Response> }
Expand Down
138 changes: 76 additions & 62 deletions apps/web/src/worker.ts
Original file line number Diff line number Diff line change
@@ -1,90 +1,104 @@
/**
* The dashboard's Worker in alchemy's single-module form: this file is both
* the resource the root stack yields (`yield* Web`) and the bundle alchemy
* deploys (`main: import.meta.url`). The Vite build is yielded from the props,
* and every request goes to `./handler` — OG images, the share preview, the
* SPA shell fallback — which stays a plain function so its tests need no
* Worker runtime.
* The dashboard's Worker, deployed from its own Vite project.
*
* `Cloudflare.Website.Vite` owns the build: one `vite build` through the
* Cloudflare Vite plugin produces the client assets and the server bundle, and
* alchemy uploads both. It replaced a `Command.Build` that shelled out to
* `bun run build` and an `assets` block pointed at its `dist/`.
*
* The reason for the move is dev, not deploy. A vite source is served by
* alchemy's own vite child, so `bun dev` runs this Worker in workerd against a
* real vite dev server instead of spawning a bare `vite dev` beside the stack.
* That bare spawn inherited the alchemy CLI's `NODE_ENV=production`, which Vite
* reads for `import.meta.env.DEV`/`PROD` rather than taking from `--mode`, so
* the dev server served `MODE: "development"` next to `DEV: false, PROD: true`.
* Every dev-only branch was dead and every production branch live; `/lab` 404ing
* through `bun dev` was the visible half. Alchemy's vite child strips the
* variable and says why (`Cloudflare/Workers/ViteChild.ts`); it simply never ran
* for us, because we were not using a vite source.
*
* `VITE_*` keys in `env` are inlined into the client bundle as
* `import.meta.env.*` by the vite source, which is the same job the removed
* `Command.Build`'s env did through `vite.config.ts`'s `define`. Non-prefixed
* keys stay ordinary Worker bindings.
*/
import { ApiWorker, CLOUDFLARE_WORKER_PLACEMENT, MapleStack, resolveWorkerName } from "@maple/infra/cloudflare"
import {
ApiWorker,
CLOUDFLARE_WORKER_PLACEMENT,
MapleStack,
resolveWorkerName,
} from "@maple/infra/cloudflare"
import { plainFrom } from "@maple/infra/env"
import * as Cloudflare from "alchemy/Cloudflare"
import * as Command from "alchemy/Command"
import * as Output from "alchemy/Output"
import { Effect } from "effect"
import { HttpServerResponse } from "effect/unstable/http"
import { handleRequest } from "./handler"

/**
* Alchemy evaluates a Worker's props wherever the class is yielded — the
* deployed bundle included, where they are inert. `__ALCHEMY_RUNTIME__` folds to
* `true` there, so the stack-side branch below, and the `@maple/infra` and
* `alchemy/Command` modules only it reaches, are dead-code-eliminated.
*/
/** This app's own root, which is where its `vite.config.ts` lives. */
const rootDir = new URL("..", import.meta.url).pathname

const props = Effect.gen(function* () {
if (globalThis.__ALCHEMY_RUNTIME__) return { main: import.meta.url }
const { stage, domains, urls } = yield* MapleStack
const api = yield* ApiWorker
// The build runs through `Command.Build` so the VITE_* env is part of the
// memo hash: a stage's URLs (or the commit) changing re-runs it with no
// source change. vite.config.ts turns these into `define` overrides.
const build = yield* Command.Build("web-build", {
command: "bun run build",
cwd: new URL("..", import.meta.url).pathname,
outdir: "dist",
env: {
VITE_API_BASE_URL: urls.api,
VITE_INGEST_URL: urls.ingest,
VITE_ELECTRIC_SYNC_URL: urls.electricSync,
VITE_MAPLE_AUTH_MODE: yield* plainFrom(["VITE_MAPLE_AUTH_MODE", "MAPLE_AUTH_MODE"], "self_hosted"),
VITE_CLERK_PUBLISHABLE_KEY: yield* plainFrom(["VITE_CLERK_PUBLISHABLE_KEY", "CLERK_PUBLISHABLE_KEY"], ""),
VITE_MAPLE_INGEST_KEY: yield* plainFrom(["VITE_MAPLE_INGEST_KEY", "MAPLE_OTEL_PUBLIC_INGEST_KEY"], ""),
// Stamped onto browser telemetry as `vcs.ref.head.revision` / `service.version`.
VITE_COMMIT_SHA: yield* plainFrom(["VITE_COMMIT_SHA", "COMMIT_SHA", "GITHUB_SHA"], ""),
},
})
return {
main: import.meta.url,
name: resolveWorkerName("web", stage),
rootDir,
// The deployed entry. A vite source owns the Worker entry, so the Effect
// implementation this class used to take as a third argument moved into
// this module; it was only unwrapping a request and three bindings.
main: "src/worker-entry.ts",
assets: {
directory: build.outdir,
hash: Output.map(build.hash, (h) => h.output ?? ""),
// Deep links must serve the shell at the requested URL: without this the
// binding 404s, the handler fetches /index.html, and the assets layer's
// trailing-slash normalization 307s that to "/" on every hard reload.
notFoundHandling: "single-page-application" as const,
},
// The default memo scope hashes this app's own tree and the lockfile, which
// would not notice an edit to the `@maple/*` packages the bundle compiles
// in. `lockfile` is restated because providing `include` drops it.
memo: {
include: ["**/*", "../../packages/*/src/**", "../../lib/*/src/**"],
lockfile: true,
},
placement: CLOUDFLARE_WORKER_PLACEMENT,
workersDev: true,
domain: domains.web,
// The share-preview lookups ride the service binding; the URL is still
// bound because bindings address requests by absolute URL. A dev stage
// without an api domain binds neither and previews degrade to the generic card.
env: {
// Bindings. The share-preview lookups ride the service binding; the URL is
// still bound because bindings address requests by absolute URL. A dev
// stage without an api domain binds neither and previews degrade to the
// generic card.
...(urls.api === "" ? undefined : { MAPLE_API_BASE_URL: urls.api }),
API: api,
// Inlined into the client bundle as `import.meta.env.*`. These were the
// removed `Command.Build`'s env, and they remain build inputs: a stage's
// URLs or the commit changing rebuilds with no source change.
VITE_API_BASE_URL: urls.api,
VITE_INGEST_URL: urls.ingest,
VITE_ELECTRIC_SYNC_URL: urls.electricSync,
VITE_MAPLE_AUTH_MODE: yield* plainFrom(
["VITE_MAPLE_AUTH_MODE", "MAPLE_AUTH_MODE"],
"self_hosted",
),
VITE_CLERK_PUBLISHABLE_KEY: yield* plainFrom(
["VITE_CLERK_PUBLISHABLE_KEY", "CLERK_PUBLISHABLE_KEY"],
"",
),
VITE_MAPLE_INGEST_KEY: yield* plainFrom(
["VITE_MAPLE_INGEST_KEY", "MAPLE_OTEL_PUBLIC_INGEST_KEY"],
"",
),
// Stamped onto browser telemetry as `vcs.ref.head.revision` / `service.version`.
VITE_COMMIT_SHA: yield* plainFrom(["VITE_COMMIT_SHA", "COMMIT_SHA", "GITHUB_SHA"], ""),
},
}
})
}).pipe(
// `Website.Vite` takes props whose error channel is `never`, where
// `Cloudflare.Worker` tolerated one. The only failure in here is a
// `ConfigError` from `plainFrom`, and every one of those calls carries a
// default, so reaching the error channel at all means the stack cannot read
// its own configuration. There is nothing for a caller to do with that.
Effect.orDie,
)

// The logical id stays `app`: it names the deployed resource, and a rename would
// plan a delete + create of the Worker behind `app.maple.dev`.
export default class Web extends Cloudflare.Worker<Web>()(
"app",
props,
Effect.succeed({
fetch: Effect.gen(function* () {
const request = yield* Cloudflare.Workers.Request
const env = yield* Cloudflare.WorkerEnvironment
return HttpServerResponse.fromWeb(
yield* Effect.promise(() =>
handleRequest(request, {
ASSETS: env.ASSETS,
API: env.API,
MAPLE_API_BASE_URL: env.MAPLE_API_BASE_URL,
}),
),
)
}),
}),
) {}
export default class Web extends Cloudflare.Website.Vite<Web>()("app", props) {}
5 changes: 5 additions & 0 deletions apps/web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ const envDir = path.resolve(import.meta.dirname, "../..")
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, envDir, "")

// `bun dev` no longer reaches this: web is a `Cloudflare.Website.Vite` Worker,
// so alchemy runs vite itself and supplies these three from the stack's own
// URLs as `define`. It stays for a vite server started by hand next to a
// portless stack (`bun --filter=@maple/web dev`), which has no other way to
// learn its siblings' routes.
if (process.env.PORTLESS_URL) {
process.env.VITE_API_BASE_URL ??= siblingUrl("api")
process.env.VITE_INGEST_URL ??= siblingUrl("ingest")
Expand Down
6 changes: 6 additions & 0 deletions knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,15 @@
"ignoreBinaries": ["tb", "pscale"]
},
"apps/web": {
// `src/worker-entry.ts` is the deployed Worker entry, named by the vite
// source as the string `main: "src/worker-entry.ts"` in `src/worker.ts`'s
// props. knip cannot follow a path in a string, so without this it reports
// the entry and everything only it reaches (the handler, the OG renderers)
// as unused files.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"entry": [
"src/routes/**/*.tsx",
"src/worker.ts",
"src/worker-entry.ts",
"src/components/ai-elements/**",
"src/components/icons/**",
"src/lib/effect-atom.ts"
Expand Down
1 change: 0 additions & 1 deletion packages/infra/src/dev-urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ export type DevApp = (typeof DEV_APPS)[number]

/** The apps that run their own `dev` script as a child process; the rest are Workers on alchemy's local runtime. */
export const DEV_PROCESS_APPS = [
"web",
"landing",
"ingest",
"local-ui",
Expand Down
Loading