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,21 @@
1
+ {
2
+ "name": "{{name}}",
3
+ "module": "src/main.ts",
4
+ "type": "module",
5
+ "private": true,
6
+ "engines": { "bun": ">=1.3.0" },
7
+ "scripts": {
8
+ "dev": "bun --hot src/main.ts",
9
+ "start": "bun src/main.ts",
10
+ "test": "bun test",
11
+ "typecheck": "tsc --noEmit"
12
+ },
13
+ "dependencies": {
14
+ "effect": "4.0.0-rc.112",
15
+ "@effect/platform-bun": "4.0.0-rc.112"
16
+ },
17
+ "devDependencies": {
18
+ "@types/bun": "^1.4.0",
19
+ "typescript": "7.0.2"
20
+ }
21
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "compilerOptions": {
3
+ "lib": ["ESNext"], "target": "ESNext", "module": "Preserve",
4
+ "moduleDetection": "force", "types": ["bun"],
5
+ "moduleResolution": "bundler", "allowImportingTsExtensions": true,
6
+ "verbatimModuleSyntax": true, "noEmit": true, "strict": true,
7
+ "skipLibCheck": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true
8
+ },
9
+ "include": ["src"]
10
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Program entrypoint.
3
+ *
4
+ * A service (`src/Users.ts`) supplied by an in-memory layer, a typed error
5
+ * (`UserNotFound`), and a program that handles it. No server — this runs,
6
+ * logs, and exits.
7
+ *
8
+ * {{runCmd}} dev # watch mode
9
+ * {{runCmd}} start # once
10
+ */
11
+ import { BunRuntime } from "@effect/platform-bun"
12
+ import { Effect, Layer } from "effect"
13
+ import { UserId } from "./domain/User.ts"
14
+ import * as Observability from "./observability.ts"
15
+ import { Users } from "./Users.ts"
16
+
17
+ const program = Effect.gen(function*() {
18
+ // Yielding the class asks for the service. Nothing here knows which layer
19
+ // will supply it.
20
+ const users = yield* Users
21
+
22
+ const created = yield* users.create({ name: "Ada", email: "ada@example.com" })
23
+ yield* Effect.logInfo("created", created)
24
+
25
+ const fetched = yield* users.getById(created.id)
26
+ yield* Effect.logInfo("getById", fetched)
27
+
28
+ // `list` is a value rather than a method, so there are no parentheses.
29
+ const all = yield* users.list
30
+ yield* Effect.logInfo("list", all)
31
+
32
+ // `UserNotFound` is in the program's error type, so this must be handled:
33
+ // delete the `catchTag` and it is a compile error, not a surprise at 3am.
34
+ const missing = yield* users.getById(UserId.make("nope")).pipe(
35
+ Effect.catchTag("UserNotFound", (error) => Effect.succeed(`no user ${error.id} — handled`))
36
+ )
37
+ yield* Effect.logInfo("getById?id=nope", missing)
38
+ }).pipe(
39
+ Effect.withSpan("main"),
40
+ Effect.provide(Layer.mergeAll(Users.layerMemory, Observability.layer("{{name}}")))
41
+ )
42
+
43
+ program.pipe(BunRuntime.runMain)
@@ -0,0 +1,32 @@
1
+ import { assert, describe, it } from "@effect/vitest"
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
+ )
13
+
14
+ describe("users", () => {
15
+ it.effect("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
+ assert.strictEqual(created.name, "Ada")
20
+
21
+ const listed = yield* users.list
22
+ assert.strictEqual(listed.length, 1)
23
+ assert.strictEqual(listed[0]?.id, created.id)
24
+ })))
25
+
26
+ it.effect("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
+ assert.strictEqual(error._tag, "UserNotFound")
31
+ })))
32
+ })
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "{{name}}",
3
+ "type": "module",
4
+ "private": true,
5
+ "engines": { "node": ">=22.6.0" },
6
+ "scripts": {
7
+ "dev": "node --experimental-strip-types --watch src/main.ts",
8
+ "start": "node --experimental-strip-types src/main.ts",
9
+ "test": "vitest run",
10
+ "typecheck": "tsc --noEmit"
11
+ },
12
+ "dependencies": {
13
+ "@effect/platform-node": "4.0.0-rc.112",
14
+ "effect": "4.0.0-rc.112"
15
+ },
16
+ "devDependencies": {
17
+ "@effect/vitest": "4.0.0-rc.112",
18
+ "@types/node": "^26.4.0",
19
+ "typescript": "7.0.2",
20
+ "vitest": "^4.1.11"
21
+ }
22
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ "lib": [
4
+ "ESNext"
5
+ ],
6
+ "target": "ESNext",
7
+ "module": "nodenext",
8
+ "moduleResolution": "nodenext",
9
+ "moduleDetection": "force",
10
+ "types": [
11
+ "node"
12
+ ],
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "noEmit": true,
16
+ "strict": true,
17
+ "skipLibCheck": true,
18
+ "noUncheckedIndexedAccess": true,
19
+ "noImplicitOverride": true
20
+ },
21
+ "include": [
22
+ "src"
23
+ ]
24
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Program entrypoint.
3
+ *
4
+ * A service (`src/Users.ts`) supplied by an in-memory layer, a typed error
5
+ * (`UserNotFound`), and a program that handles it. No server — this runs,
6
+ * logs, and exits.
7
+ *
8
+ * {{runCmd}} dev # watch mode
9
+ * {{runCmd}} start # once
10
+ */
11
+ import { NodeRuntime } from "@effect/platform-node"
12
+ import { Effect, Layer } from "effect"
13
+ import { UserId } from "./domain/User.ts"
14
+ import * as Observability from "./observability.ts"
15
+ import { Users } from "./Users.ts"
16
+
17
+ const program = Effect.gen(function*() {
18
+ // Yielding the class asks for the service. Nothing here knows which layer
19
+ // will supply it.
20
+ const users = yield* Users
21
+
22
+ const created = yield* users.create({ name: "Ada", email: "ada@example.com" })
23
+ yield* Effect.logInfo("created", created)
24
+
25
+ const fetched = yield* users.getById(created.id)
26
+ yield* Effect.logInfo("getById", fetched)
27
+
28
+ // `list` is a value rather than a method, so there are no parentheses.
29
+ const all = yield* users.list
30
+ yield* Effect.logInfo("list", all)
31
+
32
+ // `UserNotFound` is in the program's error type, so this must be handled:
33
+ // delete the `catchTag` and it is a compile error, not a surprise at 3am.
34
+ const missing = yield* users.getById(UserId.make("nope")).pipe(
35
+ Effect.catchTag("UserNotFound", (error) => Effect.succeed(`no user ${error.id} — handled`))
36
+ )
37
+ yield* Effect.logInfo("getById?id=nope", missing)
38
+ }).pipe(
39
+ Effect.withSpan("main"),
40
+ Effect.provide(Layer.mergeAll(Users.layerMemory, Observability.layer("{{name}}")))
41
+ )
42
+
43
+ program.pipe(NodeRuntime.runMain)
@@ -0,0 +1,49 @@
1
+ import { Context, Effect, Layer } from "effect"
2
+ import { User, type UserCreate, UserId, UserNotFound } from "./domain/User.ts"
3
+
4
+ /**
5
+ * A service: an interface, a tag, and a layer that supplies it.
6
+ *
7
+ * The interface is what callers see; `layerMemory` is one implementation. Swap
8
+ * it for a database-backed layer and nothing in `main.ts` changes.
9
+ */
10
+ export class Users extends Context.Service<Users, {
11
+ readonly list: Effect.Effect<ReadonlyArray<User>>
12
+ getById(id: UserId): Effect.Effect<User, UserNotFound>
13
+ create(input: typeof UserCreate.Type): Effect.Effect<User>
14
+ }>()("app/Users") {
15
+ static readonly layerMemory = Layer.sync(Users, () => {
16
+ const store = new Map<UserId, User>()
17
+ let next = 1
18
+
19
+ // `Effect.fn` names the span each call creates, so with `--otel` these show
20
+ // up as `Users.getById` / `Users.create` in the trace.
21
+ const getById = Effect.fn("Users.getById")(function*(id: UserId) {
22
+ yield* Effect.annotateCurrentSpan({ id })
23
+ const found = store.get(id)
24
+ if (found === undefined) {
25
+ // The failure is returned, not thrown, so it lands in the error channel
26
+ // and every caller has to deal with it.
27
+ return yield* new UserNotFound({ id })
28
+ }
29
+ return found
30
+ })
31
+
32
+ const create = Effect.fn("Users.create")(function*(input: typeof UserCreate.Type) {
33
+ // `UserId.make` is the branded constructor. Never reach for `as UserId`:
34
+ // the cast would compile even for a value the brand rejects.
35
+ const user = new User({ id: UserId.make(String(next++)), name: input.name, email: input.email })
36
+ store.set(user.id, user)
37
+ yield* Effect.annotateCurrentSpan({ id: user.id })
38
+ return user
39
+ })
40
+
41
+ return Users.of({
42
+ // `list` is a value, not a method, so it is named with `withSpan` rather
43
+ // than `Effect.fn`.
44
+ list: Effect.sync(() => [...store.values()]).pipe(Effect.withSpan("Users.list")),
45
+ getById,
46
+ create
47
+ })
48
+ })
49
+ }
@@ -0,0 +1,21 @@
1
+ import { Schema } from "effect"
2
+
3
+ // A brand makes `UserId` a distinct type from `string`, so a raw string cannot
4
+ // be passed where an id is expected. `UserId.make` is the only way in.
5
+ export const UserId = Schema.String.pipe(Schema.brand("UserId"))
6
+ export type UserId = typeof UserId.Type
7
+
8
+ export class User extends Schema.Class<User>("User")({
9
+ id: UserId,
10
+ name: Schema.String,
11
+ email: Schema.String
12
+ }) {}
13
+
14
+ export const UserCreate = Schema.Struct({
15
+ name: Schema.String,
16
+ email: Schema.String
17
+ })
18
+
19
+ export class UserNotFound extends Schema.TaggedError<UserNotFound>()("UserNotFound", {
20
+ id: UserId
21
+ }) {}
@@ -0,0 +1,114 @@
1
+ # {{name}}
2
+
3
+ One `HttpApi` definition, served by an Effect v4 backend and consumed by a
4
+ server-rendered React frontend whose data comes from Effect's own reactivity —
5
+ no separate data-fetching library.
6
+
7
+ ```
8
+ apps/api the schema-first HttpApi server, on :3000
9
+ apps/web TanStack Start + Effect atoms, on :3001
10
+ ```
11
+
12
+ ## Run it
13
+
14
+ Two terminals, because the two apps are separate processes:
15
+
16
+ ```bash
17
+ cd apps/api && {{runCmd}} dev # http://localhost:3000/docs
18
+ cd apps/web && {{runCmd}} dev # http://localhost:3001
19
+ ```
20
+
21
+ `apps/api` reads `PORT` (default 3000) and `WEB_ORIGIN` (default
22
+ `http://localhost:3001`), which is the origin its CORS is scoped to. `apps/web`
23
+ reads `VITE_API_URL` (default `http://localhost:3000`). **The two must agree** —
24
+ a mismatch fails as a browser CORS rejection rather than an obvious error.
25
+
26
+ That CORS allow-list carries `traceparent` and `b3` alongside `Content-Type`, and
27
+ it has to: `HttpTraceContext` attaches both to every request the generated client
28
+ makes, so a browser call joins the server's trace. Leave them out and the
29
+ preflight fails — the request never leaves the browser, whatever the handler does.
30
+
31
+ `VITE_API_URL` is inlined by Vite at build time into both the server and browser
32
+ bundles, so SSR cannot use an internal address distinct from the browser's, and
33
+ changing it needs a rebuild rather than a restart.
34
+
35
+ ## How the frontend gets its data
36
+
37
+ `apps/web/src/atoms/NotesApi.ts` builds an `AtomHttpApi` client from the *same*
38
+ `Api` the server implements, imported as `@{{name}}/api/api`. Renaming an
39
+ endpoint or changing a schema is a compile error in the UI as well as the server.
40
+
41
+ That client hands back atoms: `notesList` is a query atom holding an
42
+ `AsyncResult`, and `createNote` is a mutation. The React hooks come from
43
+ `@effect/atom-react` — note the scope, since `@effect-atom/atom-react` is an
44
+ older standalone package that targets effect v3 and cannot read
45
+ `effect/unstable/reactivity` atoms.
46
+
47
+ Rendering is server-side, with the data carried across:
48
+
49
+ 1. `getRouter()` creates an `AtomRegistry` — **per request**, since a
50
+ module-level one would leak one visitor's data into another's render.
51
+ 2. The route loader fills it before rendering, so the markup ships with data.
52
+ 3. `Hydration.dehydrate` serializes it into the HTML payload.
53
+ 4. `Hydration.hydrate` restores it on the client before first paint.
54
+
55
+ The loader runs the query through `Effect.exit`, not `Effect.ignore`.
56
+ `AtomHttpApi` *dies* on a transport error rather than failing, and `ignore` only
57
+ swallows failures — a defect escapes it and 500s the whole page whenever the API
58
+ is unreachable. `exit` captures both, so an API that is down renders the error
59
+ branch instead of taking the page down with it.
60
+
61
+ The dehydrated payload crosses as a JSON string and is decoded with a `Schema` on
62
+ the way back, rather than asserted onto `JSON.parse`. TanStack Start type-checks
63
+ `dehydrate`'s return and rejects `unknown`-typed properties since it cannot prove
64
+ them JSON-safe — and `Hydration.DehydratedAtomValue.value` is `unknown` by
65
+ design. A string is provably safe, and decoding checks what actually arrived.
66
+
67
+ So the first paint has the list already, with no loading flash and no refetch on
68
+ mount. `notesList` carries a `serializationKey` because that is the identity the
69
+ handoff uses; without one the atom is not serializable, dehydration skips it, and
70
+ the app silently degrades into a client-side refetch while looking perfectly
71
+ correct. The test in `apps/web/src/atoms/` is what guards that.
72
+
73
+ ## Adding an endpoint
74
+
75
+ 1. Declare it in `apps/api/src/api/Notes.ts`.
76
+ 2. Implement it in `apps/api/src/server/Notes/http.ts` — the handler list is
77
+ type-checked against the declaration, so a missing handler will not compile.
78
+
79
+ The frontend picks the change up through `@{{name}}/api/api` with no extra step.
80
+
81
+ ## Tests
82
+
83
+ ```bash
84
+ cd apps/api && {{runCmd}} test # the API, through real routing and decoding
85
+ cd apps/web && {{runCmd}} test # the SSR hydration round-trip
86
+ ```
87
+
88
+ `apps/web` runs `vite build` before `tsc` (a `pretypecheck` script), because
89
+ `src/routeTree.gen.ts` is generated by the TanStack Start plugin and gitignored —
90
+ on a fresh clone there is no file for `tsc` to read until something has built
91
+ once.
92
+
93
+ `apps/api` uses `HttpApiTest.groups`, which runs the real request encoding,
94
+ routing and response decoding against the handlers with no server and no port.
95
+
96
+ `apps/web` has two tests, both about the thing that fails silently: that a
97
+ dehydrated atom rehydrates without issuing a second request, and that the shipped
98
+ atom really is serializable.
99
+
100
+ ## A note on `reactivityKeys`
101
+
102
+ Mutations normally invalidate queries by reactivity key. `notesList` deliberately
103
+ does not use them: in effect `4.0.0-rc.112`, combining `reactivityKeys` with
104
+ `serializationKey` breaks the hydration preload, so the first client read
105
+ refetches anyway — reintroducing exactly what hydration exists to avoid. The list
106
+ is refreshed explicitly after a successful mutation instead.
107
+
108
+ The mutation is driven by `useAtomSet(createNote, { mode: "promiseExit" })`, which
109
+ makes it awaitable, so the submit handler acts on the `Exit` directly: on success
110
+ it refreshes the list and clears the form, and on failure it leaves what the user
111
+ typed in place to retry.
112
+
113
+ The comment in `apps/web/src/atoms/NotesApi.ts` says how to retest the
114
+ `reactivityKeys` combination after an effect upgrade.
@@ -0,0 +1,7 @@
1
+ node_modules
2
+ dist
3
+ .output
4
+ .vite
5
+ *.tsbuildinfo
6
+ .DS_Store
7
+ apps/web/src/routeTree.gen.ts
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "{{name}}",
3
+ "private": true,
4
+ "type": "module",
5
+ "workspaces": ["apps/*"]
6
+ }
@@ -0,0 +1,2 @@
1
+ packages:
2
+ - "apps/*"
@@ -0,0 +1,9 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext", "module": "Preserve",
4
+ "moduleDetection": "force",
5
+ "moduleResolution": "bundler", "allowImportingTsExtensions": true,
6
+ "verbatimModuleSyntax": true, "noEmit": true, "strict": true,
7
+ "skipLibCheck": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true
8
+ }
9
+ }
@@ -0,0 +1,10 @@
1
+ import { Config } from "effect"
2
+
3
+ export const port = Config.port("PORT").pipe(Config.withDefault(3000))
4
+
5
+ export const baseUrl = port.pipe(Config.map((port) => `http://localhost:${port}`))
6
+
7
+ /** The origin the web app is served from; used to scope CORS. */
8
+ export const webOrigin = Config.string("WEB_ORIGIN").pipe(
9
+ Config.withDefault("http://localhost:3001")
10
+ )
@@ -0,0 +1,35 @@
1
+ import { Effect, Layer } from "effect"
2
+ import { HttpRouter } from "effect/unstable/http"
3
+ import { HttpApiBuilder, HttpApiScalar } from "effect/unstable/httpapi"
4
+ import { Api } from "../api/Api.ts"
5
+ import { webOrigin } from "../config.ts"
6
+ import { NotesApiHandlers } from "./Notes/http.ts"
7
+
8
+ const SystemApiHandlers = HttpApiBuilder.group(
9
+ Api,
10
+ "system",
11
+ (handlers) => handlers.handleAll({ health: () => Effect.void })
12
+ )
13
+
14
+ const ApiRoutes = HttpApiBuilder.layer(Api, {
15
+ openapiPath: "/openapi.json"
16
+ }).pipe(Layer.provide([NotesApiHandlers, SystemApiHandlers]))
17
+
18
+ const DocsRoute = HttpApiScalar.layer(Api, { path: "/docs" })
19
+
20
+ // The browser runs on a different origin to the API, so it needs CORS. Scoped to
21
+ // the web app's origin rather than "*", so this stays correct if the API is ever
22
+ // exposed beyond localhost.
23
+ //
24
+ // `traceparent` and `b3` are not optional: HttpTraceContext attaches both to
25
+ // every client request so a browser call joins the server's trace. Omitting them
26
+ // from the allow-list fails the preflight and the request never leaves the
27
+ // browser, whatever the handler does.
28
+ const CorsLayer = Layer.unwrap(Effect.map(webOrigin, (origin) =>
29
+ HttpRouter.cors({
30
+ allowedOrigins: [origin],
31
+ allowedMethods: ["GET", "POST", "OPTIONS"],
32
+ allowedHeaders: ["Content-Type", "traceparent", "b3"]
33
+ })))
34
+
35
+ export const AllRoutes = Layer.mergeAll(ApiRoutes, DocsRoute, CorsLayer)
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@{{name}}/api",
3
+ "module": "src/index.ts",
4
+ "type": "module",
5
+ "private": true,
6
+ "//": "`exports` is what lets the web app import the API definition — and only the definition. The server and its handlers stay unreachable from the browser bundle.",
7
+ "exports": {
8
+ "./api": "./src/api/Api.ts"
9
+ },
10
+ "engines": { "bun": ">=1.3.0" },
11
+ "scripts": {
12
+ "dev": "bun --hot src/index.ts",
13
+ "start": "bun src/index.ts",
14
+ "test": "bun test",
15
+ "typecheck": "tsc --noEmit",
16
+ "client": "bun src/client.ts"
17
+ },
18
+ "dependencies": {
19
+ "effect": "4.0.0-rc.112",
20
+ "@effect/platform-bun": "4.0.0-rc.112"
21
+ },
22
+ "devDependencies": {
23
+ "@types/bun": "^1.4.0",
24
+ "typescript": "7.0.2"
25
+ }
26
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": { "lib": ["ESNext"], "types": ["bun"] },
4
+ "include": ["src"]
5
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@{{name}}/api",
3
+ "type": "module",
4
+ "private": true,
5
+ "//": "`exports` is what lets the web app import the API definition — and only the definition. The server and its handlers stay unreachable from the browser bundle.",
6
+ "exports": {
7
+ "./api": "./src/api/Api.ts"
8
+ },
9
+ "engines": { "node": ">=22.6.0" },
10
+ "scripts": {
11
+ "dev": "node --experimental-strip-types --watch src/index.ts",
12
+ "start": "node --experimental-strip-types src/index.ts",
13
+ "test": "vitest run",
14
+ "typecheck": "tsc --noEmit",
15
+ "client": "node --experimental-strip-types src/client.ts"
16
+ },
17
+ "dependencies": {
18
+ "@effect/platform-node": "4.0.0-rc.112",
19
+ "effect": "4.0.0-rc.112"
20
+ },
21
+ "devDependencies": {
22
+ "@effect/vitest": "4.0.0-rc.112",
23
+ "@types/node": "^26.4.0",
24
+ "typescript": "7.0.2",
25
+ "vitest": "^4.1.11"
26
+ }
27
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": { "lib": ["ESNext"], "types": ["node"] },
4
+ "include": ["src"]
5
+ }
@@ -0,0 +1,65 @@
1
+ import { Api } from "@{{name}}/api/api"
2
+ import { Effect, Layer } from "effect"
3
+ import { HttpClient, HttpClientResponse } from "effect/unstable/http"
4
+ import { AsyncResult, Atom, AtomHttpApi, AtomRegistry, Hydration } from "effect/unstable/reactivity"
5
+ import { expect, test } from "bun:test"
6
+ import { notesList } from "./NotesApi.ts"
7
+
8
+ const stubNotes = [{ id: "1", title: "first", body: "hello" }]
9
+
10
+ let requests = 0
11
+
12
+ const StubHttpClient = Layer.succeed(
13
+ HttpClient.HttpClient,
14
+ HttpClient.make((request) => {
15
+ requests++
16
+ return Effect.succeed(
17
+ HttpClientResponse.fromWeb(
18
+ request,
19
+ new Response(JSON.stringify(stubNotes), {
20
+ status: 200,
21
+ headers: { "content-type": "application/json" }
22
+ })
23
+ )
24
+ )
25
+ })
26
+ )
27
+
28
+ class StubApi extends AtomHttpApi.Service<StubApi>()("test/StubApi", {
29
+ api: Api,
30
+ httpClient: StubHttpClient,
31
+ baseUrl: "http://localhost"
32
+ }) {}
33
+
34
+ // Same group, endpoint and serializationKey as the shipped atom, so it produces
35
+ // the same registry key: `AtomHttpApi:notes:list:notes/list`.
36
+ const listQuery = Atom.keepAlive(
37
+ StubApi.query("notes", "list", { serializationKey: "notes/list" })
38
+ )
39
+
40
+ test("dehydrates a resolved list and hydrates it without refetching", async () => {
41
+ requests = 0
42
+
43
+ const server = AtomRegistry.make()
44
+ const notes = await Effect.runPromise(AtomRegistry.getResult(listQuery)(server))
45
+ expect(notes.length).toBe(1)
46
+ expect(requests).toBe(1)
47
+
48
+ const dehydrated = Hydration.toValues(Hydration.dehydrate(server))
49
+ expect(dehydrated.map((entry) => entry.key)).toContain("AtomHttpApi:notes:list:notes/list")
50
+
51
+ const client = AtomRegistry.make()
52
+ Hydration.hydrate(client, dehydrated)
53
+
54
+ const hydrated = client.get(listQuery)
55
+ expect(AsyncResult.isSuccess(hydrated)).toBe(true)
56
+ // The whole point: reading the atom on the "client" issued no second request.
57
+ expect(requests).toBe(1)
58
+ })
59
+
60
+ // Guards the shipped atom, not just the stub above: without a serializationKey
61
+ // it would not be serializable, dehydrate would skip it, and SSR would silently
62
+ // degrade into a client refetch.
63
+ test("the shipped notesList atom is serializable", () => {
64
+ expect(Atom.isSerializable(notesList)).toBe(true)
65
+ })
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@{{name}}/web",
3
+ "type": "module",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "vite dev",
7
+ "build": "vite build",
8
+ "//": "`routeTree.gen.ts` is generated by the TanStack Start plugin and gitignored, so a fresh clone has no file for tsc to read. Building first generates it.",
9
+ "pretypecheck": "vite build",
10
+ "typecheck": "tsc --noEmit",
11
+ "test": "bun test"
12
+ },
13
+ "dependencies": {
14
+ "@{{name}}/api": "{{workspaceVersion}}",
15
+ "@effect/atom-react": "4.0.0-rc.112",
16
+ "@tanstack/react-router": "^1.170.32",
17
+ "@tanstack/react-start": "^1.168.49",
18
+ "effect": "4.0.0-rc.112",
19
+ "react": "^19.2.8",
20
+ "react-dom": "^19.2.8"
21
+ },
22
+ "devDependencies": {
23
+ "@types/bun": "^1.4.0",
24
+ "@types/react": "^19.2.0",
25
+ "@types/react-dom": "^19.2.0",
26
+ "@vitejs/plugin-react": "^6.1.1",
27
+ "typescript": "7.0.2",
28
+ "vite": "^8.2.2"
29
+ }
30
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
5
+ "jsx": "react-jsx",
6
+ "types": ["vite/client", "bun"]
7
+ },
8
+ "include": ["src"]
9
+ }