diff --git a/README.md b/README.md index 404abfce..be886212 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ The repository is structured into two workspace roots defined in `pnpm-workspace └── docs/ # Solutions, tooling decisions, and plans ``` -- [`apps/site`](apps/site) — The one Cloudflare Worker: a TanStack Start site defined and deployed with Alchemy. +- [`apps/site`](apps/site) — The one Cloudflare Worker: a TanStack Start site that calls its Worker through effect/rpc (one RpcGroup served at `/api/rpc`, a typed RpcClient in the page), defined and deployed with Alchemy. - [`apps/site-e2e`](apps/site-e2e) — The end-to-end journeys that run against `pnpm dev` (`pnpm journeys`). --- diff --git a/apps/site-e2e/tests/__fixtures__/browser.fixture.ts b/apps/site-e2e/tests/__fixtures__/browser.fixture.ts index 648483f9..ecd098cf 100644 --- a/apps/site-e2e/tests/__fixtures__/browser.fixture.ts +++ b/apps/site-e2e/tests/__fixtures__/browser.fixture.ts @@ -93,3 +93,22 @@ export const visitHomePage = (scenario: string): Effect.Effect Effect.promise(() => context.close()), ) }) + +const healthChecked = (): boolean => document.querySelector('[role=status]')?.textContent.endsWith('checking') === false + +export const readWorkerHealth: Effect.Effect = Effect.gen(function*() { + const origin = yield* siteUrl + const browser = yield* Chromium + return yield* Effect.acquireUseRelease( + Effect.promise(() => browser.newContext()), + (context) => + Effect.gen(function*() { + const page = yield* Effect.promise(() => context.newPage()) + yield* Effect.promise(() => page.goto(`${origin}/`)) + yield* Effect.promise(() => page.waitForFunction(healthChecked)) + const status = yield* Effect.promise(() => page.getByRole('status').textContent()) + return status ?? '' + }), + (context) => Effect.promise(() => context.close()), + ) +}) diff --git a/apps/site-e2e/tests/health.integration.test.ts b/apps/site-e2e/tests/health.integration.test.ts new file mode 100644 index 00000000..7eda5d3d --- /dev/null +++ b/apps/site-e2e/tests/health.integration.test.ts @@ -0,0 +1,20 @@ +import { Gherkin, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' + +import { ChromiumLive, readWorkerHealth } from './__fixtures__/browser.fixture' + +const Feature = makeFeature({ it }) + +const CHROMIUM_TIMEOUT_MS = 30_000 + +Feature('Seeing that the Worker is healthy', { timeout: CHROMIUM_TIMEOUT_MS }) + .withLayer(ChromiumLive) + .live('a real Chromium loads the page the running Worker serves') + .body(({ scenario }) => { + scenario( + 'The home page reports a healthy Worker', + Gherkin.Do.pipe( + When('a visitor opens the home page')('health', () => readWorkerHealth), + Then('the page says the Worker is healthy')((s, expect) => expect(s.health).toBe('Worker health: ok')), + ), + ) + }) diff --git a/apps/site/src/api/health.schema.ts b/apps/site/src/api/health.schema.ts new file mode 100644 index 00000000..aed2cd06 --- /dev/null +++ b/apps/site/src/api/health.schema.ts @@ -0,0 +1,4 @@ +import * as S from 'effect/Schema' + +export const Health = S.Struct({ status: S.Literal('ok') }) +export type Health = S.Schema.Type diff --git a/apps/site/src/api/site-rpc-client.ts b/apps/site/src/api/site-rpc-client.ts new file mode 100644 index 00000000..a2cdb240 --- /dev/null +++ b/apps/site/src/api/site-rpc-client.ts @@ -0,0 +1,18 @@ +import * as Effect from 'effect/Effect' +import { FetchHttpClient, HttpClient, HttpClientRequest } from 'effect/http' +import * as Layer from 'effect/Layer' +import { RpcClient, RpcSerialization } from 'effect/rpc' + +import { SITE_RPC_PATH, SiteRpcs } from './site-rpcs' + +export const siteClient = RpcClient.make(SiteRpcs) + +export const SiteClientProtocol = Layer.effect(RpcClient.Protocol)( + Effect.flatMap( + HttpClient.HttpClient, + (client) => RpcClient.makeProtocolHttp(HttpClient.mapRequest(client, HttpClientRequest.setUrl(SITE_RPC_PATH))), + ), +).pipe( + Layer.provide(RpcSerialization.layerJson), + Layer.provide(FetchHttpClient.layer), +) diff --git a/apps/site/src/api/site-rpc-server.ts b/apps/site/src/api/site-rpc-server.ts new file mode 100644 index 00000000..6a06a5da --- /dev/null +++ b/apps/site/src/api/site-rpc-server.ts @@ -0,0 +1,17 @@ +import * as Effect from 'effect/Effect' +import { HttpServer } from 'effect/http' +import * as Layer from 'effect/Layer' +import { RpcSerialization, RpcServer } from 'effect/rpc' + +import type { Health } from './health.schema' +import { SITE_RPC_PATH, SiteRpcs } from './site-rpcs' + +const healthy: Health = { status: 'ok' } + +const HealthHandlers = SiteRpcs.toLayer({ health: () => Effect.succeed(healthy) }) + +export const SiteRpcLive = RpcServer.layerHttp({ group: SiteRpcs, path: SITE_RPC_PATH, protocol: 'http' }).pipe( + Layer.provide(HealthHandlers), + Layer.provide(RpcSerialization.layerJson), + Layer.provide(HttpServer.layerServices), +) diff --git a/apps/site/src/api/site-rpcs.ts b/apps/site/src/api/site-rpcs.ts new file mode 100644 index 00000000..4ad292d1 --- /dev/null +++ b/apps/site/src/api/site-rpcs.ts @@ -0,0 +1,7 @@ +import { Rpc, RpcGroup } from 'effect/rpc' + +import { Health } from './health.schema' + +export const SITE_RPC_PATH = '/api/rpc' + +export const SiteRpcs = RpcGroup.make(Rpc.make('health', { success: Health })) diff --git a/apps/site/src/routes/index.tsx b/apps/site/src/routes/index.tsx index e38f736c..5a681aa1 100644 --- a/apps/site/src/routes/index.tsx +++ b/apps/site/src/routes/index.tsx @@ -1,12 +1,29 @@ import { createFileRoute } from '@tanstack/react-router' +import * as Effect from 'effect/Effect' +import { useEffect, useState } from 'react' + +import { siteClient, SiteClientProtocol } from '../api/site-rpc-client' export const Route = createFileRoute('/')({ component: Home }) +const workerHealth = Effect.scoped(Effect.flatMap(siteClient, (client) => client.health())).pipe( + Effect.map((health) => health.status), + Effect.orElseSucceed(() => 'unreachable'), + Effect.provide(SiteClientProtocol), +) + function Home() { + const [health, setHealth] = useState('checking') + + useEffect(() => { + void Effect.runPromise(workerHealth).then(setHealth) + }, []) + return (

Endgame Starter

One Cloudflare Worker, built the systemfsoftware way.

+

Worker health: {health}

) } diff --git a/apps/site/src/worker.ts b/apps/site/src/worker.ts index bc0d5303..6ecd7b2a 100644 --- a/apps/site/src/worker.ts +++ b/apps/site/src/worker.ts @@ -1,9 +1,16 @@ import startHandler from '@tanstack/react-start/server-entry' import * as Effect from 'effect/Effect' +import { HttpRouter } from 'effect/http' import * as S from 'effect/Schema' +import { SiteRpcLive } from './api/site-rpc-server' +import { SITE_RPC_PATH } from './api/site-rpcs' import { contentSecurityPolicyOf, CspNonce } from './csp/content-security-policy.schema' +const siteRpc = HttpRouter.toWebHandler(SiteRpcLive) + +const isRpcRequest = (request: Request): boolean => new URL(request.url).pathname === SITE_RPC_PATH + const drawNonce = (random: Crypto) => S.decodeEffect(CspNonce)(btoa(String.fromCharCode(...random.getRandomValues(new Uint8Array(16))))) @@ -21,5 +28,6 @@ const serve = (request: Request, random: Crypto) => }) export default { - fetch: (request: Request): Promise => Effect.runPromise(serve(request, globalThis.crypto)), + fetch: (request: Request): Promise => + isRpcRequest(request) ? siteRpc.handler(request) : Effect.runPromise(serve(request, globalThis.crypto)), } diff --git a/tsconfig.base.json b/tsconfig.base.json index 6f0ad5f6..3eda7580 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,10 +1,11 @@ { - // The one repo-wide unstable-API opt-in, exactly `effect/http` (Kiro ruling, 2026-10-05). + // The one repo-wide unstable-API opt-in, exactly `effect/http` (Kiro ruling, 2026-10-05) and + // `effect/rpc` (2026-10-07: the site calls its Worker through effect/rpc, shipped only there). // Effect 4.0.1 ships HTTP only under the unstable marker; effecttsgo/unstable-api-usage // (new in @effect/tsgo 0.48.1) errors on it, and global-fetch-in-effect bans the fetch // alternative. Drop this when @systemfsoftware/tsconfig carries the same opt-in. "extends": "@systemfsoftware/tsconfig/bundler/dom", "compilerOptions": { - "plugins": [{ "name": "@effect/language-service", "allowedUnstableApis": ["effect/http"] }] + "plugins": [{ "name": "@effect/language-service", "allowedUnstableApis": ["effect/http", "effect/rpc"] }] } }