Skip to content
Draft
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
43 changes: 0 additions & 43 deletions apps/cloud/src/api.request-scope.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,7 @@
import { resetSubjectTouchCache } from "@executor-js/sdk/host-internal";
import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "@executor-js/sdk/testing";

import { RequestScopedServicesLive } from "./api/layers";

Check warning on line 51 in apps/cloud/src/api.request-scope.node.test.ts

View workflow job for this annotation

GitHub Actions / Lint

eslint(no-unused-vars)

Identifier 'RequestScopedServicesLive' is imported but never used.
import { makeApiLive } from "./api/router";

class Counter extends Context.Service<Counter, { readonly id: number }>()("test/Counter") {}

Expand Down Expand Up @@ -175,48 +174,6 @@
});
});

// ---------------------------------------------------------------------------
// 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.
//
Expand Down
56 changes: 0 additions & 56 deletions apps/cloud/src/api/router.ts

This file was deleted.

29 changes: 0 additions & 29 deletions apps/cloud/src/extensions/docs.ts

This file was deleted.

115 changes: 102 additions & 13 deletions apps/cloud/src/extensions/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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";
Expand Down Expand Up @@ -59,15 +58,93 @@
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 = <A>(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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");

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;
};

Check failure on line 120 in apps/cloud/src/extensions/routes.ts

View workflow job for this annotation

GitHub Actions / Lint

executor(no-double-cast)

Avoid double casts through unknown/any; use a typed boundary, schema decode, or a narrow allow comment with a reason. Skill: wrdn-effect-schema-boundaries.
const spec = openApiSpec();
docsHtml = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHtml(spec.info.title)} Documentation</title>
<style>${swaggerUi.css}</style>
</head>
<body>
<div id="swagger-ui"></div>
<script id="swagger-spec" type="application/json">
${escapeSpecJson(spec)}
</script>
<script>
${swaggerUi.javascript}
window.onload = () => {
window.ui = SwaggerUIBundle({
spec: JSON.parse(document.getElementById("swagger-spec").textContent),
dom_id: "#swagger-ui",
});
};
</script>
</body>
</html>`;
return docsHtml;
};

/**
* Build cloud's app-only extension routes. `rsLive` is the per-request DB layer
Expand Down Expand Up @@ -104,10 +181,22 @@
);

// 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));
Expand Down
5 changes: 2 additions & 3 deletions e2e/cloud/surface-reachability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading