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
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,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,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,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
|
+
}
|