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
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "create-effect-project",
3
+ "version": "0.1.0",
4
+ "description": "Scaffold an Effect v4 project — an HttpApi server, a plain program, or a Cloudflare Worker over HTTP or RPC — on Node or Bun",
5
+ "keywords": ["effect", "effect-ts", "scaffold", "template", "cli", "starter"],
6
+ "license": "MIT",
7
+ "repository": { "type": "git", "url": "git+https://github.com/deracs/create-effect-project.git" },
8
+ "homepage": "https://github.com/deracs/create-effect-project#readme",
9
+ "bugs": "https://github.com/deracs/create-effect-project/issues",
10
+ "type": "module",
11
+ "bin": { "create-effect-project": "dist/bin.js" },
12
+ "files": ["dist", "templates"],
13
+ "//": "Consumers get the bundle and the templates. `engines` is the floor for RUNNING the CLI; the projects it generates carry their own, higher floor in their own manifests.",
14
+ "engines": { "node": ">=20" },
15
+ "scripts": {
16
+ "build": "esbuild src/bin.ts --bundle --platform=node --format=esm --target=node20 --outfile=dist/bin.js --log-level=warning --banner:js=\"import{createRequire as __cr}from'node:module';const require=__cr(import.meta.url);\"",
17
+ "test": "vitest run",
18
+ "test:watch": "vitest",
19
+ "test:e2e": "CEA_E2E=1 vitest run test/e2e.test.ts --testTimeout=600000",
20
+ "test:pack": "node scripts/smoke-pack.mjs",
21
+ "typecheck": "tsc --noEmit -p tsconfig.json",
22
+ "prepublishOnly": "npm run typecheck && npm run test && npm run build && npm run test:pack"
23
+ },
24
+ "//devDependencies": "effect and @effect/platform-node are devDependencies on purpose: they are bundled into dist/bin.js, so a consumer installs zero runtime dependencies.",
25
+ "devDependencies": {
26
+ "@effect/platform-node": "4.0.0-rc.112",
27
+ "@effect/vitest": "4.0.0-rc.112",
28
+ "@types/node": "^26.4.0",
29
+ "effect": "4.0.0-rc.112",
30
+ "esbuild": "^0.25.0",
31
+ "typescript": "7.0.2",
32
+ "vitest": "^4.1.11"
33
+ }
34
+ }
@@ -0,0 +1,4 @@
1
+ node_modules
2
+ dist
3
+ *.tsbuildinfo
4
+ .DS_Store
@@ -0,0 +1,13 @@
1
+ import { Config } from "effect"
2
+
3
+ export const port = Config.port("PORT").pipe(Config.withDefault(3000))
4
+
5
+ /**
6
+ * Where the client points.
7
+ *
8
+ * `API_URL` wins, so the same client can talk to a deployed instance — set it
9
+ * to the URL `deploy` printed. Otherwise it is the local server on `PORT`.
10
+ */
11
+ export const baseUrl = Config.string("API_URL").pipe(
12
+ Config.orElse(() => port.pipe(Config.map((port) => `http://localhost:${port}`)))
13
+ )
@@ -0,0 +1,5 @@
1
+ {
2
+ "$schema": "./node_modules/oxlint/configuration_schema.json",
3
+ "ignorePatterns": ["node_modules/**", "dist/**"],
4
+ "categories": { "correctness": "error" }
5
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "js/ts.experimental.useTsgo": true,
3
+ "js/ts.tsdk.path": "./node_modules/typescript/bin",
4
+ "js/ts.tsdk.promptToUseWorkspaceVersion": true,
5
+ "js/ts.tsdk.additionalLocations": ["./node_modules/typescript/bin"]
6
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "scripts": {
3
+ "prepare": "effect-tsgo patch --typescript",
4
+ "lint": "oxlint src"
5
+ },
6
+ "devDependencies": {
7
+ "@effect/language-service": "0.87.2",
8
+ "@effect/tsgo": "0.37.0",
9
+ "oxlint": "1.80.0"
10
+ }
11
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "plugins": [
4
+ {
5
+ "name": "@effect/language-service",
6
+ "diagnosticSeverity": {
7
+ "globalDate": "warning",
8
+ "globalFetch": "warning",
9
+ "globalRandom": "warning",
10
+ "globalTimers": "warning",
11
+ "processEnv": "warning"
12
+ }
13
+ }
14
+ ]
15
+ }
16
+ }
@@ -0,0 +1,9 @@
1
+ import { Layer } from "effect"
2
+
3
+ /**
4
+ * Telemetry is not wired up in this project.
5
+ *
6
+ * To add OTLP export of logs, metrics and traces, scaffold again with `--otel`,
7
+ * or replace this file with `Otlp.layerJson` from `effect/unstable/observability`.
8
+ */
9
+ export const layer = (_serviceName: string) => Layer.empty
@@ -0,0 +1,23 @@
1
+ import { Config, Effect, Layer } from "effect"
2
+ import { FetchHttpClient } from "effect/unstable/http"
3
+ import { Otlp } from "effect/unstable/observability"
4
+
5
+ // Setting this turns telemetry on; leaving it unset makes the layer a no-op, so
6
+ // nothing tries to export during ordinary local runs.
7
+ //
8
+ // OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 {{runCmd}} dev
9
+ const endpoint = Config.string("OTEL_EXPORTER_OTLP_ENDPOINT").pipe(
10
+ Config.map((url) => url.replace(/\/$/, "")),
11
+ Config.option
12
+ )
13
+
14
+ /** Exports logs, metrics and traces over OTLP/HTTP beneath the configured endpoint. */
15
+ export const layer = (serviceName: string) =>
16
+ Layer.unwrap(Effect.map(
17
+ endpoint,
18
+ (endpoint) =>
19
+ endpoint._tag === "None" ? Layer.empty : Otlp.layerJson({
20
+ baseUrl: endpoint.value,
21
+ resource: { serviceName, serviceVersion: "0.0.1" }
22
+ })
23
+ )).pipe(Layer.provide(FetchHttpClient.layer))
@@ -0,0 +1,25 @@
1
+ {
2
+ "rules": {
3
+ "typescript/no-explicit-any": "error",
4
+ "typescript/ban-ts-comment": "error",
5
+ "typescript/no-non-null-assertion": "error",
6
+ "typescript/no-non-null-asserted-nullish-coalescing": "error",
7
+ "typescript/no-empty-interface": "error",
8
+ "typescript/no-empty-object-type": "error",
9
+ "no-console": "error",
10
+ "no-empty": "error",
11
+ "no-empty-function": "error",
12
+ "no-useless-return": "error",
13
+ "no-useless-computed-key": "error",
14
+ "no-else-return": "error",
15
+ "no-lonely-if": "error",
16
+ "no-duplicate-imports": ["error", { "allowSeparateTypeImports": true }],
17
+ "require-await": "error",
18
+ "unicorn/no-useless-promise-resolve-reject": "error",
19
+ "unicorn/no-useless-switch-case": "error",
20
+ "unicorn/no-useless-error-capture-stack-trace": "error",
21
+ "unicorn/no-useless-collection-argument": "error",
22
+ "unicorn/no-unnecessary-array-flat-depth": "error",
23
+ "unicorn/no-await-expression-member": "error"
24
+ }
25
+ }
@@ -0,0 +1,9 @@
1
+ import { HttpApi, OpenApi } from "effect/unstable/httpapi"
2
+ import { NotesApiGroup } from "./Notes.ts"
3
+ import { SystemApi } from "./System.ts"
4
+
5
+ export class Api extends HttpApi.make("notes-api")
6
+ .add(NotesApiGroup)
7
+ .add(SystemApi)
8
+ .annotateMerge(OpenApi.annotations({ title: "Notes API" }))
9
+ {}
@@ -0,0 +1,25 @@
1
+ import { Schema } from "effect"
2
+ import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
3
+ import { Note, NoteCreate, NoteId, NoteNotFound } from "../domain/Note.ts"
4
+
5
+ export class NotesApiGroup extends HttpApiGroup.make("notes")
6
+ .add(
7
+ HttpApiEndpoint.get("list", "/", {
8
+ success: Schema.Array(Note)
9
+ }),
10
+ HttpApiEndpoint.get("getById", "/:id", {
11
+ params: { id: NoteId },
12
+ success: Note,
13
+ error: NoteNotFound.pipe(HttpApiSchema.status(404))
14
+ }),
15
+ HttpApiEndpoint.post("create", "/", {
16
+ payload: NoteCreate,
17
+ success: Note
18
+ })
19
+ )
20
+ .prefix("/notes")
21
+ .annotateMerge(OpenApi.annotations({
22
+ title: "Notes",
23
+ description: "Note management endpoints"
24
+ }))
25
+ {}
@@ -0,0 +1,7 @@
1
+ import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
2
+
3
+ export class SystemApi extends HttpApiGroup.make("system", { topLevel: true }).add(
4
+ HttpApiEndpoint.get("health", "/health", {
5
+ success: HttpApiSchema.NoContent
6
+ })
7
+ ) {}
@@ -0,0 +1,24 @@
1
+ import { Context, Effect, flow, Layer, Schedule } from "effect"
2
+ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
3
+ import { HttpApiClient } from "effect/unstable/httpapi"
4
+ import { Api } from "../api/Api.ts"
5
+ import { baseUrl } from "../config.ts"
6
+
7
+ // The client's shape is derived from `Api`, so renames and schema changes are
8
+ // checked end-to-end at compile time.
9
+ export class ApiClient extends Context.Service<ApiClient, HttpApiClient.ForApi<typeof Api>>()("app/ApiClient") {
10
+ static readonly layer = Layer.effect(
11
+ ApiClient,
12
+ Effect.gen(function*() {
13
+ const url = yield* baseUrl
14
+
15
+ return yield* HttpApiClient.make(Api, {
16
+ transformClient: (client) =>
17
+ client.pipe(
18
+ HttpClient.mapRequest(flow(HttpClientRequest.prependUrl(url))),
19
+ HttpClient.retryTransient({ schedule: Schedule.exponential(100), times: 3 })
20
+ )
21
+ })
22
+ })
23
+ ).pipe(Layer.provide(FetchHttpClient.layer))
24
+ }
@@ -0,0 +1,22 @@
1
+ import { Effect, Layer } from "effect"
2
+ import { HttpApiBuilder } from "effect/unstable/httpapi"
3
+ import { Api } from "../../api/Api.ts"
4
+ import { Notes } from "../Notes.ts"
5
+
6
+ export const NotesApiHandlersNoDeps = HttpApiBuilder.group(
7
+ Api,
8
+ "notes",
9
+ Effect.fn(function*(handlers) {
10
+ const notes = yield* Notes
11
+
12
+ return handlers.handleAll({
13
+ list: () => notes.list,
14
+ getById: ({ params }) => notes.getById(params.id),
15
+ create: ({ payload }) => notes.create(payload)
16
+ })
17
+ })
18
+ )
19
+
20
+ export const NotesApiHandlers = NotesApiHandlersNoDeps.pipe(
21
+ Layer.provide(Notes.layerMemory)
22
+ )
@@ -0,0 +1,18 @@
1
+ import { Effect, Layer } from "effect"
2
+ import { HttpApiBuilder, HttpApiScalar } from "effect/unstable/httpapi"
3
+ import { Api } from "../api/Api.ts"
4
+ import { NotesApiHandlers } from "./Notes/http.ts"
5
+
6
+ const SystemApiHandlers = HttpApiBuilder.group(
7
+ Api,
8
+ "system",
9
+ (handlers) => handlers.handleAll({ health: () => Effect.void })
10
+ )
11
+
12
+ const ApiRoutes = HttpApiBuilder.layer(Api, {
13
+ openapiPath: "/openapi.json"
14
+ }).pipe(Layer.provide([NotesApiHandlers, SystemApiHandlers]))
15
+
16
+ const DocsRoute = HttpApiScalar.layer(Api, { path: "/docs" })
17
+
18
+ export const AllRoutes = Layer.mergeAll(ApiRoutes, DocsRoute)
@@ -0,0 +1,42 @@
1
+ import { Context, Effect, Layer } from "effect"
2
+ import { Note, type NoteCreate, NoteId, NoteNotFound } from "../domain/Note.ts"
3
+
4
+ export class Notes extends Context.Service<Notes, {
5
+ readonly list: Effect.Effect<ReadonlyArray<Note>>
6
+ getById(id: NoteId): Effect.Effect<Note, NoteNotFound>
7
+ create(input: typeof NoteCreate.Type): Effect.Effect<Note>
8
+ }>()("app/Notes") {
9
+ static readonly layerMemory = Layer.sync(Notes, () => {
10
+ const store = new Map<NoteId, Note>()
11
+ let next = 1
12
+
13
+ // `Effect.fn` names the span each call creates, so with `--otel` these show
14
+ // up as `Notes.list` / `Notes.getById` / `Notes.create` inside the request
15
+ // span the HTTP server opens.
16
+ const getById = Effect.fn("Notes.getById")(function*(id: NoteId) {
17
+ yield* Effect.annotateCurrentSpan({ id })
18
+ const found = store.get(id)
19
+ if (found === undefined) {
20
+ return yield* new NoteNotFound({ id })
21
+ }
22
+ return found
23
+ })
24
+
25
+ const create = Effect.fn("Notes.create")(function*(input: typeof NoteCreate.Type) {
26
+ // `NoteId.make` is the branded constructor. Never reach for `as NoteId`:
27
+ // the cast would compile even for a value the brand rejects.
28
+ const note = new Note({ id: NoteId.make(String(next++)), title: input.title, body: input.body })
29
+ store.set(note.id, note)
30
+ yield* Effect.annotateCurrentSpan({ id: note.id })
31
+ return note
32
+ })
33
+
34
+ return Notes.of({
35
+ // `list` is a value, not a method, so it is named with `withSpan` rather
36
+ // than `Effect.fn`.
37
+ list: Effect.sync(() => [...store.values()]).pipe(Effect.withSpan("Notes.list")),
38
+ getById,
39
+ create
40
+ })
41
+ })
42
+ }
@@ -0,0 +1,19 @@
1
+ import { Schema } from "effect"
2
+
3
+ export const NoteId = Schema.String.pipe(Schema.brand("NoteId"))
4
+ export type NoteId = typeof NoteId.Type
5
+
6
+ export class Note extends Schema.Class<Note>("Note")({
7
+ id: NoteId,
8
+ title: Schema.String,
9
+ body: Schema.String
10
+ }) {}
11
+
12
+ export const NoteCreate = Schema.Struct({
13
+ title: Schema.String,
14
+ body: Schema.String
15
+ })
16
+
17
+ export class NoteNotFound extends Schema.TaggedError<NoteNotFound>()("NoteNotFound", {
18
+ id: NoteId
19
+ }) {}
@@ -0,0 +1,79 @@
1
+ # {{name}}
2
+
3
+ A schema-first Effect v4 `HttpApi` 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
+ | Route | What |
17
+ | --- | --- |
18
+ | `GET /health` | Liveness probe (204) |
19
+ | `GET /notes` | List notes |
20
+ | `POST /notes` | Create a note |
21
+ | `GET /notes/:id` | Fetch one (404 `NoteNotFound` if absent) |
22
+ | `GET /openapi.json` | Generated OpenAPI document |
23
+ | `GET /docs` | Scalar API reference |
24
+
25
+ ## The point
26
+
27
+ `src/api`, `src/server` and `src/client` are **identical** to the local-server template. The
28
+ only difference is the entrypoint: instead of binding `AllRoutes` to a port, `src/worker.ts`
29
+ converts it to a fetch handler.
30
+
31
+ ```ts
32
+ // a local server
33
+ HttpRouter.serve(AllRoutes).pipe(Layer.provide(NodeHttpServer.layerConfig(createServer, { port })))
34
+
35
+ // a Cloudflare Worker
36
+ HttpRouter.toHttpEffect(AllRoutes).pipe(Effect.provide(HttpServer.layerServices))
37
+ ```
38
+
39
+ One `HttpApi` definition, either target. `HttpServer.layerServices` supplies the file-serving
40
+ services the OpenAPI and Scalar routes ask for, backed by a no-op filesystem — a Worker has no
41
+ disk, and those routes serve generated content.
42
+
43
+ ## Infrastructure
44
+
45
+ `alchemy.run.ts` is the stack: ordinary TypeScript returning an Effect. Adding a bucket is a
46
+ value, not a config file:
47
+
48
+ ```ts
49
+ const Uploads = Cloudflare.R2.Bucket("Uploads")
50
+ // then, inside the worker's init:
51
+ const uploads = yield* Cloudflare.R2.ReadWriteBucket(Uploads)
52
+ ```
53
+
54
+ ## The typed client
55
+
56
+ `src/client/ApiClient.ts` derives its shape from `src/api/Api.ts`, so renaming an endpoint or
57
+ changing a schema is a compile error on both sides. It reads `API_URL`, so the same client works
58
+ against a deployed Worker:
59
+
60
+ ```bash
61
+ API_URL=https://your-worker.workers.dev {{runCmd}} client
62
+ ```
63
+
64
+ ## Tests
65
+
66
+ ```bash
67
+ {{runCmd}} test
68
+ ```
69
+
70
+ `HttpApiTest.groups` runs the real request encoding, routing and response decoding against the
71
+ handlers — no Worker, no port, no Cloudflare account. These are the same tests the local-server
72
+ template runs, because they test the same handlers.
73
+
74
+ ## Telemetry
75
+
76
+ Not wired up. OTLP export from inside a Worker isolate is not the right shape for Cloudflare —
77
+ use [Workers Observability](https://developers.cloudflare.com/workers/observability/) or a
78
+ [tail worker](https://developers.cloudflare.com/workers/observability/logs/tail-workers/), and
79
+ `{{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,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 { BunRuntime } from "@effect/platform-bun"
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(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,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
+ }