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