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,37 @@
1
+ /**
2
+ * Client entrypoint — exercises the API with the generated typed client.
3
+ *
4
+ * Point it at the deployed Worker, or at `{{runCmd}} dev`:
5
+ *
6
+ * API_URL=https://... {{runCmd}} client
7
+ */
8
+ import { NodeRuntime } from "@effect/platform-node"
9
+ import { Effect } from "effect"
10
+ import { ApiClient } from "./client/ApiClient.ts"
11
+ import { NoteId } from "./domain/Note.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
+ // Worker 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(Effect.provide(ApiClient.layer))
36
+
37
+ program.pipe(NodeRuntime.runMain)
@@ -0,0 +1,48 @@
1
+ import { assert, describe, it } from "@effect/vitest"
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
+ // Handlers wired to the in-memory Notes, so the whole HTTP pipeline runs
12
+ // without a server: same encoding, routing and decoding as production.
13
+ const TestLayer = Layer.mergeAll(
14
+ NotesApiHandlersNoDeps.pipe(Layer.provide(Notes.layerMemory)),
15
+ HttpServer.layerServices
16
+ )
17
+
18
+ const makeClient = HttpApiTest.groups(Api, ["notes"])
19
+
20
+ const run = <A, E, R>(layer: Layer.Layer<R>, effect: Effect.Effect<A, E, R | Scope.Scope>) =>
21
+ effect.pipe(
22
+ Effect.scoped,
23
+ Effect.provide(layer),
24
+ // Keep the test output to the assertions; raise this to see app logs.
25
+ Effect.provideService(References.MinimumLogLevel, "None")
26
+ )
27
+
28
+ describe("notes api", () => {
29
+ it.effect("creates then lists a note", () =>
30
+ run(TestLayer, Effect.gen(function*() {
31
+ const client = yield* makeClient
32
+ const created = yield* client.notes.create({ payload: { title: "first", body: "hello" } })
33
+ assert.strictEqual(created.title, "first")
34
+
35
+ const listed = yield* client.notes.list()
36
+ assert.strictEqual(listed.length, 1)
37
+ assert.strictEqual(listed[0]?.id, created.id)
38
+ })))
39
+
40
+ it.effect("fails with NoteNotFound for an unknown id", () =>
41
+ run(TestLayer, Effect.gen(function*() {
42
+ const client = yield* makeClient
43
+ const error = yield* Effect.flip(
44
+ client.notes.getById({ params: { id: NoteId.make("nope") } })
45
+ )
46
+ assert.strictEqual(error._tag, "NoteNotFound")
47
+ })))
48
+ })
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Cloudflare Worker entrypoint.
3
+ *
4
+ * The same `AllRoutes` the local server uses, converted to a fetch handler
5
+ * instead of being bound to a port. Nothing under `src/api`, `src/server` or
6
+ * `src/client` changes between the two — one HttpApi definition, two targets.
7
+ */
8
+ import * as Cloudflare from "alchemy/Cloudflare"
9
+ import * as Effect from "effect/Effect"
10
+ import { HttpRouter, HttpServer } from "effect/unstable/http"
11
+ import { AllRoutes } from "./server/http.ts"
12
+
13
+ export default Cloudflare.Worker(
14
+ "{{name}}",
15
+ { main: import.meta.url },
16
+ Effect.succeed({
17
+ // `layerServices` supplies the file-serving services the OpenAPI and Scalar
18
+ // routes ask for, backed by a no-op filesystem — a Worker has no disk, and
19
+ // those routes serve generated content rather than files.
20
+ fetch: HttpRouter.toHttpEffect(AllRoutes).pipe(Effect.provide(HttpServer.layerServices))
21
+ })
22
+ )
@@ -0,0 +1,83 @@
1
+ # {{name}}
2
+
3
+ An Effect v4 RPC service deployed to a Cloudflare Worker with
4
+ [Alchemy](https://alchemy.run) — infrastructure as Effects, no YAML and no `wrangler.toml`.
5
+
6
+ ## Run it
7
+
8
+ ```bash
9
+ npx alchemy login # once, connects your Cloudflare account
10
+ {{runCmd}} dev # local
11
+ {{runCmd}} plan # what would change
12
+ {{runCmd}} deploy # apply it — prints the URL
13
+ {{runCmd}} destroy # tear it down
14
+ ```
15
+
16
+ ## RPC instead of HTTP
17
+
18
+ `src/rpc.ts` declares the contract once. The server's handler types and the client's method
19
+ signatures both come from it, so a rename or a schema change is a compile error on both sides:
20
+
21
+ ```ts
22
+ const getById = Rpc.make("getById", {
23
+ success: Note,
24
+ error: NoteNotFound,
25
+ payload: { id: NoteId }
26
+ })
27
+ ```
28
+
29
+ Calling it is a method call, not a request:
30
+
31
+ ```ts
32
+ const client = yield* RpcClient.make(NotesRpcs)
33
+ const note = yield* client.getById({ id })
34
+ ```
35
+
36
+ The difference from the HttpApi templates is what crosses the wire. `NoteNotFound` arrives as
37
+ **itself** — `Effect.catchTag("NoteNotFound", ...)` on the client — rather than as a 404 the caller
38
+ has to interpret. There are no paths, no verbs and no status codes to agree on. What you give up is
39
+ everything that comes from being ordinary HTTP: no OpenAPI document, no Scalar page, and no `curl`.
40
+
41
+ Scaffold `--template alchemy-http` instead if you want a public, documented API.
42
+
43
+ ## What's here
44
+
45
+ | File | What |
46
+ | --- | --- |
47
+ | `src/rpc.ts` | The contract — `Rpc.make` procedures collected into an `RpcGroup` |
48
+ | `src/domain/Note.ts` | The domain: a `Schema.Class`, a branded `NoteId`, a `NoteNotFound` error |
49
+ | `src/server/Notes.ts` | The `Notes` service — plain business logic, knows nothing about RPC |
50
+ | `src/server/Notes/rpc.ts` | The handlers: a thin adapter from the group onto that service |
51
+ | `src/worker.ts` | The Worker — `RpcWorker` takes the group as `schema` |
52
+ | `alchemy.run.ts` | The stack |
53
+ | `src/client.ts` | A runnable demo client |
54
+
55
+ `src/server/Notes.ts` is the same file the HTTP templates use. Logic lives there and the transport
56
+ adapter is `src/server/Notes/rpc.ts`, so moving between RPC and HTTP touches one file.
57
+
58
+ ## Infrastructure
59
+
60
+ `alchemy.run.ts` is ordinary TypeScript returning an Effect. Adding a bucket is a value, not a
61
+ config file:
62
+
63
+ ```ts
64
+ const Uploads = Cloudflare.R2.Bucket("Uploads")
65
+ // then, inside the worker's init:
66
+ const uploads = yield* Cloudflare.R2.ReadWriteBucket(Uploads)
67
+ ```
68
+
69
+ ## Tests
70
+
71
+ ```bash
72
+ {{runCmd}} test
73
+ ```
74
+
75
+ `RpcTest.makeClient` runs the real encode/decode round trip against the handlers in-process — no
76
+ Worker, no port, and no Cloudflare account. These are the tests CI can run.
77
+
78
+ ## Telemetry
79
+
80
+ Not wired up. OTLP export from inside a Worker isolate is not the right shape for Cloudflare —
81
+ use [Workers Observability](https://developers.cloudflare.com/workers/observability/) or a
82
+ [tail worker](https://developers.cloudflare.com/workers/observability/logs/tail-workers/), and
83
+ `{{runCmd}} tail` to stream logs. That is why `--otel` does not apply to this template.
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The stack: what gets deployed, and what it reports back.
3
+ *
4
+ * {{runCmd}} plan # what would change
5
+ * {{runCmd}} deploy # apply it, prints the URL
6
+ * {{runCmd}} destroy # tear it down
7
+ *
8
+ * Deploying needs a Cloudflare account — `npx alchemy login` once.
9
+ */
10
+ import * as Alchemy from "alchemy"
11
+ import * as Cloudflare from "alchemy/Cloudflare"
12
+ import * as Effect from "effect/Effect"
13
+ import Worker from "./src/worker.ts"
14
+
15
+ export default Alchemy.Stack(
16
+ "{{name}}",
17
+ { providers: Cloudflare.providers(), state: Cloudflare.state() },
18
+ Effect.gen(function*() {
19
+ const worker = yield* Worker
20
+ return { url: worker.url }
21
+ })
22
+ )
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "{{name}}",
3
+ "module": "src/worker.ts",
4
+ "type": "module",
5
+ "private": true,
6
+ "engines": { "bun": ">=1.3.0" },
7
+ "scripts": {
8
+ "dev": "alchemy dev",
9
+ "plan": "alchemy plan",
10
+ "deploy": "alchemy deploy",
11
+ "destroy": "alchemy destroy",
12
+ "tail": "alchemy tail",
13
+ "test": "bun test",
14
+ "typecheck": "tsc --noEmit",
15
+ "client": "bun src/client.ts"
16
+ },
17
+ "dependencies": {
18
+ "@effect/platform-bun": "4.0.0-rc.112",
19
+ "alchemy": "2.0.0-beta.74",
20
+ "effect": "4.0.0-rc.112"
21
+ },
22
+ "devDependencies": {
23
+ "@cloudflare/workers-types": "^5.20260827.0",
24
+ "@types/bun": "^1.4.0",
25
+ "typescript": "7.0.2"
26
+ }
27
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "compilerOptions": {
3
+ "lib": [
4
+ "ESNext",
5
+ "DOM"
6
+ ],
7
+ "target": "ESNext",
8
+ "module": "esnext",
9
+ "moduleResolution": "bundler",
10
+ "moduleDetection": "force",
11
+ "types": [
12
+ "@cloudflare/workers-types",
13
+ "bun"
14
+ ],
15
+ "allowImportingTsExtensions": true,
16
+ "verbatimModuleSyntax": true,
17
+ "noEmit": true,
18
+ "strict": true,
19
+ "skipLibCheck": true,
20
+ "noUncheckedIndexedAccess": true,
21
+ "noImplicitOverride": true
22
+ },
23
+ "include": [
24
+ "src",
25
+ "alchemy.run.ts"
26
+ ]
27
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Client entrypoint — calls the RPC group as ordinary typed methods.
3
+ *
4
+ * Point it at the deployed Worker, or at `{{runCmd}} dev`:
5
+ *
6
+ * API_URL=https://... {{runCmd}} client
7
+ */
8
+ import { BunRuntime } from "@effect/platform-bun"
9
+ import { Effect, Layer } from "effect"
10
+ import { FetchHttpClient } from "effect/unstable/http"
11
+ import { RpcClient, RpcSerialization } from "effect/unstable/rpc"
12
+ import { baseUrl } from "./config.ts"
13
+ import { NoteId } from "./domain/Note.ts"
14
+ import { NotesRpcs } from "./rpc.ts"
15
+
16
+ const program = Effect.gen(function*() {
17
+ // The client's methods are derived from the group, so this call is checked
18
+ // against the server's handler at compile time.
19
+ const client = yield* RpcClient.make(NotesRpcs)
20
+
21
+ const created = yield* client.create({ title: "first", body: "hello" })
22
+ yield* Effect.logInfo("created", created)
23
+
24
+ const fetched = yield* client.getById({ id: created.id })
25
+ yield* Effect.logInfo("getById", fetched)
26
+
27
+ const all = yield* client.list()
28
+ yield* Effect.logInfo("list", all)
29
+
30
+ // The error arrives as `NoteNotFound` itself — no status code to decode.
31
+ const missing = yield* client.getById({ id: NoteId.make("nope") }).pipe(
32
+ Effect.catchTag("NoteNotFound", (error) => Effect.succeed(`no note ${error.id} — handled`))
33
+ )
34
+ yield* Effect.logInfo("getById?id=nope", missing)
35
+ })
36
+
37
+ const ClientLayer = Layer.unwrap(Effect.map(baseUrl, (url) =>
38
+ RpcClient.layerProtocolHttp({ url }).pipe(
39
+ Layer.provide(FetchHttpClient.layer),
40
+ Layer.provide(RpcSerialization.layerNdjson)
41
+ )))
42
+
43
+ program.pipe(Effect.scoped, Effect.provide(ClientLayer), BunRuntime.runMain)
@@ -0,0 +1,40 @@
1
+ import { expect, test } from "bun:test"
2
+ import { Effect, type Layer, References, type Scope } from "effect"
3
+ import { RpcTest } from "effect/unstable/rpc"
4
+ import { NoteId } from "../../domain/Note.ts"
5
+ import { NotesRpcs } from "../../rpc.ts"
6
+ import { NotesRpcHandlers } from "./rpc.ts"
7
+
8
+ // `RpcTest.makeClient` runs the real encode/decode round trip against the
9
+ // handlers in-process: no Worker, no port, no Cloudflare account.
10
+ //
11
+ // `runPromise` demands a fully-provided effect, so R is pinned to exactly what
12
+ // NotesRpcHandlers supplies rather than left generic.
13
+ type Handlers = Layer.Success<typeof NotesRpcHandlers>
14
+
15
+ const run = <A, E>(effect: Effect.Effect<A, E, Handlers | Scope.Scope>) =>
16
+ effect.pipe(
17
+ Effect.provide(NotesRpcHandlers),
18
+ // Keep the output to the assertions; raise this to see app logs.
19
+ Effect.provideService(References.MinimumLogLevel, "None"),
20
+ Effect.scoped,
21
+ Effect.runPromise
22
+ )
23
+
24
+ test("creates then lists a note", () =>
25
+ run(Effect.gen(function*() {
26
+ const client = yield* RpcTest.makeClient(NotesRpcs)
27
+ const created = yield* client.create({ title: "first", body: "hello" })
28
+ expect(created.title).toBe("first")
29
+
30
+ const listed = yield* client.list()
31
+ expect(listed.length).toBe(1)
32
+ expect(listed[0]?.id).toBe(created.id)
33
+ })))
34
+
35
+ test("fails with NoteNotFound for an unknown id", () =>
36
+ run(Effect.gen(function*() {
37
+ const client = yield* RpcTest.makeClient(NotesRpcs)
38
+ const error = yield* Effect.flip(client.getById({ id: NoteId.make("nope") }))
39
+ expect(error._tag).toBe("NoteNotFound")
40
+ })))
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "{{name}}",
3
+ "type": "module",
4
+ "private": true,
5
+ "engines": { "node": ">=22.6.0" },
6
+ "scripts": {
7
+ "dev": "alchemy dev",
8
+ "plan": "alchemy plan",
9
+ "deploy": "alchemy deploy",
10
+ "destroy": "alchemy destroy",
11
+ "tail": "alchemy tail",
12
+ "test": "vitest run",
13
+ "typecheck": "tsc --noEmit",
14
+ "client": "node --experimental-strip-types src/client.ts"
15
+ },
16
+ "dependencies": {
17
+ "@effect/platform-node": "4.0.0-rc.112",
18
+ "alchemy": "2.0.0-beta.74",
19
+ "effect": "4.0.0-rc.112"
20
+ },
21
+ "devDependencies": {
22
+ "@cloudflare/workers-types": "^5.20260827.0",
23
+ "@effect/vitest": "4.0.0-rc.112",
24
+ "@types/node": "^26.4.0",
25
+ "typescript": "7.0.2",
26
+ "vitest": "^4.1.11"
27
+ }
28
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "compilerOptions": {
3
+ "lib": [
4
+ "ESNext",
5
+ "DOM"
6
+ ],
7
+ "target": "ESNext",
8
+ "module": "esnext",
9
+ "moduleResolution": "bundler",
10
+ "moduleDetection": "force",
11
+ "types": [
12
+ "@cloudflare/workers-types",
13
+ "node"
14
+ ],
15
+ "allowImportingTsExtensions": true,
16
+ "verbatimModuleSyntax": true,
17
+ "noEmit": true,
18
+ "strict": true,
19
+ "skipLibCheck": true,
20
+ "noUncheckedIndexedAccess": true,
21
+ "noImplicitOverride": true
22
+ },
23
+ "include": [
24
+ "src",
25
+ "alchemy.run.ts"
26
+ ]
27
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Client entrypoint — calls the RPC group as ordinary typed methods.
3
+ *
4
+ * Point it at the deployed Worker, or at `{{runCmd}} dev`:
5
+ *
6
+ * API_URL=https://... {{runCmd}} client
7
+ */
8
+ import { NodeRuntime } from "@effect/platform-node"
9
+ import { Effect, Layer } from "effect"
10
+ import { FetchHttpClient } from "effect/unstable/http"
11
+ import { RpcClient, RpcSerialization } from "effect/unstable/rpc"
12
+ import { baseUrl } from "./config.ts"
13
+ import { NoteId } from "./domain/Note.ts"
14
+ import { NotesRpcs } from "./rpc.ts"
15
+
16
+ const program = Effect.gen(function*() {
17
+ // The client's methods are derived from the group, so this call is checked
18
+ // against the server's handler at compile time.
19
+ const client = yield* RpcClient.make(NotesRpcs)
20
+
21
+ const created = yield* client.create({ title: "first", body: "hello" })
22
+ yield* Effect.logInfo("created", created)
23
+
24
+ const fetched = yield* client.getById({ id: created.id })
25
+ yield* Effect.logInfo("getById", fetched)
26
+
27
+ const all = yield* client.list()
28
+ yield* Effect.logInfo("list", all)
29
+
30
+ // The error arrives as `NoteNotFound` itself — no status code to decode.
31
+ const missing = yield* client.getById({ id: NoteId.make("nope") }).pipe(
32
+ Effect.catchTag("NoteNotFound", (error) => Effect.succeed(`no note ${error.id} — handled`))
33
+ )
34
+ yield* Effect.logInfo("getById?id=nope", missing)
35
+ })
36
+
37
+ const ClientLayer = Layer.unwrap(Effect.map(baseUrl, (url) =>
38
+ RpcClient.layerProtocolHttp({ url }).pipe(
39
+ Layer.provide(FetchHttpClient.layer),
40
+ Layer.provide(RpcSerialization.layerNdjson)
41
+ )))
42
+
43
+ program.pipe(Effect.scoped, Effect.provide(ClientLayer), NodeRuntime.runMain)
@@ -0,0 +1,41 @@
1
+ import { assert, describe, it } from "@effect/vitest"
2
+ import { Effect, type Layer, References, type Scope } from "effect"
3
+ import { RpcTest } from "effect/unstable/rpc"
4
+ import { NoteId } from "../../domain/Note.ts"
5
+ import { NotesRpcs } from "../../rpc.ts"
6
+ import { NotesRpcHandlers } from "./rpc.ts"
7
+
8
+ // `RpcTest.makeClient` runs the real encode/decode round trip against the
9
+ // handlers in-process: no Worker, no port, no Cloudflare account.
10
+ //
11
+ // R is pinned to exactly what NotesRpcHandlers supplies rather than left
12
+ // generic, so an unprovided requirement is a compile error here.
13
+ type Handlers = Layer.Success<typeof NotesRpcHandlers>
14
+
15
+ const run = <A, E>(effect: Effect.Effect<A, E, Handlers | Scope.Scope>) =>
16
+ effect.pipe(
17
+ Effect.provide(NotesRpcHandlers),
18
+ // Keep the output to the assertions; raise this to see app logs.
19
+ Effect.provideService(References.MinimumLogLevel, "None"),
20
+ Effect.scoped
21
+ )
22
+
23
+ describe("notes rpc", () => {
24
+ it.effect("creates then lists a note", () =>
25
+ run(Effect.gen(function*() {
26
+ const client = yield* RpcTest.makeClient(NotesRpcs)
27
+ const created = yield* client.create({ title: "first", body: "hello" })
28
+ assert.strictEqual(created.title, "first")
29
+
30
+ const listed = yield* client.list()
31
+ assert.strictEqual(listed.length, 1)
32
+ assert.strictEqual(listed[0]?.id, created.id)
33
+ })))
34
+
35
+ it.effect("fails with NoteNotFound for an unknown id", () =>
36
+ run(Effect.gen(function*() {
37
+ const client = yield* RpcTest.makeClient(NotesRpcs)
38
+ const error = yield* Effect.flip(client.getById({ id: NoteId.make("nope") }))
39
+ assert.strictEqual(error._tag, "NoteNotFound")
40
+ })))
41
+ })
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The RPC contract.
3
+ *
4
+ * One declaration produces the server's handler types and the client's method
5
+ * signatures, so a rename or a schema change is a compile error on both sides —
6
+ * the same guarantee `HttpApi` gives, without HTTP semantics in the way.
7
+ */
8
+ import { Schema } from "effect"
9
+ import { Rpc, RpcGroup } from "effect/unstable/rpc"
10
+ import { Note, NoteCreate, NoteId, NoteNotFound } from "./domain/Note.ts"
11
+
12
+ const list = Rpc.make("list", {
13
+ success: Schema.Array(Note)
14
+ })
15
+
16
+ const getById = Rpc.make("getById", {
17
+ success: Note,
18
+ // Typed failures cross the wire as themselves: the client gets a
19
+ // `NoteNotFound`, not a status code it has to interpret.
20
+ error: NoteNotFound,
21
+ payload: { id: NoteId }
22
+ })
23
+
24
+ const create = Rpc.make("create", {
25
+ success: Note,
26
+ payload: NoteCreate.fields
27
+ })
28
+
29
+ export class NotesRpcs extends RpcGroup.make(list, getById, create) {}
@@ -0,0 +1,17 @@
1
+ import { Effect, Layer } from "effect"
2
+ import { NotesRpcs } from "../../rpc.ts"
3
+ import { Notes } from "../Notes.ts"
4
+
5
+ /**
6
+ * The handlers are a thin adapter over the `Notes` service — the same service
7
+ * the HTTP templates use. Business logic lives in `src/server/Notes.ts` and
8
+ * knows nothing about RPC, so swapping the transport touches only this file.
9
+ */
10
+ export const NotesRpcHandlers = NotesRpcs.toLayer(Effect.gen(function*() {
11
+ const notes = yield* Notes
12
+ return {
13
+ list: () => notes.list,
14
+ getById: ({ id }) => notes.getById(id),
15
+ create: (payload) => notes.create(payload)
16
+ }
17
+ })).pipe(Layer.provide(Notes.layerMemory))
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Cloudflare Worker entrypoint.
3
+ *
4
+ * `RpcWorker` takes the group as `schema`, so the deployed Worker and any
5
+ * consumer that imports this class agree on the contract by construction.
6
+ */
7
+ import * as Cloudflare from "alchemy/Cloudflare"
8
+ import { Effect, Layer } from "effect"
9
+ import { RpcSerialization, RpcServer } from "effect/unstable/rpc"
10
+ import { NotesRpcs } from "./rpc.ts"
11
+ import { NotesRpcHandlers } from "./server/Notes/rpc.ts"
12
+
13
+ export default class Worker extends Cloudflare.RpcWorker<Worker>()(
14
+ "{{name}}",
15
+ { main: import.meta.url, schema: NotesRpcs },
16
+ // `Effect.succeed` rather than `Effect.gen`: the outer effect is one-time
17
+ // init, the inner one serves each request. Returning the inner effect from a
18
+ // generator produces the same nesting but reads like a mistake.
19
+ Effect.succeed(
20
+ RpcServer.toHttpEffect(NotesRpcs).pipe(
21
+ Effect.provide(Layer.mergeAll(NotesRpcHandlers, RpcSerialization.layerNdjson))
22
+ )
23
+ )
24
+ ) {}
@@ -0,0 +1,53 @@
1
+ # {{name}}
2
+
3
+ A plain Effect v4 program — a service, a typed error, and a `main` that runs and exits.
4
+ No server, no build step: the runtime executes TypeScript directly.
5
+
6
+ ## Run it
7
+
8
+ ```bash
9
+ {{runCmd}} dev # watch mode
10
+ {{runCmd}} start # once
11
+ {{runCmd}} test
12
+ ```
13
+
14
+ ## What's here
15
+
16
+ | File | What |
17
+ | --- | --- |
18
+ | `src/domain/User.ts` | The domain: a `Schema.Class`, a branded `UserId`, and a `UserNotFound` error |
19
+ | `src/Users.ts` | A `Context.Service` — an interface, a tag, and `layerMemory` supplying it |
20
+ | `src/main.ts` | The program: create, fetch, list, and handle the failure |
21
+ | `src/Users.test.ts` | Tests against the real layer |
22
+
23
+ ## The shape to copy
24
+
25
+ `src/Users.ts` is the pattern worth reusing. The interface is what callers depend on;
26
+ `layerMemory` is one implementation of it. Replacing it with a database-backed layer is a
27
+ change to that one file — `main.ts` and the tests keep compiling, because neither mentions
28
+ the implementation.
29
+
30
+ Failures work the same way. `getById` returns `Effect<User, UserNotFound>`, so the error is
31
+ part of the type. `main.ts` has to handle it; deleting the `Effect.catchTag` is a compile
32
+ error rather than an unhandled rejection in production.
33
+
34
+ ## Tests
35
+
36
+ ```bash
37
+ {{runCmd}} test
38
+ ```
39
+
40
+ They run against `Users.layerMemory` — the real implementation, not a mock. When you add a
41
+ database layer, keep the in-memory one for tests: the seam is the layer, so nothing needs
42
+ stubbing.
43
+
44
+ ## Telemetry
45
+
46
+ `src/observability.ts` exports logs, metrics and traces over OTLP/HTTP. It is a no-op unless
47
+ `OTEL_EXPORTER_OTLP_ENDPOINT` is set:
48
+
49
+ ```bash
50
+ OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 {{runCmd}} start
51
+ ```
52
+
53
+ The `Effect.fn("Users.getById")` names in `src/Users.ts` are the span names you will see.
@@ -0,0 +1,31 @@
1
+ import { expect, test } from "bun:test"
2
+ import { Effect, References } from "effect"
3
+ import { UserId } from "./domain/User.ts"
4
+ import { Users } from "./Users.ts"
5
+
6
+ // Tests run against the real layer, not a mock: `layerMemory` IS the seam.
7
+ const run = <A, E>(effect: Effect.Effect<A, E, Users>) =>
8
+ effect.pipe(
9
+ Effect.provide(Users.layerMemory),
10
+ // Keep the output to the assertions; raise this to see app logs.
11
+ Effect.provideService(References.MinimumLogLevel, "None"),
12
+ Effect.runPromise
13
+ )
14
+
15
+ test("creates then lists a user", () =>
16
+ run(Effect.gen(function*() {
17
+ const users = yield* Users
18
+ const created = yield* users.create({ name: "Ada", email: "ada@example.com" })
19
+ expect(created.name).toBe("Ada")
20
+
21
+ const listed = yield* users.list
22
+ expect(listed.length).toBe(1)
23
+ expect(listed[0]?.id).toBe(created.id)
24
+ })))
25
+
26
+ test("fails with UserNotFound for an unknown id", () =>
27
+ run(Effect.gen(function*() {
28
+ const users = yield* Users
29
+ const error = yield* Effect.flip(users.getById(UserId.make("nope")))
30
+ expect(error._tag).toBe("UserNotFound")
31
+ })))