dánwòdánwò

Real stacks

Several apps

Start a frontend, an API and workers together with stackEnvironment, in order, wired to each other, behind one proxy.

stackEnvironment starts several apps for each worker: a frontend, the API it calls, background workers, a local dev server. It puts them all behind one proxy and stops them when the run ends.

List the apps

Each app is a command danwo runs, the address it answers on, and a name:

danwo.config.ts
import { defineConfig, stackEnvironment, stripeService } from "@danwo/sdk"

export default defineConfig({
  testDir: "tests",
  environment: () =>
    stackEnvironment({
      services: [stripeService()],
      apps: [
        { name: "web", command: "pnpm dev", cwd: "apps/web", url: "http://localhost:3000" },
        { name: "api", command: "pnpm start", cwd: "apps/api", url: "http://localhost:4000" },
      ],
    }),
})

The command runs in a shell, in cwd, with PORT set to the port of its url. It inherits the proxy's environment, so its calls to Stripe reach the emulator.

Start them in order

An API usually needs its worker up first, and a frontend needs the API. Say so with dependsOn:

danwo.config.ts · start order
apps: [
  { name: "web", command: "pnpm dev", url: "http://localhost:3000", dependsOn: ["api"] },
  { name: "api", command: "pnpm start", url: "http://localhost:4000", dependsOn: ["worker"] },
  { name: "worker", command: "pnpm worker", url: "http://localhost:4100" },
]

danwo starts worker, then api, then web. Apps with nothing between them start together. A cycle, or a name that isn't in the list, fails with an error naming it.

Say when an app is ready

By default an app is ready when its url answers. Change that with ready:

readyReady whenUse it for
left outurl answers any HTTP requestmost servers
{ path: "/health" }that path answersan app with a health check
{ log: "worker ready" }that text (or a RegExp) appears in its outputa worker that serves nothing
{ exit: true }its command exits with code 0docker compose up --wait

An app has two minutes to be ready, for a dev server's first build. Change it with timeoutMs. If it isn't ready by then, the error ends with the last lines it printed, or says it printed nothing.

Start containers with a command that returns

docker compose up --wait starts its containers, waits until they are healthy and then exits. Give it ready: { exit: true }, and a stop command, since the containers outlive it:

danwo.config.ts · containers
{
  name: "db",
  command: "docker compose up --wait --wait-timeout 60",
  stop: "docker compose down",
  url: "http://localhost:5432",
  ready: { exit: true },
}

Its exit doesn't count as the app going down. danwo runs stop when the run ends, and also when the app fails to start. Every command gets an empty stdin, so nothing waits on a prompt.

--wait alone waits forever on a container that never turns healthy. Add --wait-timeout, so compose says which container it was.

Keep the runner's secrets from the app

By default an app inherits danwo's whole environment, so its own variables (a DATABASE_URL, a .env you exported) just work. So do the runner's: a CI token, or danwo's own model key. Set inheritEnv to keep those out:

danwo.config.ts · what the app inherits
{
  name: "web",
  command: "pnpm dev",
  url: "http://localhost:3000",
  inheritEnv: ["DATABASE_URL", "NEXT_PUBLIC_*"],
  env: { STRIPE_SECRET_KEY: "sk_test_danwo" },
}
inheritEnvThe app inherits
true (default)everything danwo's process has
falseonly what a shell needs: PATH, HOME, the temp folder, the locale
a list of namesthat, plus the names listed; NEXT_PUBLIC_* matches a prefix

danwo's proxy variables and the app's own env always go on top.

The default is convenient, and it is a trade-off. Whatever your CI exports (GITHUB_TOKEN, cloud keys, AI_GATEWAY_API_KEY) reaches the app under test, which may log it or send it on. Records hide only the secrets danwo knows. On shared CI, prefer inheritEnv: false and name what the app needs.

Wire one app to another

env adds variables to an app's environment. Make it a function and it receives every app's address by name:

danwo.config.ts · wiring
{
  name: "web",
  command: "pnpm dev",
  url: "http://localhost:3000",
  dependsOn: ["api"],
  env: ({ api }) => ({ VITE_API_URL: api ?? "" }),
}

The frontend now calls the API that this worker started, not one on a fixed port. A NODE_OPTIONS you set here is joined with danwo's, not replaced.

Choose the page the browser opens

The browser opens the first app's address. Name another with baseUrl:

danwo.config.ts · the page to open
stackEnvironment({ baseUrl: "web", apps: [api, web] })

Tests get every app's address as the apps fixture, so an invariant can ask the API directly:

tests/tasks.danwo.ts
import { test as base } from "@danwo/sdk"

const test = base.extend<{ readonly apps: Readonly<Record<string, string>> }>()

test("adds a task", async ({ app, screen, expect }) => {
  await app.open("/")
  await screen.getByRole("button", { name: "Add task" }).click()
  await expect(screen.getByRole("status")).toHaveText("Task saved")
}).invariant(async ({ apps, expect }) => {
  const tasks = await fetch(`${apps.api ?? ""}/tasks`)

  expect(tasks.status).toBe(200)
})

When an app fails to start

The test file fails with the app's name, why it stopped, and the last lines of its output. The run exits with code 2:

✗ tests/tasks.danwo.ts: worker 0 failed to start: Error: api: it exited with code 1 before http://localhost:4000 answered
    Error [ERR_MODULE_NOT_FOUND]: Cannot find module '…/apps/api/dist/main.js'

What each app printed while it started is kept in .danwo/runs/<run>/environment-<worker>.json, apart from the tests' own records.

Options

OptionWhat it doesDefault
appsthe apps to startrequired
servicesthe emulators behind the proxy (stripeService(), clerkService())none
baseUrlthe name of the app the browser opensthe first app
unmatchedwhat happens to hosts no service answers: passthrough or blockpassthrough
allow, blocked, telemetrywhich outside hosts pass or are refusedsee Outside hosts
assetswhether a page's images and fonts still load under blockpassthrough
secretslive-looking keys an app would use: warn, fail or ignorewarn
nodeShimkeep danwo's preload even when a script sets NODE_OPTIONStrue

Next

On this page

Run it on every pull request

A GitHub check and one comment per pull request, with the flight records attached.

Set up CI