diff --git a/apps/cloud/src/api.request-scope.node.test.ts b/apps/cloud/src/api.request-scope.node.test.ts index e2bac9143..d87eca779 100644 --- a/apps/cloud/src/api.request-scope.node.test.ts +++ b/apps/cloud/src/api.request-scope.node.test.ts @@ -49,7 +49,6 @@ import { resetSubjectTouchCache } from "@executor-js/sdk/host-internal"; import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "@executor-js/sdk/testing"; import { RequestScopedServicesLive } from "./api/layers"; -import { makeApiLive } from "./api/router"; class Counter extends Context.Service()("test/Counter") {} @@ -175,48 +174,6 @@ describe("HttpRouter.toWebHandler request scoping", () => { }); }); -// --------------------------------------------------------------------------- -// Regression test against the prod handler factory. If anyone reverts -// `makeApiLive` back to wiring `RequestScopedServicesLive` via -// `Layer.provideMerge`, this test fails — the counter only increments -// once at boot instead of once per request. -// --------------------------------------------------------------------------- - -describe("makeApiLive (prod handler factory) request scoping", () => { - it("rebuilds RequestScopedServicesLive per request", async () => { - const counts = { acquires: 0, releases: 0 }; - // Wrap the real per-request layer with an `acquireRelease` counter. - // `requestScopedMiddleware` calls `Layer.build` per request, so this - // counter increments per request iff the wiring is correct. - const trackedRsLive = Layer.effectDiscard( - Effect.acquireRelease( - Effect.sync(() => { - counts.acquires += 1; - }), - () => - Effect.sync(() => { - counts.releases += 1; - }), - ), - ).pipe(Layer.provideMerge(RequestScopedServicesLive)); - - const handler = HttpRouter.toWebHandler(makeApiLive(trackedRsLive), { - disableLogger: true, - }).handler; - - // Hit a protected route. ExecutionStackMiddleware short-circuits with - // 403 (no session cookie) but not before `requestScopedMiddleware` - // has built the per-request layer. We don't care about the response — - // only that the layer was built once per request. `/integrations` is a - // v2 protected route (the old `/scope` group was removed). - await handler(new Request("http://test.local/integrations"), Context.empty()); - await handler(new Request("http://test.local/integrations"), Context.empty()); - - expect(counts.acquires).toBe(2); - expect(counts.releases).toBe(2); - }); -}); - // --------------------------------------------------------------------------- // `ExecutionStackMiddleware` request scoping. // diff --git a/apps/cloud/src/api/router.ts b/apps/cloud/src/api/router.ts deleted file mode 100644 index 8c80825ef..000000000 --- a/apps/cloud/src/api/router.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Layer } from "effect"; -import { HttpRouter } from "effect/unstable/http"; - -import { - RouterConfigLive, - requestScopedMiddleware, - type MemberDirectory, -} from "@executor-js/api/server"; - -import { UserStoreService } from "../auth/context"; -import { WorkOsMirror } from "../auth/workos-mirror"; -import { DbService } from "../db/db"; -import { makeAccountApiLive } from "../account/account-api"; - -import { AutumnRoutesLive } from "../extensions/billing/route"; -import { CloudDocsLive } from "../extensions/docs"; -import { ApiErrorLoggingLive } from "../observability/error-logging"; -import { - BootSharedServices, - RequestScopedServicesLive, - makeOrgApiLive, - makeNonProtectedApiLive, -} from "./layers"; -import { makeProtectedApiLive } from "./protected"; - -// One router. Each sub-API contributes its routes via `HttpApiBuilder.layer`, -// which calls `HttpRouter.use(...)` under the hood. Autumn's catch-all proxy -// is added as a plain `HttpRouter.add` route. They all merge into the same -// routing table; there is no outer-then-inner router stacking. -// -// The per-request `DbService` + `UserStoreService` wiring is threaded -// through each sub-API's factory. Boot-scoped services come in here via -// `Layer.provideMerge`. `requestScopedLive` is exposed as a parameter -// so tests can substitute a counting fake for `DbService.Live` and -// assert per-request semantics — see -// `apps/cloud/src/api.request-scope.node.test.ts`. -export const makeApiLive = ( - requestScopedLive: Layer.Layer, -) => { - const BillingRoutesLive = AutumnRoutesLive.pipe( - Layer.provide(requestScopedMiddleware(requestScopedLive).layer), - ); - return Layer.mergeAll( - makeNonProtectedApiLive(requestScopedLive), - makeOrgApiLive(requestScopedLive), - makeAccountApiLive(requestScopedLive), - CloudDocsLive, - makeProtectedApiLive(requestScopedLive), - BillingRoutesLive, - ApiErrorLoggingLive, - ).pipe(Layer.provideMerge(RouterConfigLive), Layer.provideMerge(BootSharedServices)); -}; - -export const ApiLive = makeApiLive(RequestScopedServicesLive); - -export const handleApiRequest = HttpRouter.toWebHandler(ApiLive).handler; diff --git a/apps/cloud/src/extensions/docs.ts b/apps/cloud/src/extensions/docs.ts deleted file mode 100644 index 5850072e1..000000000 --- a/apps/cloud/src/extensions/docs.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Effect, Layer } from "effect"; -import { HttpRouter, HttpServerResponse } from "effect/unstable/http"; -import { HttpApiSwagger, OpenApi } from "effect/unstable/httpapi"; - -import { AccountApi, AdminUsersApi } from "@executor-js/api"; - -import { CloudAuthApi, CloudAuthPublicApi } from "../auth/api"; -import { OrgApi } from "../org/api"; - -import { ProtectedCloudApi } from "../api/layers"; - -export const CloudOpenApi = ProtectedCloudApi.add(CloudAuthPublicApi) - .add(CloudAuthApi) - .add(OrgApi) - .add(AccountApi) - .add(AdminUsersApi); - -const spec = OpenApi.fromApi(CloudOpenApi); - -export const CloudOpenApiJsonLive = HttpRouter.add( - "GET", - "/api/openapi.json", - Effect.succeed(HttpServerResponse.jsonUnsafe(spec)), -); - -export const CloudDocsLive = Layer.mergeAll( - HttpApiSwagger.layer(CloudOpenApi, { path: "/api/docs" }), - CloudOpenApiJsonLive, -); diff --git a/apps/cloud/src/extensions/routes.ts b/apps/cloud/src/extensions/routes.ts index f1c4389fe..7b72d2c8f 100644 --- a/apps/cloud/src/extensions/routes.ts +++ b/apps/cloud/src/extensions/routes.ts @@ -6,7 +6,7 @@ // - the WorkOS session routes (login / callback / me / organizations / // switch-organization / invitations / MCP-approval) — `NonProtectedApi`. // - the cloud-only WorkOS domain-verification routes — `OrgHttpApi`. -// - Swagger UI + the OpenAPI JSON for the full cloud spec. +// - Swagger UI + the OpenAPI JSON for the full cloud spec (lazy). // - the Autumn billing proxy (`/api/billing/*`) — billing-as-extension (the // `extensions.routes` SEAM, but served under `/api` like everything else). // - the WorkOS webhook (`/api/webhooks/workos`) — signature-verified poke of @@ -24,8 +24,7 @@ import { env, waitUntil } from "cloudflare:workers"; import { Effect, Layer } from "effect"; import { HttpRouter, HttpServerResponse } from "effect/unstable/http"; -import { HttpApiBuilder } from "effect/unstable/httpapi"; -import { HttpApiSwagger, OpenApi } from "effect/unstable/httpapi"; +import { HttpApiBuilder, OpenApi } from "effect/unstable/httpapi"; import { AccountApi, AdminUsersApi } from "@executor-js/api"; import { requestScopedMiddleware, type MemberDirectory } from "@executor-js/api/server"; @@ -59,15 +58,93 @@ const apiPrefixedRouter = Layer.effect(HttpRouter.HttpRouter)( Effect.map(HttpRouter.HttpRouter.asEffect(), (router) => router.prefixed("/api")), ); +// --------------------------------------------------------------------------- +// Docs, built on demand. +// +// Nothing below runs until someone asks for `/api/docs` or `/api/openapi.json`. +// Both were previously built at module scope, so every cold isolate paid for +// two routes almost nobody calls: `OpenApi.fromApi` walks all ~91 endpoints, +// and effect's Swagger UI bundle is a single ~2 MB string literal that the +// isolate had to evaluate before serving any request. The bundle now arrives +// through a dynamic import, which keeps it out of the app plane's static +// closure entirely. +// +// Each step is memoized for the life of the isolate, so a second docs request +// is as cheap as the old module-scope version. +// --------------------------------------------------------------------------- + +/** Build `build()` at most once per isolate. */ +const once = (build: () => A): (() => A) => { + let cell: { readonly value: A } | undefined; + return () => (cell ??= { value: build() }).value; +}; + // The full cloud OpenAPI spec, prefixed so the served paths match `/api/*`. -const CloudOpenApi = ProtectedCloudApi.add(CloudAuthPublicApi) - .add(CloudAuthApi) - .add(OrgApi) - .add(AccountApi) - .add(AdminUsersApi) - .prefix("/api"); +const cloudOpenApi = once(() => + ProtectedCloudApi.add(CloudAuthPublicApi) + .add(CloudAuthApi) + .add(OrgApi) + .add(AccountApi) + .add(AdminUsersApi) + .prefix("/api"), +); -const spec = OpenApi.fromApi(CloudOpenApi); +const openApiSpec = once(() => OpenApi.fromApi(cloudOpenApi())); + +// The two escapes effect applies before interpolating into the page. Copied +// rather than imported because they live in an internal module; they are three +// lines and their behaviour is fixed by the HTML they guard. +const escapeHtml = (value: string) => + value.replace(/&/g, "&").replace(//g, ">"); + +const escapeSpecJson = (value: unknown) => + JSON.stringify(value) + .replace(/<\/script>/gi, "<\\/script>") + .replace(/[\u2028\u2029]/g, (c) => (c === "\u2028" ? "\\u2028" : "\\u2029")); + +let docsHtml: string | undefined; + +/** + * The Swagger UI page. Mirrors what `HttpApiSwagger.layer` renders — same + * shell, same inlined bundle, same inlined spec — so the served page is + * byte-identical to the layer this route replaced. + */ +const renderDocsHtml = async () => { + if (docsHtml !== undefined) return docsHtml; + // The ~2 MB Swagger UI bundle. Loaded here so it never enters the statically + // reachable module graph of a cold isolate. + const swaggerUi = + (await import("effect/unstable/httpapi/internal/httpApiSwagger")) as unknown as { + readonly css: string; + readonly javascript: string; + }; + const spec = openApiSpec(); + docsHtml = ` + + + + + ${escapeHtml(spec.info.title)} Documentation + + + +
+ + + +`; + return docsHtml; +}; /** * Build cloud's app-only extension routes. `rsLive` is the per-request DB layer @@ -104,10 +181,22 @@ export const makeCloudExtensionRoutes = ( ); // Swagger UI at /api/docs + the OpenAPI JSON at /api/openapi.json, over the - // `/api`-prefixed spec (so the served paths match). + // `/api`-prefixed spec (so the served paths match). Both bodies are built on + // the first request that asks for them — see the block above. const DocsRoutes = Layer.mergeAll( - HttpApiSwagger.layer(CloudOpenApi, { path: "/api/docs" }), - HttpRouter.add("GET", "/api/openapi.json", Effect.succeed(HttpServerResponse.jsonUnsafe(spec))), + HttpRouter.add( + "GET", + "/api/docs", + Effect.map( + Effect.promise(() => renderDocsHtml()), + (html) => HttpServerResponse.html(html), + ), + ), + HttpRouter.add( + "GET", + "/api/openapi.json", + Effect.sync(() => HttpServerResponse.jsonUnsafe(openApiSpec())), + ), ); const BillingRoutes = AutumnRoutesLive.pipe(Layer.provide(requestScopedMiddleware(rsLive).layer)); diff --git a/e2e/cloud/surface-reachability.test.ts b/e2e/cloud/surface-reachability.test.ts index 17ab2d541..e925f81ef 100644 --- a/e2e/cloud/surface-reachability.test.ts +++ b/e2e/cloud/surface-reachability.test.ts @@ -41,9 +41,8 @@ scenario( // the ONLY thing that documents them — the routes serve either way, so a // group dropped from the `.add(...)` chain leaves a mounted, undocumented // plane and nothing else fails. Asserted against the SERVED spec rather - // than the module: cloud builds the same composition twice (here and in - // `extensions/docs.ts`) and only this one reaches the runtime, so importing - // either module could pass while the wire is wrong. + // than the module: the spec is built lazily on the first request, so + // importing the module could pass while the wire is wrong. expect(paths, "the account plane is documented").toContain("/api/account/me"); expect(paths, "including the org-key surface the console reads").toContain( "/api/account/org-api-keys",