diff --git a/apps/cli/src/next/commands/branches/switch/switch.handler.ts b/apps/cli/src/next/commands/branches/switch/switch.handler.ts index 86cbdb35a3..24d76bbc96 100644 --- a/apps/cli/src/next/commands/branches/switch/switch.handler.ts +++ b/apps/cli/src/next/commands/branches/switch/switch.handler.ts @@ -1,5 +1,4 @@ import { StateManager, daemonLayer, resolveManagedStack, stopDaemon } from "@supabase/stack/effect"; -import { daemonEntryPoint } from "@supabase/stack"; import { Effect, Option } from "effect"; import { PlatformApi } from "../../../auth/platform-api.service.ts"; import { CliConfig } from "../../../config/cli-config.service.ts"; @@ -154,17 +153,14 @@ export const switchBranch = Effect.fn("branches.switch")(function* (opts: { }, }); - const stackLayer = yield* daemonLayer( - { - cacheRoot: cliConfig.supabaseHome, - cwd: runtimeInfo.cwd, - projectDir: projectHome.projectRoot, - projectStateRoot: projectHome.projectHomeDir, - name: stackState.name, - ...launchConfig, - }, - daemonEntryPoint, - ); + const stackLayer = yield* daemonLayer({ + cacheRoot: cliConfig.supabaseHome, + cwd: runtimeInfo.cwd, + projectDir: projectHome.projectRoot, + projectStateRoot: projectHome.projectHomeDir, + name: stackState.name, + ...launchConfig, + }); yield* Effect.scoped( Effect.gen(function* () { diff --git a/apps/cli/src/next/commands/functions/dev/dev.command.ts b/apps/cli/src/next/commands/functions/dev/dev.command.ts index 3e84cba86e..04d932246b 100644 --- a/apps/cli/src/next/commands/functions/dev/dev.command.ts +++ b/apps/cli/src/next/commands/functions/dev/dev.command.ts @@ -1,5 +1,4 @@ -import { unixHttpClientLayer } from "@supabase/stack"; -import { DEFAULT_MANAGED_STACK_NAME } from "@supabase/stack/effect"; +import { DEFAULT_MANAGED_STACK_NAME, unixHttpClientLayer } from "@supabase/stack/effect"; import { Layer } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts index ba348f6aa6..d3e17f4314 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts @@ -1,4 +1,3 @@ -import { daemonEntryPoint } from "@supabase/stack"; import { connectLayer, daemonLayer, @@ -68,18 +67,15 @@ const startFullStack = Effect.fnUntraced(function* (opts: FunctionsDevStackOptio yield* ensureProjectStateIgnored(projectHome.projectRoot); const serviceVersionContext = yield* resolveServiceVersionContext([], undefined); - const stackLayer = yield* daemonLayer( - { - cacheRoot: cliConfig.supabaseHome, - cwd: runtimeInfo.cwd, - projectDir: projectHome.projectRoot, - projectStateRoot: projectHome.projectHomeDir, - name: opts.stack, - edgeRuntime: opts.edgeRuntime, - ...versionsFromContext(serviceVersionContext), - }, - daemonEntryPoint, - ); + const stackLayer = yield* daemonLayer({ + cacheRoot: cliConfig.supabaseHome, + cwd: runtimeInfo.cwd, + projectDir: projectHome.projectRoot, + projectStateRoot: projectHome.projectHomeDir, + name: opts.stack, + edgeRuntime: opts.edgeRuntime, + ...versionsFromContext(serviceVersionContext), + }); const state = yield* stateManager.read(opts.stack); yield* stateManager.writeMetadata( diff --git a/apps/cli/src/next/commands/functions/list/list.integration.test.ts b/apps/cli/src/next/commands/functions/list/list.integration.test.ts index cebc2aa46c..367f023ffe 100644 --- a/apps/cli/src/next/commands/functions/list/list.integration.test.ts +++ b/apps/cli/src/next/commands/functions/list/list.integration.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { FunctionResponse } from "@supabase/api/effect"; import { BunServices } from "@effect/platform-bun"; -import { unixHttpClientLayer } from "@supabase/stack"; +import { unixHttpClientLayer } from "@supabase/stack/effect"; import { mkdtempSync } from "node:fs"; import { mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; diff --git a/apps/cli/src/next/commands/functions/new/new.integration.test.ts b/apps/cli/src/next/commands/functions/new/new.integration.test.ts index 0b6789a757..ad33aab809 100644 --- a/apps/cli/src/next/commands/functions/new/new.integration.test.ts +++ b/apps/cli/src/next/commands/functions/new/new.integration.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { unixHttpClientLayer } from "@supabase/stack"; +import { unixHttpClientLayer } from "@supabase/stack/effect"; import { existsSync, mkdtempSync } from "node:fs"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; diff --git a/apps/cli/src/next/commands/logs/logs.integration.test.ts b/apps/cli/src/next/commands/logs/logs.integration.test.ts index f9e554e959..95c1eb6e36 100644 --- a/apps/cli/src/next/commands/logs/logs.integration.test.ts +++ b/apps/cli/src/next/commands/logs/logs.integration.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { unixHttpClientLayer } from "@supabase/stack"; +import { unixHttpClientLayer } from "@supabase/stack/effect"; import { Effect, Exit, Fiber, Layer } from "effect"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; diff --git a/apps/cli/src/next/commands/start/start.command.ts b/apps/cli/src/next/commands/start/start.command.ts index 4db1fc276d..4b675d13e9 100644 --- a/apps/cli/src/next/commands/start/start.command.ts +++ b/apps/cli/src/next/commands/start/start.command.ts @@ -7,7 +7,6 @@ import { stackMetadata, type StackMetadata, } from "@supabase/stack/effect"; -import { daemonEntryPoint } from "@supabase/stack"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; import { projectLocalServiceVersionsLayer } from "../../config/project-local-service-versions.layer.ts"; @@ -197,17 +196,14 @@ export const startCommand = Command.make("start", flags).pipe( yield* output.intro("Start local Supabase stack"); yield* ensureProjectStateIgnored(projectHome.projectRoot); - const stackLayer = yield* daemonLayer( - { - cacheRoot: cliConfig.supabaseHome, - cwd: runtimeInfo.cwd, - projectDir: projectHome.projectRoot, - projectStateRoot: projectHome.projectHomeDir, - name: flags.stack, - ...stackConfig, - }, - daemonEntryPoint, - ); + const stackLayer = yield* daemonLayer({ + cacheRoot: cliConfig.supabaseHome, + cwd: runtimeInfo.cwd, + projectDir: projectHome.projectRoot, + projectStateRoot: projectHome.projectHomeDir, + name: flags.stack, + ...stackConfig, + }); const daemonState = yield* stateManager.read(flags.stack); const metadata = stackMetadata({ diff --git a/apps/cli/src/next/commands/status/status.integration.test.ts b/apps/cli/src/next/commands/status/status.integration.test.ts index 95ac5bc27a..f12be0f0ee 100644 --- a/apps/cli/src/next/commands/status/status.integration.test.ts +++ b/apps/cli/src/next/commands/status/status.integration.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { unixHttpClientLayer } from "@supabase/stack"; +import { unixHttpClientLayer } from "@supabase/stack/effect"; import { StackServiceState } from "@supabase/stack/effect"; import { Effect, Layer } from "effect"; import { status } from "./status.handler.ts"; diff --git a/apps/cli/src/next/commands/stop/stop.integration.test.ts b/apps/cli/src/next/commands/stop/stop.integration.test.ts index bcf7a8d46f..1f8167e436 100644 --- a/apps/cli/src/next/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/next/commands/stop/stop.integration.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { Effect, Exit, Layer } from "effect"; import { BunServices } from "@effect/platform-bun"; -import { unixHttpClientLayer } from "@supabase/stack"; +import { unixHttpClientLayer } from "@supabase/stack/effect"; import { stop } from "./stop.handler.ts"; import { mockOutput, withEnv } from "../../../../tests/helpers/mocks.ts"; import { diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index 69f9d35cbf..69be7da263 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -1,6 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { ProjectConfigStore } from "@supabase/config"; -import { unixHttpClientLayer } from "@supabase/stack"; +import { unixHttpClientLayer } from "@supabase/stack/effect"; import { Cause, Console, Effect, Exit, Fiber, Layer, Runtime, Stdio } from "effect"; import { CliError, CliOutput, Command } from "effect/unstable/cli"; import { CLI_VERSION } from "./version.ts"; diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index 885009f822..792c27fbb8 100644 --- a/apps/cli/tests/helpers/mocks.ts +++ b/apps/cli/tests/helpers/mocks.ts @@ -14,7 +14,7 @@ import { type StackMetadata, type StackState, } from "@supabase/stack/effect"; -import { UnixHttpClient } from "@supabase/stack"; +import { UnixHttpClient } from "@supabase/stack/testing"; import { Api } from "../../src/next/auth/api.service.ts"; import type { LoginSessionResponse, ProfileResponse } from "../../src/next/auth/api.service.ts"; import { Credentials } from "../../src/next/auth/credentials.service.ts"; diff --git a/apps/cli/tests/helpers/running-stack.ts b/apps/cli/tests/helpers/running-stack.ts index 50283f76fa..9e7ed2e106 100644 --- a/apps/cli/tests/helpers/running-stack.ts +++ b/apps/cli/tests/helpers/running-stack.ts @@ -1,8 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import * as BunHttpServer from "@effect/platform-bun/BunHttpServer"; -import { unixHttpClientLayer } from "@supabase/stack"; import { - DaemonServer, DEFAULT_VERSIONS, fullVersionManifest, type PartialVersionManifest, @@ -14,7 +12,9 @@ import { type StackInfo, type StackMetadata, type StackState, + unixHttpClientLayer, } from "@supabase/stack/effect"; +import { DaemonServer } from "@supabase/stack/testing"; import { Effect, Layer, ManagedRuntime, Option, Stream } from "effect"; import { spawn, type ChildProcess } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; diff --git a/packages/stack/docs/architecture.md b/packages/stack/docs/architecture.md index 539d724c14..847f705351 100644 --- a/packages/stack/docs/architecture.md +++ b/packages/stack/docs/architecture.md @@ -11,12 +11,14 @@ The package exposes two levels of Interface: - `@supabase/stack` selects `bun.ts` or `node.ts` through export conditions and exposes the Promise-oriented `createStack()` / `StackHandle` Interface plus prefetch helpers. -- `@supabase/stack/effect` exposes Effect Interfaces and layer factories used by the CLI and - advanced callers. +- `@supabase/stack/effect` selects a runtime Adapter through the same export conditions and exposes + Effect Interfaces plus platform-bound layer factories used by the CLI and advanced callers. +- `@supabase/stack/testing` exposes only the service tags needed to replace daemon transport in + consumer tests. Runtime implementation tags do not leak through the root or Effect barrels. -The root runtime Adapters provide Effect filesystem, path, child-process, HTTP-server, and Unix -socket HTTP implementations. `createStack.ts` remains platform-agnostic and receives a -`PlatformFactory`. +Internal runtime Adapters provide Effect filesystem, path, child-process, HTTP-server, and Unix +socket HTTP implementations. `createStack.ts` and the layer factories remain platform-agnostic; +the conditional root and Effect entries bind them to their selected runtime. ```mermaid flowchart LR @@ -293,12 +295,15 @@ Callers may explicitly supply `projectStateRoot`, in which case durable stacks l ## Runtime entrypoints and exports - `bun.ts` and `node.ts` are root export-condition targets. +- `effect-bun.ts` and `effect-node.ts` are Effect export-condition targets. They bind foreground, + daemon, and Unix-socket layers without exposing raw platform factories or bootstrap paths. - `daemon-bun.ts` is exported as `@supabase/stack/daemon-bun` so the compiled CLI can dispatch to it in-process. -- `daemon-node.ts` is intentionally not a package export. `node.ts` resolves it by file URL and - passes that filesystem path to `daemonLayer`; the package `knip.entry` list preserves this live - file-URL-only entrypoint. -- `effect.ts` is the low-level Effect export used by the CLI. There is no `internals.ts` entrypoint. +- `daemon-node.ts` is intentionally not a package export. The internal Node platform Adapter + resolves it by file URL and passes that filesystem path to `daemonLayer`; the package + `knip.entry` list preserves this live file-URL-only entrypoint. +- `effect.ts` is the platform-agnostic consumer contract re-exported by the conditional Effect + entries. There is no general-purpose `internals.ts` entrypoint. ## Testing diff --git a/packages/stack/docs/detach-mode.md b/packages/stack/docs/detach-mode.md index eaf7a96122..e2b1d44a67 100644 --- a/packages/stack/docs/detach-mode.md +++ b/packages/stack/docs/detach-mode.md @@ -163,20 +163,21 @@ crash-recovery metadata is deliberately separate from user-facing `/status` conn ## Package entrypoints -| File | Reachability and role | -| --------------------- | ------------------------------------------------------------------------------------------------------------- | -| `src/daemon.ts` | Shared daemon protocol and lifecycle; receives runtime-specific HTTP-server factories. | -| `src/daemon-bun.ts` | Bun daemon Adapter. Exported as `@supabase/stack/daemon-bun` for compiled CLI dispatch. | -| `src/daemon-node.ts` | Node daemon Adapter. Intentionally file-URL-only: `node.ts` resolves its path and passes it to `daemonLayer`. | -| `src/DaemonServer.ts` | Unix-socket HTTP/SSE Adapter over `Stack`. | -| `src/RemoteStack.ts` | Remote Effect `Stack` Adapter over that transport. | -| `src/layers.ts` | Foreground, foreground-daemon, forked-daemon, and connect layer composition. | -| `src/StateManager.ts` | Durable metadata, live-state claims, scanning, stale-state removal, and deletion. | -| `src/effect.ts` | Effect-facing exports consumed by the CLI and advanced callers. | - -There is no `internals.ts`. `daemon-node.ts` is not a package export because Node root consumers -reach it by the file URL returned from `node.ts`; it is listed under `knip.entry` in `package.json` -so static unused-code analysis preserves that live entrypoint. +| File | Reachability and role | +| --------------------- | ------------------------------------------------------------------------------------------------------- | +| `src/daemon.ts` | Shared daemon protocol and lifecycle; receives runtime-specific HTTP-server factories. | +| `src/daemon-bun.ts` | Bun daemon Adapter. Exported as `@supabase/stack/daemon-bun` for compiled CLI dispatch. | +| `src/daemon-node.ts` | Node daemon Adapter. Intentionally file-URL-only: the internal Node platform Adapter resolves its path. | +| `src/DaemonServer.ts` | Unix-socket HTTP/SSE Adapter over `Stack`. | +| `src/RemoteStack.ts` | Remote Effect `Stack` Adapter over that transport. | +| `src/layers.ts` | Foreground, foreground-daemon, forked-daemon, and connect layer composition. | +| `src/StateManager.ts` | Durable metadata, live-state claims, scanning, stale-state removal, and deletion. | +| `src/effect-*.ts` | Conditional Effect entries that bind consumer layers to Bun or Node. | +| `src/effect.ts` | Platform-agnostic Effect contracts re-exported by the conditional entries. | + +There is no `internals.ts`. `daemon-node.ts` is not a package export because the Node Effect +Adapter reaches it through the file URL returned by the internal platform module; it is listed +under `knip.entry` in `package.json` so static unused-code analysis preserves that live entrypoint. ## Compiled executable re-entry diff --git a/packages/stack/package.json b/packages/stack/package.json index ebb76b6b7b..d8c3f695d8 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -8,7 +8,11 @@ "bun": "./src/bun.ts", "default": "./src/node.ts" }, - "./effect": "./src/effect.ts", + "./effect": { + "bun": "./src/effect-bun.ts", + "default": "./src/effect-node.ts" + }, + "./testing": "./src/testing.ts", "./daemon-bun": "./src/daemon-bun.ts" }, "scripts": { diff --git a/packages/stack/src/JwtGenerator.ts b/packages/stack/src/JwtGenerator.ts index 83ac2115d6..ae4566cfd3 100644 --- a/packages/stack/src/JwtGenerator.ts +++ b/packages/stack/src/JwtGenerator.ts @@ -1,5 +1,4 @@ import { createHmac } from "node:crypto"; -import { Effect, Layer, Context } from "effect"; // Hardcoded opaque key defaults matching Go CLI (pkg/config/apikeys.go:19-20). // These are client-facing keys for local dev — SDKs use these, not JWTs directly. @@ -10,8 +9,7 @@ export const defaultSecretKey = "sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz"; export const defaultJwtSecret = "super-secret-jwt-token-with-at-least-32-characters-long"; /** - * Pure synchronous JWT generation. Used both by the JwtGenerator service - * and directly in createStack() where JWTs are needed before layers run. + * Pure synchronous JWT generation used while resolving stack configuration. */ export function generateJwt(secret: string, role: string): string { const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url"); @@ -38,16 +36,3 @@ export function generateJwks(secret: string): string { ], }); } - -export class JwtGenerator extends Context.Service< - JwtGenerator, - { - readonly generate: (secret: string, role: string) => Effect.Effect; - readonly generateJwks: (secret: string) => Effect.Effect; - } ->()("local/JwtGenerator") { - static layer: Layer.Layer = Layer.succeed(this, { - generate: (secret: string, role: string) => Effect.sync(() => generateJwt(secret, role)), - generateJwks: (secret: string) => Effect.sync(() => generateJwks(secret)), - }); -} diff --git a/packages/stack/src/UnixSocketSse.integration.test.ts b/packages/stack/src/UnixSocketSse.integration.test.ts index e6694814da..c7de5133b0 100644 --- a/packages/stack/src/UnixSocketSse.integration.test.ts +++ b/packages/stack/src/UnixSocketSse.integration.test.ts @@ -10,7 +10,7 @@ import { DaemonServer } from "./DaemonServer.ts"; import { RemoteStack } from "./RemoteStack.ts"; import { Stack, type StackInfo } from "./Stack.ts"; import { StackServiceState } from "./StackServiceState.ts"; -import { unixHttpClientLayer } from "./bun.ts"; +import { unixHttpClientLayer } from "./platform-bun.ts"; const REFERENCE_IDLE_TIMEOUT_SECONDS = 1; // Keep the idle gap just past a short reference timeout so the suite stays fast. diff --git a/packages/stack/src/bun.ts b/packages/stack/src/bun.ts index ebc92eb69c..2d05640881 100644 --- a/packages/stack/src/bun.ts +++ b/packages/stack/src/bun.ts @@ -1,59 +1,17 @@ import { BunServices } from "@effect/platform-bun"; -import * as BunHttpServer from "@effect/platform-bun/BunHttpServer"; -import { fileURLToPath } from "node:url"; import { Effect, Layer } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { BinaryResolver } from "./BinaryResolver.ts"; -import { - createStack as createStackCore, - type PlatformFactory, - type StackHandle, -} from "./createStack.ts"; +import { createStack as createStackCore, type StackHandle } from "./createStack.ts"; import { prefetch as prefetchEffect, type PrefetchOptions, type PrefetchResult, } from "./prefetch.ts"; import { defaultCacheRoot } from "./paths.ts"; +import { platformFactory } from "./platform-bun.ts"; import { StackPreparation } from "./StackPreparation.ts"; import type { StackConfig } from "./StackConfig.ts"; -import { UnixHttpClient, UnixHttpClientError } from "./UnixHttpClient.ts"; - -interface BunUnixRequestInit extends RequestInit { - readonly unix: string; -} - -export const unixHttpClientLayer = Layer.succeed(UnixHttpClient, { - request: (socketPath, path, init) => - Effect.tryPromise({ - try: () => { - const requestInit: BunUnixRequestInit = { - ...init, - unix: socketPath, - }; - return fetch(`http://localhost${path}`, requestInit); - }, - catch: (cause) => new UnixHttpClientError({ socketPath, path, cause }), - }), -}); - -// --------------------------------------------------------------------------- -// Platform values — for use with Effect layer factories -// --------------------------------------------------------------------------- - -/** Bun platform factory for use with foregroundLayer / daemonLayer. */ -export const platformFactory: PlatformFactory = ({ apiPort, releaseApiPort }) => - Layer.mergeAll( - BunServices.layer, - Layer.unwrap(releaseApiPort.pipe(Effect.as(BunHttpServer.layer({ port: apiPort })))), - ); - -/** Path to the Bun daemon entry point for use with daemonLayer. */ -export const daemonEntryPoint: string = fileURLToPath(new URL("./daemon-bun.ts", import.meta.url)); - -// --------------------------------------------------------------------------- -// Promise API — convenience wrappers for non-Effect consumers -// --------------------------------------------------------------------------- export async function createStack(config?: StackConfig): Promise { return createStackCore(config, platformFactory); diff --git a/packages/stack/src/createStack.ts b/packages/stack/src/createStack.ts index 5db6ebcca3..37c0d94ab6 100644 --- a/packages/stack/src/createStack.ts +++ b/packages/stack/src/createStack.ts @@ -1,13 +1,12 @@ import type { LogEntry } from "@supabase/process-compose"; -import { Context, Effect, type Layer, ManagedRuntime, Stream } from "effect"; -import { FileSystem, Path } from "effect"; +import { Context, Effect, FileSystem, type Layer, ManagedRuntime, Path, Stream } from "effect"; import { HttpServer } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { ApiProxy } from "./ApiProxy.ts"; import { candidateCleanupTargets, cleanupAutoManagedPaths, dockerForceRemove } from "./cleanup.ts"; import { toStackError } from "./errors.ts"; import type { FunctionsReloadConfig } from "./functions.ts"; -import { daemonLayer, foregroundLayer, type DaemonStartError } from "./layers.ts"; +import { foregroundLayer } from "./layers.ts"; import { LocalStackLifecycle } from "./LocalStack.ts"; import { PORT_FIELDS, reservePorts, type PortLease } from "./PortAllocator.ts"; import { allocatedPortFieldsForConfig } from "./ServicePorts.ts"; @@ -16,18 +15,16 @@ import type { EdgeRuntimeReloadConfig } from "./Stack.ts"; import type { ReadyOptions, ResolvedStackConfig, StackConfig } from "./StackConfig.ts"; import { resolveConfig } from "./StackConfigResolver.ts"; import type { StackServiceState } from "./StackServiceState.ts"; -import { InvalidStackStateError, StackAlreadyRunningError } from "./StateManager.ts"; -import { UnixHttpClient } from "./UnixHttpClient.ts"; -export type PlatformServices = +type PlatformServices = | FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner | HttpServer.HttpServer; -export type PlatformLayer = Layer.Layer; +type PlatformLayer = Layer.Layer; /** Supplies the platform HTTP server used by the stack and HTTP proxy. */ -export interface PlatformFactoryOptions { +interface PlatformFactoryOptions { readonly apiPort: number; readonly releaseApiPort: Effect.Effect; } @@ -74,31 +71,6 @@ export interface StackHandle extends AsyncDisposable { logHistory(name: string, limit?: number): Promise>; } -export const projectDaemonLayer = (opts: { - readonly cacheRoot: string; - readonly cwd: string; - readonly projectDir?: string; - readonly projectStateRoot?: string; - readonly name?: string; - readonly daemonEntryPoint: string; - readonly stackConfig?: Omit; -}): Effect.Effect< - Layer.Layer, - DaemonStartError | InvalidStackStateError | StackAlreadyRunningError, - FileSystem.FileSystem | Path.Path | UnixHttpClient -> => - daemonLayer( - { - cacheRoot: opts.cacheRoot, - cwd: opts.cwd, - projectDir: opts.projectDir, - projectStateRoot: opts.projectStateRoot, - name: opts.name, - ...opts.stackConfig, - }, - opts.daemonEntryPoint, - ); - export async function createStack( config: StackConfig | undefined, platformFactory: PlatformFactory, diff --git a/packages/stack/src/daemon-node.ts b/packages/stack/src/daemon-node.ts index 387b2dff11..7c34ac6c70 100644 --- a/packages/stack/src/daemon-node.ts +++ b/packages/stack/src/daemon-node.ts @@ -4,9 +4,10 @@ import { createServer } from "node:http"; import { Effect, Layer } from "effect"; import { runDaemon } from "./daemon.ts"; -// Live child-process entrypoint for Node root consumers. `node.ts` resolves this module by file URL -// and passes its filesystem path to daemonLayer, so it is deliberately not a package export. The -// `knip.entry` declaration in package.json preserves this file-URL-only reachability. +// Live child-process entrypoint for Node Effect consumers. The internal Node platform adapter +// resolves this module by file URL and passes its filesystem path to daemonLayer, so it is +// deliberately not a package export. The `knip.entry` declaration in package.json preserves this +// file-URL-only reachability; see the matching note in node.ts. runDaemon( ({ apiPort, releaseApiPort }) => Layer.mergeAll( diff --git a/packages/stack/src/daemon.ts b/packages/stack/src/daemon.ts index a3e0c61464..ee42d7f0c7 100644 --- a/packages/stack/src/daemon.ts +++ b/packages/stack/src/daemon.ts @@ -25,12 +25,12 @@ export interface DaemonStartMessage { readonly socketPath: string; } -export interface DaemonStartedMessage { +interface DaemonStartedMessage { readonly type: "started"; readonly state: StackState; } -export interface DaemonErrorMessage { +interface DaemonErrorMessage { readonly type: "error"; readonly message: string; } diff --git a/packages/stack/src/effect-bun.ts b/packages/stack/src/effect-bun.ts new file mode 100644 index 0000000000..24ea594248 --- /dev/null +++ b/packages/stack/src/effect-bun.ts @@ -0,0 +1,24 @@ +// @supabase/stack/effect — Bun-bound Effect interfaces and consumer layers. + +export * from "./effect.ts"; + +import type { Layer } from "effect"; +import type { PortLease } from "./PortAllocator.ts"; +import type { Stack } from "./Stack.ts"; +import type { ResolvedStackConfig } from "./StackConfig.ts"; +import type { DaemonConfigInput } from "./StackConfigResolver.ts"; +import { + daemonLayer as daemonLayerForPlatform, + foregroundLayer as foregroundLayerForPlatform, +} from "./layers.ts"; +import { daemonEntryPoint, platformFactory, unixHttpClientLayer } from "./platform-bun.ts"; + +export { unixHttpClientLayer }; + +export const foregroundLayer = ( + config: ResolvedStackConfig, + portLease: PortLease, +): Layer.Layer => foregroundLayerForPlatform(config, platformFactory, portLease); + +export const daemonLayer = (input: DaemonConfigInput) => + daemonLayerForPlatform(input, daemonEntryPoint); diff --git a/packages/stack/src/effect-node.ts b/packages/stack/src/effect-node.ts new file mode 100644 index 0000000000..8159880f79 --- /dev/null +++ b/packages/stack/src/effect-node.ts @@ -0,0 +1,24 @@ +// @supabase/stack/effect — Node-bound Effect interfaces and consumer layers. + +export * from "./effect.ts"; + +import type { Layer } from "effect"; +import type { PortLease } from "./PortAllocator.ts"; +import type { Stack } from "./Stack.ts"; +import type { ResolvedStackConfig } from "./StackConfig.ts"; +import type { DaemonConfigInput } from "./StackConfigResolver.ts"; +import { + daemonLayer as daemonLayerForPlatform, + foregroundLayer as foregroundLayerForPlatform, +} from "./layers.ts"; +import { daemonEntryPoint, platformFactory, unixHttpClientLayer } from "./platform-node.ts"; + +export { unixHttpClientLayer }; + +export const foregroundLayer = ( + config: ResolvedStackConfig, + portLease: PortLease, +): Layer.Layer => foregroundLayerForPlatform(config, platformFactory, portLease); + +export const daemonLayer = (input: DaemonConfigInput) => + daemonLayerForPlatform(input, daemonEntryPoint); diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts index 6a15a5c76d..f1ea9b8407 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -1,5 +1,4 @@ -// @supabase/stack/effect — advanced Effect and low-level APIs. -// Platform-agnostic: pass platformFactory/daemonEntryPoint from @supabase/stack. +// Platform-agnostic Effect contracts re-exported by the conditional @supabase/stack/effect entry. export type { LogEntry } from "@supabase/process-compose"; export type { StackServiceStatus } from "./StackServiceState.ts"; @@ -25,9 +24,6 @@ export { postgrestAssetName, } from "./Platform.ts"; -export type { BinarySpec } from "./BinaryResolver.ts"; -export { BinaryResolver } from "./BinaryResolver.ts"; - export type { ServiceResolution } from "./StackPreparation.ts"; export type { PrefetchOptions, PrefetchResult } from "./prefetch.ts"; @@ -38,7 +34,6 @@ export { defaultPublishableKey, defaultSecretKey, generateJwt, - JwtGenerator, } from "./JwtGenerator.ts"; export type { @@ -57,8 +52,6 @@ export { reservePorts, } from "./PortAllocator.ts"; -export type { ProxyConfig } from "./ApiProxy.ts"; -export { ApiProxy } from "./ApiProxy.ts"; export type { AnalyticsConfig, AuthConfig, @@ -92,7 +85,6 @@ export type { VectorConfig, } from "./StackConfig.ts"; export { DEFAULT_STACK_READINESS_POLICY, resolveReadinessPolicy } from "./StackConfig.ts"; -export { StackBuilder } from "./StackBuilder.ts"; export type { EdgeRuntimeReloadConfig, StackInfo } from "./Stack.ts"; export { EdgeRuntimeReloadConfigSchema, Stack } from "./Stack.ts"; @@ -163,18 +155,6 @@ export { stackMetadata, } from "./StackMetadata.ts"; -export { DaemonServer } from "./DaemonServer.ts"; -export { RemoteStack } from "./RemoteStack.ts"; -export { UnixHttpClient, UnixHttpClientError } from "./UnixHttpClient.ts"; - -export type { - PlatformFactory, - PlatformFactoryOptions, - PlatformLayer, - PlatformServices, - StackHandle, -} from "./createStack.ts"; -export { createStack, projectDaemonLayer } from "./createStack.ts"; export type { ResolvedDaemonConfig } from "./StackConfig.ts"; export { defaultManagedStackName, @@ -182,7 +162,7 @@ export { resolveDaemonConfig, } from "./StackConfigResolver.ts"; -export { connectLayer, DaemonStartError, daemonLayer, foregroundLayer } from "./layers.ts"; +export { connectLayer, DaemonStartError } from "./layers.ts"; export type { ManagedStack } from "./managed-stack.ts"; export { resolveManagedStack } from "./managed-stack.ts"; @@ -194,11 +174,3 @@ export { resolveStackSummary, stopDaemon, } from "./discovery.ts"; - -export type { - DaemonErrorMessage, - DaemonHttpServerFactory, - DaemonMessage, - DaemonStartedMessage, - DaemonStartMessage, -} from "./daemon.ts"; diff --git a/packages/stack/src/entrypoints.unit.test.ts b/packages/stack/src/entrypoints.unit.test.ts index 61d8f0f41b..06b9b548ed 100644 --- a/packages/stack/src/entrypoints.unit.test.ts +++ b/packages/stack/src/entrypoints.unit.test.ts @@ -1,23 +1,76 @@ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { describe, expect, expectTypeOf, it } from "vitest"; +import type { Layer } from "effect"; +import * as bunRoot from "./bun.ts"; +import * as bunEffect from "./effect-bun.ts"; +import * as nodeEffect from "./effect-node.ts"; +import * as nodeRoot from "./node.ts"; +import type { StackHandle } from "./createStack.ts"; +import type { Stack } from "./Stack.ts"; +import * as testing from "./testing.ts"; -import { describe, expect, it } from "vitest"; +const INTERNAL_EFFECT_EXPORTS = [ + "ApiProxy", + "BinaryResolver", + "DaemonServer", + "JwtGenerator", + "RemoteStack", + "StackBuilder", + "UnixHttpClient", + "createStack", + "projectDaemonLayer", +] as const; describe("@supabase/stack entrypoints", () => { - it("ships conditional root exports and keeps only the effect subpath", () => { + it("declares only intentional package entrypoints", () => { const srcDir = dirname(fileURLToPath(import.meta.url)); const packageJson = JSON.parse(readFileSync(join(srcDir, "../package.json"), "utf8")) as { readonly exports: Record>; + readonly knip: { readonly entry: ReadonlyArray }; }; - expect(packageJson.exports["."]).toEqual({ - bun: "./src/bun.ts", - default: "./src/node.ts", + expect(packageJson.exports).toEqual({ + ".": { + bun: "./src/bun.ts", + default: "./src/node.ts", + }, + "./effect": { + bun: "./src/effect-bun.ts", + default: "./src/effect-node.ts", + }, + "./testing": "./src/testing.ts", + "./daemon-bun": "./src/daemon-bun.ts", }); - expect(packageJson.exports["./effect"]).toBe("./src/effect.ts"); - expect(packageJson.exports["./bun"]).toBeUndefined(); - expect(packageJson.exports["./node"]).toBeUndefined(); + expect(packageJson.exports["./daemon-node"]).toBeUndefined(); expect(packageJson.exports["./internals"]).toBeUndefined(); + expect(packageJson.knip.entry).toContain("src/daemon-node.ts"); + }); + + it("keeps the root runtime surface Promise-only", () => { + expect(Object.keys(nodeRoot).sort()).toEqual(["createStack", "prefetch"]); + expect(Object.keys(bunRoot).sort()).toEqual(["createStack", "prefetch"]); + expectTypeOf(nodeRoot.createStack).returns.toEqualTypeOf>(); + expectTypeOf(bunRoot.createStack).returns.toEqualTypeOf>(); + }); + + it("binds consumer Effect layers without exposing implementation tags", () => { + expectTypeOf(nodeEffect.foregroundLayer).returns.toEqualTypeOf>(); + expectTypeOf(bunEffect.foregroundLayer).returns.toEqualTypeOf>(); + + for (const entrypoint of [nodeEffect, bunEffect]) { + expect(entrypoint).toHaveProperty("connectLayer"); + expect(entrypoint).toHaveProperty("daemonLayer"); + expect(entrypoint).toHaveProperty("foregroundLayer"); + expect(entrypoint).toHaveProperty("unixHttpClientLayer"); + for (const name of INTERNAL_EFFECT_EXPORTS) { + expect(entrypoint).not.toHaveProperty(name); + } + } + }); + + it("isolates consumer test seams in the testing entry", () => { + expect(Object.keys(testing).sort()).toEqual(["DaemonServer", "UnixHttpClient"]); }); }); diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 4b238c07b8..3969233f6f 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -32,4 +32,3 @@ export type { ResolvedFunction, ResolvedFunctionsBundle, } from "./functions.ts"; -export { UnixHttpClient, UnixHttpClientError } from "./UnixHttpClient.ts"; diff --git a/packages/stack/src/node.ts b/packages/stack/src/node.ts index aedf7ab1f1..8be38e7ce1 100644 --- a/packages/stack/src/node.ts +++ b/packages/stack/src/node.ts @@ -1,145 +1,23 @@ import { NodeServices } from "@effect/platform-node"; -import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; -import { createServer } from "node:http"; -import * as Http from "node:http"; -import { Readable } from "node:stream"; -import { fileURLToPath } from "node:url"; import { Effect, Layer } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { BinaryResolver } from "./BinaryResolver.ts"; -import { - createStack as createStackCore, - type PlatformFactory, - type StackHandle, -} from "./createStack.ts"; +import { createStack as createStackCore, type StackHandle } from "./createStack.ts"; import { prefetch as prefetchEffect, type PrefetchOptions, type PrefetchResult, } from "./prefetch.ts"; import { defaultCacheRoot } from "./paths.ts"; +import { platformFactory } from "./platform-node.ts"; import { StackPreparation } from "./StackPreparation.ts"; import type { StackConfig } from "./StackConfig.ts"; -import { UnixHttpClient, UnixHttpClientError } from "./UnixHttpClient.ts"; - -const mergeBodyHeaders = ( - headersInit: RequestInit["headers"] | undefined, - bodyHeaders: Headers, -): Headers => { - const headers = new Headers(headersInit); - for (const [key, value] of bodyHeaders.entries()) { - if (!headers.has(key)) { - headers.set(key, value); - } - } - return headers; -}; - -const toOutgoingHeaders = (headers: Headers): Http.OutgoingHttpHeaders => - Object.fromEntries(headers.entries()); - -const toResponseHeaders = (headers: Http.IncomingHttpHeaders): Headers => { - const responseHeaders = new Headers(); - for (const [key, value] of Object.entries(headers)) { - if (value === undefined) { - continue; - } - if (Array.isArray(value)) { - for (const item of value) { - responseHeaders.append(key, item); - } - continue; - } - responseHeaders.set(key, value); - } - return responseHeaders; -}; - -const encodeRequest = async ( - init: RequestInit | undefined, -): Promise<{ - readonly body: Uint8Array | undefined; - readonly headers: Http.OutgoingHttpHeaders; -}> => { - if (init?.body == null) { - return { - body: undefined, - headers: toOutgoingHeaders(new Headers(init?.headers)), - }; - } - - const bodyResponse = new Response(init.body); - const headers = mergeBodyHeaders(init.headers, bodyResponse.headers); - return { - body: new Uint8Array(await bodyResponse.arrayBuffer()), - headers: toOutgoingHeaders(headers), - }; -}; - -const toWebResponse = (response: Http.IncomingMessage): Response => - new Response( - response.statusCode === 204 || response.statusCode === 304 ? null : Readable.toWeb(response), - { - status: response.statusCode ?? 200, - statusText: response.statusMessage ?? "", - headers: toResponseHeaders(response.headers), - }, - ); - -export const unixHttpClientLayer = Layer.succeed(UnixHttpClient, { - request: (socketPath, path, init) => - Effect.tryPromise({ - try: async () => { - const { body, headers } = await encodeRequest(init); - return await new Promise((resolve, reject) => { - const request = Http.request( - { - socketPath, - path, - method: init?.method ?? "GET", - headers, - signal: init?.signal ?? undefined, - }, - (response) => { - resolve(toWebResponse(response)); - }, - ); - - request.on("error", reject); - request.end(body); - }); - }, - catch: (cause) => new UnixHttpClientError({ socketPath, path, cause }), - }), -}); - -// --------------------------------------------------------------------------- -// Platform values — for use with Effect layer factories -// --------------------------------------------------------------------------- - -/** Node platform factory for use with foregroundLayer / daemonLayer. */ -export const platformFactory: PlatformFactory = ({ apiPort, releaseApiPort }) => - Layer.mergeAll( - NodeServices.layer, - Layer.unwrap( - releaseApiPort.pipe( - Effect.as(NodeHttpServer.layer(() => createServer(), { port: apiPort }).pipe(Layer.orDie)), - ), - ), - ); /** - * Path to the Node daemon entry point for use with daemonLayer. - * - * `daemon-node.ts` is intentionally reached by this file URL instead of a package export. Keep the - * matching `knip.entry` in package.json when changing this path; static import analysis cannot see - * the child-process entrypoint. + * The Node daemon bootstrap is deliberately not exported from the package. The conditional Effect + * entry resolves `daemon-node.ts` by file URL through the internal platform adapter. Keep + * `src/daemon-node.ts` in package.json's `knip.entry` list: static imports cannot see that fork target. */ -export const daemonEntryPoint: string = fileURLToPath(new URL("./daemon-node.ts", import.meta.url)); - -// --------------------------------------------------------------------------- -// Promise API — convenience wrappers for non-Effect consumers -// --------------------------------------------------------------------------- export async function createStack(config?: StackConfig): Promise { return createStackCore(config, platformFactory); diff --git a/packages/stack/src/platform-bun.ts b/packages/stack/src/platform-bun.ts new file mode 100644 index 0000000000..96d6252165 --- /dev/null +++ b/packages/stack/src/platform-bun.ts @@ -0,0 +1,30 @@ +import { BunServices } from "@effect/platform-bun"; +import * as BunHttpServer from "@effect/platform-bun/BunHttpServer"; +import { fileURLToPath } from "node:url"; +import { Effect, Layer } from "effect"; +import type { PlatformFactory } from "./createStack.ts"; +import { UnixHttpClient, UnixHttpClientError } from "./UnixHttpClient.ts"; + +interface BunUnixRequestInit extends RequestInit { + readonly unix: string; +} + +export const unixHttpClientLayer = Layer.succeed(UnixHttpClient, { + request: (socketPath, path, init) => + Effect.tryPromise({ + try: () => { + const requestInit: BunUnixRequestInit = { ...init, unix: socketPath }; + return fetch(`http://localhost${path}`, requestInit); + }, + catch: (cause) => new UnixHttpClientError({ socketPath, path, cause }), + }), +}); + +export const platformFactory: PlatformFactory = ({ apiPort, releaseApiPort }) => + Layer.mergeAll( + BunServices.layer, + Layer.unwrap(releaseApiPort.pipe(Effect.as(BunHttpServer.layer({ port: apiPort })))), + ); + +/** Internal source-mode child target. Compiled CLI dispatch still uses the daemon-bun export. */ +export const daemonEntryPoint = fileURLToPath(new URL("./daemon-bun.ts", import.meta.url)); diff --git a/packages/stack/src/platform-node.ts b/packages/stack/src/platform-node.ts new file mode 100644 index 0000000000..bdcc9c85b3 --- /dev/null +++ b/packages/stack/src/platform-node.ts @@ -0,0 +1,109 @@ +import { NodeServices } from "@effect/platform-node"; +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import { createServer } from "node:http"; +import * as Http from "node:http"; +import { Readable } from "node:stream"; +import { fileURLToPath } from "node:url"; +import { Effect, Layer } from "effect"; +import type { PlatformFactory } from "./createStack.ts"; +import { UnixHttpClient, UnixHttpClientError } from "./UnixHttpClient.ts"; + +const mergeBodyHeaders = ( + headersInit: RequestInit["headers"] | undefined, + bodyHeaders: Headers, +): Headers => { + const headers = new Headers(headersInit); + for (const [key, value] of bodyHeaders.entries()) { + if (!headers.has(key)) { + headers.set(key, value); + } + } + return headers; +}; + +const toOutgoingHeaders = (headers: Headers): Http.OutgoingHttpHeaders => + Object.fromEntries(headers.entries()); + +const toResponseHeaders = (headers: Http.IncomingHttpHeaders): Headers => { + const responseHeaders = new Headers(); + for (const [key, value] of Object.entries(headers)) { + if (value === undefined) continue; + if (Array.isArray(value)) { + for (const item of value) responseHeaders.append(key, item); + continue; + } + responseHeaders.set(key, value); + } + return responseHeaders; +}; + +const encodeRequest = async ( + init: RequestInit | undefined, +): Promise<{ + readonly body: Uint8Array | undefined; + readonly headers: Http.OutgoingHttpHeaders; +}> => { + if (init?.body == null) { + return { + body: undefined, + headers: toOutgoingHeaders(new Headers(init?.headers)), + }; + } + + const bodyResponse = new Response(init.body); + const headers = mergeBodyHeaders(init.headers, bodyResponse.headers); + return { + body: new Uint8Array(await bodyResponse.arrayBuffer()), + headers: toOutgoingHeaders(headers), + }; +}; + +const toWebResponse = (response: Http.IncomingMessage): Response => + new Response( + response.statusCode === 204 || response.statusCode === 304 ? null : Readable.toWeb(response), + { + status: response.statusCode ?? 200, + statusText: response.statusMessage ?? "", + headers: toResponseHeaders(response.headers), + }, + ); + +export const unixHttpClientLayer = Layer.succeed(UnixHttpClient, { + request: (socketPath, path, init) => + Effect.tryPromise({ + try: async () => { + const { body, headers } = await encodeRequest(init); + return await new Promise((resolve, reject) => { + const request = Http.request( + { + socketPath, + path, + method: init?.method ?? "GET", + headers, + signal: init?.signal ?? undefined, + }, + (response) => { + resolve(toWebResponse(response)); + }, + ); + + request.on("error", reject); + request.end(body); + }); + }, + catch: (cause) => new UnixHttpClientError({ socketPath, path, cause }), + }), +}); + +export const platformFactory: PlatformFactory = ({ apiPort, releaseApiPort }) => + Layer.mergeAll( + NodeServices.layer, + Layer.unwrap( + releaseApiPort.pipe( + Effect.as(NodeHttpServer.layer(() => createServer(), { port: apiPort }).pipe(Layer.orDie)), + ), + ), + ); + +/** Internal child-process target. It is intentionally absent from package exports. */ +export const daemonEntryPoint = fileURLToPath(new URL("./daemon-node.ts", import.meta.url)); diff --git a/packages/stack/src/testing.ts b/packages/stack/src/testing.ts new file mode 100644 index 0000000000..206459eeeb --- /dev/null +++ b/packages/stack/src/testing.ts @@ -0,0 +1,3 @@ +/** Test-only service tags for building deterministic consumer layers. */ +export { DaemonServer } from "./DaemonServer.ts"; +export { UnixHttpClient } from "./UnixHttpClient.ts";