create-effect-project 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -0
  3. package/dist/bin.js +58245 -0
  4. package/package.json +34 -0
  5. package/templates/_shared/_gitignore +4 -0
  6. package/templates/_shared/config.ts +13 -0
  7. package/templates/_shared/features/_oxlintrc.json +5 -0
  8. package/templates/_shared/features/_vscode.settings.json +6 -0
  9. package/templates/_shared/features/lint.package.json +11 -0
  10. package/templates/_shared/features/lint.tsconfig.json +16 -0
  11. package/templates/_shared/features/observability.noop.ts +9 -0
  12. package/templates/_shared/features/observability.otel.ts +23 -0
  13. package/templates/_shared/features/slop.oxlintrc.json +25 -0
  14. package/templates/_shared/httpapi/api/Api.ts +9 -0
  15. package/templates/_shared/httpapi/api/Notes.ts +25 -0
  16. package/templates/_shared/httpapi/api/System.ts +7 -0
  17. package/templates/_shared/httpapi/client/ApiClient.ts +24 -0
  18. package/templates/_shared/httpapi/server/Notes/http.ts +22 -0
  19. package/templates/_shared/httpapi/server/http.ts +18 -0
  20. package/templates/_shared/notes/Notes.ts +42 -0
  21. package/templates/_shared/notes/domain/Note.ts +19 -0
  22. package/templates/alchemy-http/README.md +79 -0
  23. package/templates/alchemy-http/alchemy.run.ts +22 -0
  24. package/templates/alchemy-http/runtime/bun/_package.json +27 -0
  25. package/templates/alchemy-http/runtime/bun/_tsconfig.json +27 -0
  26. package/templates/alchemy-http/runtime/bun/client.ts +37 -0
  27. package/templates/alchemy-http/runtime/bun/http.test.ts +45 -0
  28. package/templates/alchemy-http/runtime/node/_package.json +28 -0
  29. package/templates/alchemy-http/runtime/node/_tsconfig.json +27 -0
  30. package/templates/alchemy-http/runtime/node/client.ts +37 -0
  31. package/templates/alchemy-http/runtime/node/http.test.ts +48 -0
  32. package/templates/alchemy-http/src/worker.ts +22 -0
  33. package/templates/alchemy-rpc/README.md +83 -0
  34. package/templates/alchemy-rpc/alchemy.run.ts +22 -0
  35. package/templates/alchemy-rpc/runtime/bun/_package.json +27 -0
  36. package/templates/alchemy-rpc/runtime/bun/_tsconfig.json +27 -0
  37. package/templates/alchemy-rpc/runtime/bun/client.ts +43 -0
  38. package/templates/alchemy-rpc/runtime/bun/rpc.test.ts +40 -0
  39. package/templates/alchemy-rpc/runtime/node/_package.json +28 -0
  40. package/templates/alchemy-rpc/runtime/node/_tsconfig.json +27 -0
  41. package/templates/alchemy-rpc/runtime/node/client.ts +43 -0
  42. package/templates/alchemy-rpc/runtime/node/rpc.test.ts +41 -0
  43. package/templates/alchemy-rpc/src/rpc.ts +29 -0
  44. package/templates/alchemy-rpc/src/server/Notes/rpc.ts +17 -0
  45. package/templates/alchemy-rpc/src/worker.ts +24 -0
  46. package/templates/basic/README.md +53 -0
  47. package/templates/basic/runtime/bun/Users.test.ts +31 -0
  48. package/templates/basic/runtime/bun/_package.json +21 -0
  49. package/templates/basic/runtime/bun/_tsconfig.json +10 -0
  50. package/templates/basic/runtime/bun/main.ts +43 -0
  51. package/templates/basic/runtime/node/Users.test.ts +32 -0
  52. package/templates/basic/runtime/node/_package.json +22 -0
  53. package/templates/basic/runtime/node/_tsconfig.json +24 -0
  54. package/templates/basic/runtime/node/main.ts +43 -0
  55. package/templates/basic/src/Users.ts +49 -0
  56. package/templates/basic/src/domain/User.ts +21 -0
  57. package/templates/fullstack/README.md +114 -0
  58. package/templates/fullstack/_gitignore +7 -0
  59. package/templates/fullstack/_package.json +6 -0
  60. package/templates/fullstack/_pnpm-workspace.yaml +2 -0
  61. package/templates/fullstack/_tsconfig.json +9 -0
  62. package/templates/fullstack/apps/api/config.ts +10 -0
  63. package/templates/fullstack/apps/api/http.ts +35 -0
  64. package/templates/fullstack/apps/api/runtime/bun/_package.json +26 -0
  65. package/templates/fullstack/apps/api/runtime/bun/_tsconfig.json +5 -0
  66. package/templates/fullstack/apps/api/runtime/node/_package.json +27 -0
  67. package/templates/fullstack/apps/api/runtime/node/_tsconfig.json +5 -0
  68. package/templates/fullstack/apps/web/runtime/bun/NotesApi.test.ts +65 -0
  69. package/templates/fullstack/apps/web/runtime/bun/_package.json +30 -0
  70. package/templates/fullstack/apps/web/runtime/bun/_tsconfig.json +9 -0
  71. package/templates/fullstack/apps/web/runtime/node/NotesApi.test.ts +65 -0
  72. package/templates/fullstack/apps/web/runtime/node/_package.json +31 -0
  73. package/templates/fullstack/apps/web/runtime/node/_tsconfig.json +9 -0
  74. package/templates/fullstack/apps/web/src/atoms/NotesApi.ts +40 -0
  75. package/templates/fullstack/apps/web/src/router.tsx +64 -0
  76. package/templates/fullstack/apps/web/src/routes/__root.tsx +38 -0
  77. package/templates/fullstack/apps/web/src/routes/index.tsx +97 -0
  78. package/templates/fullstack/apps/web/src/styles.css +40 -0
  79. package/templates/fullstack/apps/web/vite.config.ts +11 -0
  80. package/templates/fullstack/lint.package.json +12 -0
  81. package/templates/http-server/README.md +56 -0
  82. package/templates/http-server/runtime/bun/_package.json +22 -0
  83. package/templates/http-server/runtime/bun/_tsconfig.json +10 -0
  84. package/templates/http-server/runtime/bun/client.ts +40 -0
  85. package/templates/http-server/runtime/bun/http.test.ts +45 -0
  86. package/templates/http-server/runtime/bun/index.ts +25 -0
  87. package/templates/http-server/runtime/node/_package.json +23 -0
  88. package/templates/http-server/runtime/node/_tsconfig.json +24 -0
  89. package/templates/http-server/runtime/node/client.ts +40 -0
  90. package/templates/http-server/runtime/node/http.test.ts +48 -0
  91. package/templates/http-server/runtime/node/index.ts +26 -0
