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.
- package/LICENSE +21 -0
- package/README.md +72 -0
- package/dist/bin.js +58245 -0
- package/package.json +34 -0
- package/templates/_shared/_gitignore +4 -0
- package/templates/_shared/config.ts +13 -0
- package/templates/_shared/features/_oxlintrc.json +5 -0
- package/templates/_shared/features/_vscode.settings.json +6 -0
- package/templates/_shared/features/lint.package.json +11 -0
- package/templates/_shared/features/lint.tsconfig.json +16 -0
- package/templates/_shared/features/observability.noop.ts +9 -0
- package/templates/_shared/features/observability.otel.ts +23 -0
- package/templates/_shared/features/slop.oxlintrc.json +25 -0
- package/templates/_shared/httpapi/api/Api.ts +9 -0
- package/templates/_shared/httpapi/api/Notes.ts +25 -0
- package/templates/_shared/httpapi/api/System.ts +7 -0
- package/templates/_shared/httpapi/client/ApiClient.ts +24 -0
- package/templates/_shared/httpapi/server/Notes/http.ts +22 -0
- package/templates/_shared/httpapi/server/http.ts +18 -0
- package/templates/_shared/notes/Notes.ts +42 -0
- package/templates/_shared/notes/domain/Note.ts +19 -0
- package/templates/alchemy-http/README.md +79 -0
- package/templates/alchemy-http/alchemy.run.ts +22 -0
- package/templates/alchemy-http/runtime/bun/_package.json +27 -0
- package/templates/alchemy-http/runtime/bun/_tsconfig.json +27 -0
- package/templates/alchemy-http/runtime/bun/client.ts +37 -0
- package/templates/alchemy-http/runtime/bun/http.test.ts +45 -0
- package/templates/alchemy-http/runtime/node/_package.json +28 -0
- package/templates/alchemy-http/runtime/node/_tsconfig.json +27 -0
- package/templates/alchemy-http/runtime/node/client.ts +37 -0
- package/templates/alchemy-http/runtime/node/http.test.ts +48 -0
- package/templates/alchemy-http/src/worker.ts +22 -0
- package/templates/alchemy-rpc/README.md +83 -0
- package/templates/alchemy-rpc/alchemy.run.ts +22 -0
- package/templates/alchemy-rpc/runtime/bun/_package.json +27 -0
- package/templates/alchemy-rpc/runtime/bun/_tsconfig.json +27 -0
- package/templates/alchemy-rpc/runtime/bun/client.ts +43 -0
- package/templates/alchemy-rpc/runtime/bun/rpc.test.ts +40 -0
- package/templates/alchemy-rpc/runtime/node/_package.json +28 -0
- package/templates/alchemy-rpc/runtime/node/_tsconfig.json +27 -0
- package/templates/alchemy-rpc/runtime/node/client.ts +43 -0
- package/templates/alchemy-rpc/runtime/node/rpc.test.ts +41 -0
- package/templates/alchemy-rpc/src/rpc.ts +29 -0
- package/templates/alchemy-rpc/src/server/Notes/rpc.ts +17 -0
- package/templates/alchemy-rpc/src/worker.ts +24 -0
- package/templates/basic/README.md +53 -0
- package/templates/basic/runtime/bun/Users.test.ts +31 -0
- package/templates/basic/runtime/bun/_package.json +21 -0
- package/templates/basic/runtime/bun/_tsconfig.json +10 -0
- package/templates/basic/runtime/bun/main.ts +43 -0
- package/templates/basic/runtime/node/Users.test.ts +32 -0
- package/templates/basic/runtime/node/_package.json +22 -0
- package/templates/basic/runtime/node/_tsconfig.json +24 -0
- package/templates/basic/runtime/node/main.ts +43 -0
- package/templates/basic/src/Users.ts +49 -0
- package/templates/basic/src/domain/User.ts +21 -0
- package/templates/fullstack/README.md +114 -0
- package/templates/fullstack/_gitignore +7 -0
- package/templates/fullstack/_package.json +6 -0
- package/templates/fullstack/_pnpm-workspace.yaml +2 -0
- package/templates/fullstack/_tsconfig.json +9 -0
- package/templates/fullstack/apps/api/config.ts +10 -0
- package/templates/fullstack/apps/api/http.ts +35 -0
- package/templates/fullstack/apps/api/runtime/bun/_package.json +26 -0
- package/templates/fullstack/apps/api/runtime/bun/_tsconfig.json +5 -0
- package/templates/fullstack/apps/api/runtime/node/_package.json +27 -0
- package/templates/fullstack/apps/api/runtime/node/_tsconfig.json +5 -0
- package/templates/fullstack/apps/web/runtime/bun/NotesApi.test.ts +65 -0
- package/templates/fullstack/apps/web/runtime/bun/_package.json +30 -0
- package/templates/fullstack/apps/web/runtime/bun/_tsconfig.json +9 -0
- package/templates/fullstack/apps/web/runtime/node/NotesApi.test.ts +65 -0
- package/templates/fullstack/apps/web/runtime/node/_package.json +31 -0
- package/templates/fullstack/apps/web/runtime/node/_tsconfig.json +9 -0
- package/templates/fullstack/apps/web/src/atoms/NotesApi.ts +40 -0
- package/templates/fullstack/apps/web/src/router.tsx +64 -0
- package/templates/fullstack/apps/web/src/routes/__root.tsx +38 -0
- package/templates/fullstack/apps/web/src/routes/index.tsx +97 -0
- package/templates/fullstack/apps/web/src/styles.css +40 -0
- package/templates/fullstack/apps/web/vite.config.ts +11 -0
- package/templates/fullstack/lint.package.json +12 -0
- package/templates/http-server/README.md +56 -0
- package/templates/http-server/runtime/bun/_package.json +22 -0
- package/templates/http-server/runtime/bun/_tsconfig.json +10 -0
- package/templates/http-server/runtime/bun/client.ts +40 -0
- package/templates/http-server/runtime/bun/http.test.ts +45 -0
- package/templates/http-server/runtime/bun/index.ts +25 -0
- package/templates/http-server/runtime/node/_package.json +23 -0
- package/templates/http-server/runtime/node/_tsconfig.json +24 -0
- package/templates/http-server/runtime/node/client.ts +40 -0
- package/templates/http-server/runtime/node/http.test.ts +48 -0
- 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
|
+
})))
|