@@ -0,0 +1,65 @@
1
+ import { Api } from "@{{name}}/api/api"
2
+ import { Effect, Layer } from "effect"
3
+ import { HttpClient, HttpClientResponse } from "effect/unstable/http"
4
+ import { AsyncResult, Atom, AtomHttpApi, AtomRegistry, Hydration } from "effect/unstable/reactivity"
5
+ import { expect, test } from "vitest"
6
+ import { notesList } from "./NotesApi.ts"
7
+
8
+ const stubNotes = [{ id: "1", title: "first", body: "hello" }]
9
+
10
+ let requests = 0
11
+
12
+ const StubHttpClient = Layer.succeed(
13
+ HttpClient.HttpClient,
14
+ HttpClient.make((request) => {
15
+ requests++
16
+ return Effect.succeed(
17
+ HttpClientResponse.fromWeb(
18
+ request,
19
+ new Response(JSON.stringify(stubNotes), {
20
+ status: 200,
21
+ headers: { "content-type": "application/json" }
22
+ })
23
+ )
24
+ )
25
+ })
26
+ )
27
+
28
+ class StubApi extends AtomHttpApi.Service<StubApi>()("test/StubApi", {
29
+ api: Api,
30
+ httpClient: StubHttpClient,
31
+ baseUrl: "http://localhost"
32
+ }) {}
33
+
34
+ // Same group, endpoint and serializationKey as the shipped atom, so it produces
35
+ // the same registry key: `AtomHttpApi:notes:list:notes/list`.
36
+ const listQuery = Atom.keepAlive(
37
+ StubApi.query("notes", "list", { serializationKey: "notes/list" })
38
+ )
39
+
40
+ test("dehydrates a resolved list and hydrates it without refetching", async () => {
41
+ requests = 0
42
+
43
+ const server = AtomRegistry.make()
44
+ const notes = await Effect.runPromise(AtomRegistry.getResult(listQuery)(server))
45
+ expect(notes.length).toBe(1)
46
+ expect(requests).toBe(1)
47
+
48
+ const dehydrated = Hydration.toValues(Hydration.dehydrate(server))
49
+ expect(dehydrated.map((entry) => entry.key)).toContain("AtomHttpApi:notes:list:notes/list")
50
+
51
+ const client = AtomRegistry.make()
52
+ Hydration.hydrate(client, dehydrated)
53
+
54
+ const hydrated = client.get(listQuery)
55
+ expect(AsyncResult.isSuccess(hydrated)).toBe(true)
56
+ // The whole point: reading the atom on the "client" issued no second request.
57
+ expect(requests).toBe(1)
58
+ })
59
+
60
+ // Guards the shipped atom, not just the stub above: without a serializationKey
61
+ // it would not be serializable, dehydrate would skip it, and SSR would silently
62
+ // degrade into a client refetch.
63
+ test("the shipped notesList atom is serializable", () => {
64
+ expect(Atom.isSerializable(notesList)).toBe(true)
65
+ })
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@{{name}}/web",
3
+ "type": "module",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "vite dev",
7
+ "build": "vite build",
8
+ "//": "`routeTree.gen.ts` is generated by the TanStack Start plugin and gitignored, so a fresh clone has no file for tsc to read. Building first generates it.",
9
+ "pretypecheck": "vite build",
10
+ "typecheck": "tsc --noEmit",
11
+ "test": "vitest run"
12
+ },
13
+ "dependencies": {
14
+ "@{{name}}/api": "{{workspaceVersion}}",
15
+ "@effect/atom-react": "4.0.0-rc.112",
16
+ "@tanstack/react-router": "^1.170.32",
17
+ "@tanstack/react-start": "^1.168.49",
18
+ "effect": "4.0.0-rc.112",
19
+ "react": "^19.2.8",
20
+ "react-dom": "^19.2.8"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "^26.4.0",
24
+ "@types/react": "^19.2.0",
25
+ "@types/react-dom": "^19.2.0",
26
+ "@vitejs/plugin-react": "^6.1.1",
27
+ "typescript": "7.0.2",
28
+ "vite": "^8.2.2",
29
+ "vitest": "^4.1.11"
30
+ }
31
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
5
+ "jsx": "react-jsx",
6
+ "types": ["vite/client", "node"]
7
+ },
8
+ "include": ["src"]
9
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The web app's data layer: query and mutation atoms derived from the same
3
+ * `HttpApi` definition the server implements, so a schema change is a compile
4
+ * error here too.
5
+ *
6
+ * This module reads `import.meta.env`, so it belongs to the web app and must
7
+ * not move into a package the server imports.
8
+ */
9
+ import { Api } from "@{{name}}/api/api"
10
+ import { FetchHttpClient } from "effect/unstable/http"
11
+ import { Atom, AtomHttpApi } from "effect/unstable/reactivity"
12
+
13
+ const baseUrl = import.meta.env.VITE_API_URL ?? "http://localhost:3000"
14
+
15
+ export class NotesApi extends AtomHttpApi.Service<NotesApi>()("web/NotesApi", {
16
+ api: Api,
17
+ httpClient: FetchHttpClient.layer,
18
+ baseUrl
19
+ }) {}
20
+
21
+ export const notesList = Atom.keepAlive(
22
+ NotesApi.query("notes", "list", {
23
+ // The identity for the SSR -> client handoff. Without it the atom is not
24
+ // serializable, `Hydration.dehydrate` skips it, and the client refetches on
25
+ // mount — an app that looks right while doing the wrong thing. The
26
+ // round-trip test in this directory is what guards it.
27
+ serializationKey: "notes/list"
28
+ })
29
+ )
30
+
31
+ // `reactivityKeys` is deliberately absent. Combining it with `serializationKey`
32
+ // breaks hydration in effect 4.0.0-rc.112: the preload seeds a node keyed by
33
+ // object identity rather than the serialization string, so the first client
34
+ // read refetches anyway. To retest after upgrading effect, add
35
+ // `reactivityKeys: ["notes"]` to the query above and to the stub in
36
+ // NotesApi.test.ts — if `expect(requests).toBe(1)` still fails, it is not fixed.
37
+ //
38
+ // Until then the list is refreshed explicitly after a successful mutation; see
39
+ // routes/index.tsx.
40
+ export const createNote = NotesApi.mutation("notes", "create")
@@ -0,0 +1,64 @@
1
+ import { RegistryContext } from "@effect/atom-react"
2
+ import { createRouter } from "@tanstack/react-router"
3
+ import { Schema } from "effect"
4
+ import { AtomRegistry, Hydration } from "effect/unstable/reactivity"
5
+ import { routeTree } from "./routeTree.gen"
6
+
7
+ export interface RouterContext {
8
+ readonly registry: AtomRegistry.AtomRegistry
9
+ }
10
+
11
+ // The dehydrated envelope, as a schema rather than a cast. `JSON.parse` returns
12
+ // `any`, and asserting a type onto it would only silence the compiler — this
13
+ // decodes, so the shape that actually crossed the SSR boundary is checked.
14
+ // `value` stays `Unknown` because it varies per atom; `Hydration.hydrate` hands
15
+ // it to that atom's own decoder.
16
+ const DehydratedAtoms = Schema.Array(Schema.Struct({
17
+ "~effect/reactivity/DehydratedAtom": Schema.Literal(true),
18
+ key: Schema.String,
19
+ value: Schema.Unknown,
20
+ dehydratedAt: Schema.Finite
21
+ }))
22
+
23
+ const decodeAtoms = Schema.decodeUnknownSync(DehydratedAtoms)
24
+
25
+ export function getRouter() {
26
+ // One registry per call. On the server `getRouter` runs per request, so this
27
+ // is the isolation boundary that keeps one visitor's data out of another's
28
+ // render; on the client it runs once.
29
+ const registry = AtomRegistry.make()
30
+
31
+ return createRouter({
32
+ routeTree,
33
+ scrollRestoration: true,
34
+ context: { registry } satisfies RouterContext,
35
+ // Runs on the server after rendering: encodes every serializable atom into
36
+ // the payload TanStack Start injects into the HTML.
37
+ //
38
+ // The atoms are serialized to a string rather than handed over as objects.
39
+ // Start validates this return type at compile time and rejects any property
40
+ // typed `unknown`, since it cannot prove it JSON-safe — and
41
+ // `Hydration.DehydratedAtomValue.value` is `unknown` by design, because its
42
+ // shape varies per atom. Its contents come from `Schema.encodeSync`, so they
43
+ // are genuinely JSON-safe; a string says so in a way the type system can
44
+ // check, which beats widening the return to `any` and asserting it.
45
+ dehydrate: () => ({ atoms: JSON.stringify(Hydration.toValues(Hydration.dehydrate(registry))) }),
46
+ // Runs on the client before the first render, so the atoms are already
47
+ // Success by the time any component reads them.
48
+ hydrate: (dehydrated: { readonly atoms: string }) => {
49
+ Hydration.hydrate(registry, decodeAtoms(JSON.parse(dehydrated.atoms)))
50
+ },
51
+ // `@effect/atom-react`'s own `RegistryProvider` builds a registry from
52
+ // options, but ours has to be the per-request one created above — so the
53
+ // context is provided directly.
54
+ Wrap: ({ children }: { readonly children: React.ReactNode }) => (
55
+ <RegistryContext.Provider value={registry}>{children}</RegistryContext.Provider>
56
+ )
57
+ })
58
+ }
59
+
60
+ declare module "@tanstack/react-router" {
61
+ interface Register {
62
+ router: ReturnType<typeof getRouter>
63
+ }
64
+ }
@@ -0,0 +1,38 @@
1
+ import { createRootRouteWithContext, HeadContent, Outlet, Scripts } from "@tanstack/react-router"
2
+ import type { ReactNode } from "react"
3
+ import type { RouterContext } from "../router.tsx"
4
+ import appCss from "../styles.css?url"
5
+
6
+ export const Route = createRootRouteWithContext<RouterContext>()({
7
+ head: () => ({
8
+ meta: [
9
+ { charSet: "utf-8" },
10
+ { name: "viewport", content: "width=device-width, initial-scale=1" },
11
+ { title: "Notes" }
12
+ ],
13
+ links: [{ rel: "stylesheet", href: appCss }]
14
+ }),
15
+ component: RootComponent
16
+ })
17
+
18
+ function RootComponent() {
19
+ return (
20
+ <RootDocument>
21
+ <Outlet />
22
+ </RootDocument>
23
+ )
24
+ }
25
+
26
+ function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
27
+ return (
28
+ <html>
29
+ <head>
30
+ <HeadContent />
31
+ </head>
32
+ <body>
33
+ {children}
34
+ <Scripts />
35
+ </body>
36
+ </html>
37
+ )
38
+ }
@@ -0,0 +1,97 @@
1
+ import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"
2
+ import { createFileRoute } from "@tanstack/react-router"
3
+ import { Cause, Effect, Exit } from "effect"
4
+ import { AsyncResult, AtomRegistry } from "effect/unstable/reactivity"
5
+ import { useState } from "react"
6
+ import { createNote, notesList } from "../atoms/NotesApi.ts"
7
+
8
+ export const Route = createFileRoute("/")({
9
+ component: Home,
10
+ loader: async ({ context }) => {
11
+ // Populate the registry before rendering, so the markup ships with data and
12
+ // `dehydrate` has something to encode. The loader's job is to run the query,
13
+ // not to judge it — the outcome is rendered by the onFailure branch below
14
+ // rather than thrown into an error boundary.
15
+ //
16
+ // `Effect.exit`, not `Effect.ignore`: AtomHttpApi *dies* on a transport
17
+ // error rather than failing, and `ignore` only swallows failures — a defect
18
+ // escapes it and 500s the whole page when the API is unreachable. `exit`
19
+ // captures both, so an API that is down renders the error branch.
20
+ await Effect.runPromise(
21
+ Effect.exit(AtomRegistry.getResult(notesList)(context.registry))
22
+ )
23
+ }
24
+ })
25
+
26
+ function Home() {
27
+ const notes = useAtomValue(notesList)
28
+ const created = useAtomValue(createNote)
29
+ // `promiseExit` makes the mutation awaitable and hands back an Exit, so the
30
+ // submit handler can act on the outcome directly.
31
+ const submit = useAtomSet(createNote, { mode: "promiseExit" })
32
+ const refreshNotes = useAtomRefresh(notesList)
33
+ const [title, setTitle] = useState("")
34
+ const [body, setBody] = useState("")
35
+
36
+ return (
37
+ <main>
38
+ <h1>Notes</h1>
39
+
40
+ <form
41
+ onSubmit={async (event) => {
42
+ event.preventDefault()
43
+ if (title.trim() === "") return
44
+ const exit = await submit({ payload: { title, body } })
45
+ // Only a success clears the fields, so a failed submission leaves the
46
+ // user's input in place to retry. Reactivity keys would normally
47
+ // refresh the list; see the comment in atoms/NotesApi.ts for why the
48
+ // refresh is explicit instead.
49
+ if (Exit.isSuccess(exit)) {
50
+ refreshNotes()
51
+ setTitle("")
52
+ setBody("")
53
+ }
54
+ }}
55
+ >
56
+ <input
57
+ aria-label="Title"
58
+ placeholder="Title"
59
+ value={title}
60
+ onChange={(event) => setTitle(event.target.value)}
61
+ />
62
+ <textarea
63
+ aria-label="Body"
64
+ placeholder="Body"
65
+ rows={3}
66
+ value={body}
67
+ onChange={(event) => setBody(event.target.value)}
68
+ />
69
+ <button type="submit">Add note</button>
70
+ </form>
71
+
72
+ {AsyncResult.isFailure(created) && (
73
+ <p className="error">Could not add note: {Cause.pretty(created.cause)}</p>
74
+ )}
75
+
76
+ {AsyncResult.match(notes, {
77
+ // Hydration removes the loading state on first paint, but not on a
78
+ // client-side refetch of a cold atom, so this branch still earns its place.
79
+ onInitial: () => <p>Loading…</p>,
80
+ onFailure: (failure) => <p className="error">Could not load notes: {Cause.pretty(failure.cause)}</p>,
81
+ onSuccess: (success) =>
82
+ success.value.length === 0
83
+ ? <p>No notes yet.</p>
84
+ : (
85
+ <ul>
86
+ {success.value.map((note) => (
87
+ <li key={note.id}>
88
+ <strong>{note.title}</strong>
89
+ <div>{note.body}</div>
90
+ </li>
91
+ ))}
92
+ </ul>
93
+ )
94
+ })}
95
+ </main>
96
+ )
97
+ }
@@ -0,0 +1,40 @@
1
+ :root {
2
+ color-scheme: light dark;
3
+ font-family: ui-sans-serif, system-ui, sans-serif;
4
+ line-height: 1.5;
5
+ }
6
+
7
+ body {
8
+ margin: 0;
9
+ padding: 2rem;
10
+ max-width: 40rem;
11
+ }
12
+
13
+ form {
14
+ display: grid;
15
+ gap: 0.5rem;
16
+ margin-block: 1.5rem;
17
+ }
18
+
19
+ input,
20
+ textarea,
21
+ button {
22
+ font: inherit;
23
+ padding: 0.5rem;
24
+ }
25
+
26
+ button {
27
+ justify-self: start;
28
+ }
29
+
30
+ ul {
31
+ padding-left: 1rem;
32
+ }
33
+
34
+ li {
35
+ margin-block: 0.75rem;
36
+ }
37
+
38
+ .error {
39
+ color: #b00020;
40
+ }
@@ -0,0 +1,11 @@
1
+ import { tanstackStart } from "@tanstack/react-start/plugin/vite"
2
+ import viteReact from "@vitejs/plugin-react"
3
+ import { defineConfig } from "vite"
4
+
5
+ export default defineConfig({
6
+ server: {
7
+ // Must match WEB_ORIGIN in apps/api, which CORS is scoped to.
8
+ port: 3001
9
+ },
10
+ plugins: [tanstackStart(), viteReact()]
11
+ })
@@ -0,0 +1,12 @@
1
+ {
2
+ "scripts": {
3
+ "prepare": "effect-tsgo patch --typescript",
4
+ "lint": "oxlint apps"
5
+ },
6
+ "devDependencies": {
7
+ "@effect/language-service": "0.87.2",
8
+ "@effect/tsgo": "0.37.0",
9
+ "oxlint": "1.80.0",
10
+ "typescript": "7.0.2"
11
+ }
12
+ }
@@ -0,0 +1,56 @@
1
+ # {{name}}
2
+
3
+ A schema-first HTTP API built with Effect v4 — server, OpenAPI docs, and a typed client
4
+ generated from one definition.
5
+
6
+ ## Run it
7
+
8
+ ```bash
9
+ {{runCmd}} dev # watch mode
10
+ {{runCmd}} start # once
11
+ ```
12
+
13
+ | Route | What |
14
+ | --- | --- |
15
+ | `GET /health` | Liveness probe (204) |
16
+ | `GET /notes` | List notes |
17
+ | `POST /notes` | Create a note |
18
+ | `GET /notes/:id` | Fetch one (404 `NoteNotFound` if absent) |
19
+ | `GET /openapi.json` | Generated OpenAPI document |
20
+ | `GET /docs` | Scalar API reference |
21
+
22
+ The port comes from `PORT`, defaulting to 3000.
23
+
24
+ ## The typed client
25
+
26
+ `src/client/ApiClient.ts` derives its shape from `src/api/Api.ts`, so renaming an endpoint
27
+ or changing a schema is a compile error on both sides. `src/client.ts` is a runnable demo:
28
+
29
+ ```bash
30
+ {{runCmd}} dev # one terminal
31
+ {{runCmd}} client # another
32
+ ```
33
+
34
+ ## Adding an endpoint
35
+
36
+ 1. Declare it in `src/api/Notes.ts` (or a new group added to `src/api/Api.ts`).
37
+ 2. Implement it in `src/server/Notes/http.ts` — the handler list is type-checked against the
38
+ declaration, so a missing handler will not compile.
39
+
40
+ ## Tests
41
+
42
+ ```bash
43
+ {{runCmd}} test
44
+ ```
45
+
46
+ Tests use `HttpApiTest.groups`, which runs the real request encoding, routing and response
47
+ decoding against the handlers with no server and no port.
48
+
49
+ ## Telemetry
50
+
51
+ `src/observability.ts` exports logs, metrics and traces over OTLP/HTTP. It is a no-op unless
52
+ `OTEL_EXPORTER_OTLP_ENDPOINT` is set:
53
+
54
+ ```bash
55
+ OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 {{runCmd}} dev
56
+ ```
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "{{name}}",
3
+ "module": "src/index.ts",
4
+ "type": "module",
5
+ "private": true,
6
+ "engines": { "bun": ">=1.3.0" },
7
+ "scripts": {
8
+ "dev": "bun --hot src/index.ts",
9
+ "start": "bun src/index.ts",
10
+ "test": "bun test",
11
+ "typecheck": "tsc --noEmit",
12
+ "client": "bun src/client.ts"
13
+ },
14
+ "dependencies": {
15
+ "effect": "4.0.0-rc.112",
16
+ "@effect/platform-bun": "4.0.0-rc.112"
17
+ },
18
+ "devDependencies": {
19
+ "@types/bun": "^1.4.0",
20
+ "typescript": "7.0.2"
21
+ }
22
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "compilerOptions": {
3
+ "lib": ["ESNext"], "target": "ESNext", "module": "Preserve",
4
+ "moduleDetection": "force", "types": ["bun"],
5
+ "moduleResolution": "bundler", "allowImportingTsExtensions": true,
6
+ "verbatimModuleSyntax": true, "noEmit": true, "strict": true,
7
+ "skipLibCheck": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true
8
+ },
9
+ "include": ["src"]
10
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Client entrypoint — exercises the running server with the generated client.
3
+ *
4
+ * {{runCmd}} dev # in one terminal
5
+ * {{runCmd}} client # in another
6
+ */
7
+ import { BunRuntime } from "@effect/platform-bun"
8
+ import { Effect, Layer } from "effect"
9
+ import { ApiClient } from "./client/ApiClient.ts"
10
+ import { NoteId } from "./domain/Note.ts"
11
+ import * as Observability from "./observability.ts"
12
+
13
+ const program = Effect.gen(function*() {
14
+ const client = yield* ApiClient
15
+
16
+ // `system` is a top level group, so its endpoints sit at the root.
17
+ yield* client.health()
18
+ yield* Effect.logInfo("health: ok")
19
+
20
+ const created = yield* client.notes.create({ payload: { title: "first", body: "hello" } })
21
+ yield* Effect.logInfo("created", created)
22
+
23
+ const fetched = yield* client.notes.getById({ params: { id: created.id } })
24
+ yield* Effect.logInfo("getById", fetched)
25
+
26
+ const all = yield* client.notes.list()
27
+ yield* Effect.logInfo("list", all)
28
+
29
+ // Errors are typed too: an unknown id fails with `NoteNotFound`, which the
30
+ // server returns as a 404.
31
+ const missing = yield* client.notes.getById({ params: { id: NoteId.make("nope") } }).pipe(
32
+ Effect.catchTag("NoteNotFound", () => Effect.succeed("NoteNotFound (404) as expected"))
33
+ )
34
+ yield* Effect.logInfo("getById?id=nope", missing)
35
+ }).pipe(
36
+ Effect.withSpan("client-demo"),
37
+ Effect.provide(Layer.mergeAll(ApiClient.layer, Observability.layer("notes-client")))
38
+ )
39
+
40
+ program.pipe(BunRuntime.runMain)
@@ -0,0 +1,45 @@
1
+ import { expect, test } from "bun:test"
2
+ import type { Scope } from "effect"
3
+ import { Effect, Layer, References } from "effect"
4
+ import { HttpServer } from "effect/unstable/http"
5
+ import { HttpApiTest } from "effect/unstable/httpapi"
6
+ import { Api } from "../../api/Api.ts"
7
+ import { NoteId } from "../../domain/Note.ts"
8
+ import { Notes } from "../Notes.ts"
9
+ import { NotesApiHandlersNoDeps } from "./http.ts"
10
+
11
+ const TestLayer = Layer.mergeAll(
12
+ NotesApiHandlersNoDeps.pipe(Layer.provide(Notes.layerMemory)),
13
+ HttpServer.layerServices
14
+ )
15
+
16
+ const makeClient = HttpApiTest.groups(Api, ["notes"])
17
+
18
+ const run = <A, E, R>(layer: Layer.Layer<R>, effect: Effect.Effect<A, E, R | Scope.Scope>) =>
19
+ effect.pipe(
20
+ Effect.scoped,
21
+ Effect.provide(layer),
22
+ // Keep the test output to the assertions; raise this to see app logs.
23
+ Effect.provideService(References.MinimumLogLevel, "None"),
24
+ Effect.runPromise
25
+ )
26
+
27
+ test("creates then lists a note", () =>
28
+ run(TestLayer, Effect.gen(function*() {
29
+ const client = yield* makeClient
30
+ const created = yield* client.notes.create({ payload: { title: "first", body: "hello" } })
31
+ expect(created.title).toBe("first")
32
+
33
+ const listed = yield* client.notes.list()
34
+ expect(listed.length).toBe(1)
35
+ expect(listed[0]?.id).toBe(created.id)
36
+ })))
37
+
38
+ test("fails with NoteNotFound for an unknown id", () =>
39
+ run(TestLayer, Effect.gen(function*() {
40
+ const client = yield* makeClient
41
+ const error = yield* Effect.flip(
42
+ client.notes.getById({ params: { id: NoteId.make("nope") } })
43
+ )
44
+ expect(error._tag).toBe("NoteNotFound")
45
+ })))
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Server entrypoint.
3
+ *
4
+ * A schema-first API (`src/api`) is implemented by handlers (`src/server`),
5
+ * served over Bun, and consumed by a generated typed client (`src/client`).
6
+ *
7
+ * {{runCmd}} dev # http://localhost:3000/docs
8
+ */
9
+ import { BunHttpServer, BunRuntime } from "@effect/platform-bun"
10
+ import { Layer } from "effect"
11
+ import { HttpRouter } from "effect/unstable/http"
12
+ import { port } from "./config.ts"
13
+ import * as Observability from "./observability.ts"
14
+ import { AllRoutes } from "./server/http.ts"
15
+
16
+ const HttpServerLayer = HttpRouter.serve(AllRoutes).pipe(
17
+ Layer.provide(BunHttpServer.layerConfig({ port }))
18
+ )
19
+
20
+ const Main = HttpServerLayer.pipe(
21
+ // Provided at the very end, so every span the app creates is exported.
22
+ Layer.provide(Observability.layer("{{name}}"))
23
+ )
24
+
25
+ Layer.launch(Main).pipe(BunRuntime.runMain)
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "{{name}}",
3
+ "type": "module",
4
+ "private": true,
5
+ "engines": { "node": ">=22.6.0" },
6
+ "scripts": {
7
+ "dev": "node --experimental-strip-types --watch src/index.ts",
8
+ "start": "node --experimental-strip-types src/index.ts",
9
+ "test": "vitest run",
10
+ "typecheck": "tsc --noEmit",
11
+ "client": "node --experimental-strip-types src/client.ts"
12
+ },
13
+ "dependencies": {
14
+ "@effect/platform-node": "4.0.0-rc.112",
15
+ "effect": "4.0.0-rc.112"
16
+ },
17
+ "devDependencies": {
18
+ "@effect/vitest": "4.0.0-rc.112",
19
+ "@types/node": "^26.4.0",
20
+ "typescript": "7.0.2",
21
+ "vitest": "^4.1.11"
22
+ }
23
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ "lib": [
4
+ "ESNext"
5
+ ],
6
+ "target": "ESNext",
7
+ "module": "nodenext",
8
+ "moduleResolution": "nodenext",
9
+ "moduleDetection": "force",
10
+ "types": [
11
+ "node"
12
+ ],
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "noEmit": true,
16
+ "strict": true,
17
+ "skipLibCheck": true,
18
+ "noUncheckedIndexedAccess": true,
19
+ "noImplicitOverride": true
20
+ },
21
+ "include": [
22
+ "src"
23
+ ]
24
+ }