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,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 "vitest"
|
|
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,31 @@
|
|
|
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": "vitest run"
|
|
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/node": "^26.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
|
+
"vitest": "^4.1.11"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The web app's data layer: query and mutation atoms derived from the same
|
|
3
|
+
* `HttpApi` definition the server implements, so a schema change is a compile
|
|
4
|
+
* error here too.
|
|
5
|
+
*
|
|
6
|
+
* This module reads `import.meta.env`, so it belongs to the web app and must
|
|
7
|
+
* not move into a package the server imports.
|
|
8
|
+
*/
|
|
9
|
+
import { Api } from "@{{name}}/api/api"
|
|
10
|
+
import { FetchHttpClient } from "effect/unstable/http"
|
|
11
|
+
import { Atom, AtomHttpApi } from "effect/unstable/reactivity"
|
|
12
|
+
|
|
13
|
+
const baseUrl = import.meta.env.VITE_API_URL ?? "http://localhost:3000"
|
|
14
|
+
|
|
15
|
+
export class NotesApi extends AtomHttpApi.Service<NotesApi>()("web/NotesApi", {
|
|
16
|
+
api: Api,
|
|
17
|
+
httpClient: FetchHttpClient.layer,
|
|
18
|
+
baseUrl
|
|
19
|
+
}) {}
|
|
20
|
+
|
|
21
|
+
export const notesList = Atom.keepAlive(
|
|
22
|
+
NotesApi.query("notes", "list", {
|
|
23
|
+
// The identity for the SSR -> client handoff. Without it the atom is not
|
|
24
|
+
// serializable, `Hydration.dehydrate` skips it, and the client refetches on
|
|
25
|
+
// mount — an app that looks right while doing the wrong thing. The
|
|
26
|
+
// round-trip test in this directory is what guards it.
|
|
27
|
+
serializationKey: "notes/list"
|
|
28
|
+
})
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
// `reactivityKeys` is deliberately absent. Combining it with `serializationKey`
|
|
32
|
+
// breaks hydration in effect 4.0.0-rc.112: the preload seeds a node keyed by
|
|
33
|
+
// object identity rather than the serialization string, so the first client
|
|
34
|
+
// read refetches anyway. To retest after upgrading effect, add
|
|
35
|
+
// `reactivityKeys: ["notes"]` to the query above and to the stub in
|
|
36
|
+
// NotesApi.test.ts — if `expect(requests).toBe(1)` still fails, it is not fixed.
|
|
37
|
+
//
|
|
38
|
+
// Until then the list is refreshed explicitly after a successful mutation; see
|
|
39
|
+
// routes/index.tsx.
|
|
40
|
+
export const createNote = NotesApi.mutation("notes", "create")
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { RegistryContext } from "@effect/atom-react"
|
|
2
|
+
import { createRouter } from "@tanstack/react-router"
|
|
3
|
+
import { Schema } from "effect"
|
|
4
|
+
import { AtomRegistry, Hydration } from "effect/unstable/reactivity"
|
|
5
|
+
import { routeTree } from "./routeTree.gen"
|
|
6
|
+
|
|
7
|
+
export interface RouterContext {
|
|
8
|
+
readonly registry: AtomRegistry.AtomRegistry
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// The dehydrated envelope, as a schema rather than a cast. `JSON.parse` returns
|
|
12
|
+
// `any`, and asserting a type onto it would only silence the compiler — this
|
|
13
|
+
// decodes, so the shape that actually crossed the SSR boundary is checked.
|
|
14
|
+
// `value` stays `Unknown` because it varies per atom; `Hydration.hydrate` hands
|
|
15
|
+
// it to that atom's own decoder.
|
|
16
|
+
const DehydratedAtoms = Schema.Array(Schema.Struct({
|
|
17
|
+
"~effect/reactivity/DehydratedAtom": Schema.Literal(true),
|
|
18
|
+
key: Schema.String,
|
|
19
|
+
value: Schema.Unknown,
|
|
20
|
+
dehydratedAt: Schema.Finite
|
|
21
|
+
}))
|
|
22
|
+
|
|
23
|
+
const decodeAtoms = Schema.decodeUnknownSync(DehydratedAtoms)
|
|
24
|
+
|
|
25
|
+
export function getRouter() {
|
|
26
|
+
// One registry per call. On the server `getRouter` runs per request, so this
|
|
27
|
+
// is the isolation boundary that keeps one visitor's data out of another's
|
|
28
|
+
// render; on the client it runs once.
|
|
29
|
+
const registry = AtomRegistry.make()
|
|
30
|
+
|
|
31
|
+
return createRouter({
|
|
32
|
+
routeTree,
|
|
33
|
+
scrollRestoration: true,
|
|
34
|
+
context: { registry } satisfies RouterContext,
|
|
35
|
+
// Runs on the server after rendering: encodes every serializable atom into
|
|
36
|
+
// the payload TanStack Start injects into the HTML.
|
|
37
|
+
//
|
|
38
|
+
// The atoms are serialized to a string rather than handed over as objects.
|
|
39
|
+
// Start validates this return type at compile time and rejects any property
|
|
40
|
+
// typed `unknown`, since it cannot prove it JSON-safe — and
|
|
41
|
+
// `Hydration.DehydratedAtomValue.value` is `unknown` by design, because its
|
|
42
|
+
// shape varies per atom. Its contents come from `Schema.encodeSync`, so they
|
|
43
|
+
// are genuinely JSON-safe; a string says so in a way the type system can
|
|
44
|
+
// check, which beats widening the return to `any` and asserting it.
|
|
45
|
+
dehydrate: () => ({ atoms: JSON.stringify(Hydration.toValues(Hydration.dehydrate(registry))) }),
|
|
46
|
+
// Runs on the client before the first render, so the atoms are already
|
|
47
|
+
// Success by the time any component reads them.
|
|
48
|
+
hydrate: (dehydrated: { readonly atoms: string }) => {
|
|
49
|
+
Hydration.hydrate(registry, decodeAtoms(JSON.parse(dehydrated.atoms)))
|
|
50
|
+
},
|
|
51
|
+
// `@effect/atom-react`'s own `RegistryProvider` builds a registry from
|
|
52
|
+
// options, but ours has to be the per-request one created above — so the
|
|
53
|
+
// context is provided directly.
|
|
54
|
+
Wrap: ({ children }: { readonly children: React.ReactNode }) => (
|
|
55
|
+
<RegistryContext.Provider value={registry}>{children}</RegistryContext.Provider>
|
|
56
|
+
)
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
declare module "@tanstack/react-router" {
|
|
61
|
+
interface Register {
|
|
62
|
+
router: ReturnType<typeof getRouter>
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createRootRouteWithContext, HeadContent, Outlet, Scripts } from "@tanstack/react-router"
|
|
2
|
+
import type { ReactNode } from "react"
|
|
3
|
+
import type { RouterContext } from "../router.tsx"
|
|
4
|
+
import appCss from "../styles.css?url"
|
|
5
|
+
|
|
6
|
+
export const Route = createRootRouteWithContext<RouterContext>()({
|
|
7
|
+
head: () => ({
|
|
8
|
+
meta: [
|
|
9
|
+
{ charSet: "utf-8" },
|
|
10
|
+
{ name: "viewport", content: "width=device-width, initial-scale=1" },
|
|
11
|
+
{ title: "Notes" }
|
|
12
|
+
],
|
|
13
|
+
links: [{ rel: "stylesheet", href: appCss }]
|
|
14
|
+
}),
|
|
15
|
+
component: RootComponent
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
function RootComponent() {
|
|
19
|
+
return (
|
|
20
|
+
<RootDocument>
|
|
21
|
+
<Outlet />
|
|
22
|
+
</RootDocument>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
|
|
27
|
+
return (
|
|
28
|
+
<html>
|
|
29
|
+
<head>
|
|
30
|
+
<HeadContent />
|
|
31
|
+
</head>
|
|
32
|
+
<body>
|
|
33
|
+
{children}
|
|
34
|
+
<Scripts />
|
|
35
|
+
</body>
|
|
36
|
+
</html>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"
|
|
2
|
+
import { createFileRoute } from "@tanstack/react-router"
|
|
3
|
+
import { Cause, Effect, Exit } from "effect"
|
|
4
|
+
import { AsyncResult, AtomRegistry } from "effect/unstable/reactivity"
|
|
5
|
+
import { useState } from "react"
|
|
6
|
+
import { createNote, notesList } from "../atoms/NotesApi.ts"
|
|
7
|
+
|
|
8
|
+
export const Route = createFileRoute("/")({
|
|
9
|
+
component: Home,
|
|
10
|
+
loader: async ({ context }) => {
|
|
11
|
+
// Populate the registry before rendering, so the markup ships with data and
|
|
12
|
+
// `dehydrate` has something to encode. The loader's job is to run the query,
|
|
13
|
+
// not to judge it — the outcome is rendered by the onFailure branch below
|
|
14
|
+
// rather than thrown into an error boundary.
|
|
15
|
+
//
|
|
16
|
+
// `Effect.exit`, not `Effect.ignore`: AtomHttpApi *dies* on a transport
|
|
17
|
+
// error rather than failing, and `ignore` only swallows failures — a defect
|
|
18
|
+
// escapes it and 500s the whole page when the API is unreachable. `exit`
|
|
19
|
+
// captures both, so an API that is down renders the error branch.
|
|
20
|
+
await Effect.runPromise(
|
|
21
|
+
Effect.exit(AtomRegistry.getResult(notesList)(context.registry))
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
function Home() {
|
|
27
|
+
const notes = useAtomValue(notesList)
|
|
28
|
+
const created = useAtomValue(createNote)
|
|
29
|
+
// `promiseExit` makes the mutation awaitable and hands back an Exit, so the
|
|
30
|
+
// submit handler can act on the outcome directly.
|
|
31
|
+
const submit = useAtomSet(createNote, { mode: "promiseExit" })
|
|
32
|
+
const refreshNotes = useAtomRefresh(notesList)
|
|
33
|
+
const [title, setTitle] = useState("")
|
|
34
|
+
const [body, setBody] = useState("")
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<main>
|
|
38
|
+
<h1>Notes</h1>
|
|
39
|
+
|
|
40
|
+
<form
|
|
41
|
+
onSubmit={async (event) => {
|
|
42
|
+
event.preventDefault()
|
|
43
|
+
if (title.trim() === "") return
|
|
44
|
+
const exit = await submit({ payload: { title, body } })
|
|
45
|
+
// Only a success clears the fields, so a failed submission leaves the
|
|
46
|
+
// user's input in place to retry. Reactivity keys would normally
|
|
47
|
+
// refresh the list; see the comment in atoms/NotesApi.ts for why the
|
|
48
|
+
// refresh is explicit instead.
|
|
49
|
+
if (Exit.isSuccess(exit)) {
|
|
50
|
+
refreshNotes()
|
|
51
|
+
setTitle("")
|
|
52
|
+
setBody("")
|
|
53
|
+
}
|
|
54
|
+
}}
|
|
55
|
+
>
|
|
56
|
+
<input
|
|
57
|
+
aria-label="Title"
|
|
58
|
+
placeholder="Title"
|
|
59
|
+
value={title}
|
|
60
|
+
onChange={(event) => setTitle(event.target.value)}
|
|
61
|
+
/>
|
|
62
|
+
<textarea
|
|
63
|
+
aria-label="Body"
|
|
64
|
+
placeholder="Body"
|
|
65
|
+
rows={3}
|
|
66
|
+
value={body}
|
|
67
|
+
onChange={(event) => setBody(event.target.value)}
|
|
68
|
+
/>
|
|
69
|
+
<button type="submit">Add note</button>
|
|
70
|
+
</form>
|
|
71
|
+
|
|
72
|
+
{AsyncResult.isFailure(created) && (
|
|
73
|
+
<p className="error">Could not add note: {Cause.pretty(created.cause)}</p>
|
|
74
|
+
)}
|
|
75
|
+
|
|
76
|
+
{AsyncResult.match(notes, {
|
|
77
|
+
// Hydration removes the loading state on first paint, but not on a
|
|
78
|
+
// client-side refetch of a cold atom, so this branch still earns its place.
|
|
79
|
+
onInitial: () => <p>Loading…</p>,
|
|
80
|
+
onFailure: (failure) => <p className="error">Could not load notes: {Cause.pretty(failure.cause)}</p>,
|
|
81
|
+
onSuccess: (success) =>
|
|
82
|
+
success.value.length === 0
|
|
83
|
+
? <p>No notes yet.</p>
|
|
84
|
+
: (
|
|
85
|
+
<ul>
|
|
86
|
+
{success.value.map((note) => (
|
|
87
|
+
<li key={note.id}>
|
|
88
|
+
<strong>{note.title}</strong>
|
|
89
|
+
<div>{note.body}</div>
|
|
90
|
+
</li>
|
|
91
|
+
))}
|
|
92
|
+
</ul>
|
|
93
|
+
)
|
|
94
|
+
})}
|
|
95
|
+
</main>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme: light dark;
|
|
3
|
+
font-family: ui-sans-serif, system-ui, sans-serif;
|
|
4
|
+
line-height: 1.5;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
body {
|
|
8
|
+
margin: 0;
|
|
9
|
+
padding: 2rem;
|
|
10
|
+
max-width: 40rem;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
form {
|
|
14
|
+
display: grid;
|
|
15
|
+
gap: 0.5rem;
|
|
16
|
+
margin-block: 1.5rem;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
input,
|
|
20
|
+
textarea,
|
|
21
|
+
button {
|
|
22
|
+
font: inherit;
|
|
23
|
+
padding: 0.5rem;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
button {
|
|
27
|
+
justify-self: start;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
ul {
|
|
31
|
+
padding-left: 1rem;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
li {
|
|
35
|
+
margin-block: 0.75rem;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.error {
|
|
39
|
+
color: #b00020;
|
|
40
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { tanstackStart } from "@tanstack/react-start/plugin/vite"
|
|
2
|
+
import viteReact from "@vitejs/plugin-react"
|
|
3
|
+
import { defineConfig } from "vite"
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
server: {
|
|
7
|
+
// Must match WEB_ORIGIN in apps/api, which CORS is scoped to.
|
|
8
|
+
port: 3001
|
|
9
|
+
},
|
|
10
|
+
plugins: [tanstackStart(), viteReact()]
|
|
11
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# {{name}}
|
|
2
|
+
|
|
3
|
+
A schema-first HTTP API built with Effect v4 — server, OpenAPI docs, and a typed client
|
|
4
|
+
generated from one definition.
|
|
5
|
+
|
|
6
|
+
## Run it
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
{{runCmd}} dev # watch mode
|
|
10
|
+
{{runCmd}} start # once
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
| Route | What |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| `GET /health` | Liveness probe (204) |
|
|
16
|
+
| `GET /notes` | List notes |
|
|
17
|
+
| `POST /notes` | Create a note |
|
|
18
|
+
| `GET /notes/:id` | Fetch one (404 `NoteNotFound` if absent) |
|
|
19
|
+
| `GET /openapi.json` | Generated OpenAPI document |
|
|
20
|
+
| `GET /docs` | Scalar API reference |
|
|
21
|
+
|
|
22
|
+
The port comes from `PORT`, defaulting to 3000.
|
|
23
|
+
|
|
24
|
+
## The typed client
|
|
25
|
+
|
|
26
|
+
`src/client/ApiClient.ts` derives its shape from `src/api/Api.ts`, so renaming an endpoint
|
|
27
|
+
or changing a schema is a compile error on both sides. `src/client.ts` is a runnable demo:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
{{runCmd}} dev # one terminal
|
|
31
|
+
{{runCmd}} client # another
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Adding an endpoint
|
|
35
|
+
|
|
36
|
+
1. Declare it in `src/api/Notes.ts` (or a new group added to `src/api/Api.ts`).
|
|
37
|
+
2. Implement it in `src/server/Notes/http.ts` — the handler list is type-checked against the
|
|
38
|
+
declaration, so a missing handler will not compile.
|
|
39
|
+
|
|
40
|
+
## Tests
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
{{runCmd}} test
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Tests use `HttpApiTest.groups`, which runs the real request encoding, routing and response
|
|
47
|
+
decoding against the handlers with no server and no port.
|
|
48
|
+
|
|
49
|
+
## Telemetry
|
|
50
|
+
|
|
51
|
+
`src/observability.ts` exports logs, metrics and traces over OTLP/HTTP. It is a no-op unless
|
|
52
|
+
`OTEL_EXPORTER_OTLP_ENDPOINT` is set:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 {{runCmd}} dev
|
|
56
|
+
```
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{name}}",
|
|
3
|
+
"module": "src/index.ts",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"private": true,
|
|
6
|
+
"engines": { "bun": ">=1.3.0" },
|
|
7
|
+
"scripts": {
|
|
8
|
+
"dev": "bun --hot src/index.ts",
|
|
9
|
+
"start": "bun src/index.ts",
|
|
10
|
+
"test": "bun test",
|
|
11
|
+
"typecheck": "tsc --noEmit",
|
|
12
|
+
"client": "bun src/client.ts"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"effect": "4.0.0-rc.112",
|
|
16
|
+
"@effect/platform-bun": "4.0.0-rc.112"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/bun": "^1.4.0",
|
|
20
|
+
"typescript": "7.0.2"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -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,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client entrypoint — exercises the running server with the generated client.
|
|
3
|
+
*
|
|
4
|
+
* {{runCmd}} dev # in one terminal
|
|
5
|
+
* {{runCmd}} client # in another
|
|
6
|
+
*/
|
|
7
|
+
import { BunRuntime } from "@effect/platform-bun"
|
|
8
|
+
import { Effect, Layer } from "effect"
|
|
9
|
+
import { ApiClient } from "./client/ApiClient.ts"
|
|
10
|
+
import { NoteId } from "./domain/Note.ts"
|
|
11
|
+
import * as Observability from "./observability.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
|
+
// server 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(
|
|
36
|
+
Effect.withSpan("client-demo"),
|
|
37
|
+
Effect.provide(Layer.mergeAll(ApiClient.layer, Observability.layer("notes-client")))
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
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,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server entrypoint.
|
|
3
|
+
*
|
|
4
|
+
* A schema-first API (`src/api`) is implemented by handlers (`src/server`),
|
|
5
|
+
* served over Bun, and consumed by a generated typed client (`src/client`).
|
|
6
|
+
*
|
|
7
|
+
* {{runCmd}} dev # http://localhost:3000/docs
|
|
8
|
+
*/
|
|
9
|
+
import { BunHttpServer, BunRuntime } from "@effect/platform-bun"
|
|
10
|
+
import { Layer } from "effect"
|
|
11
|
+
import { HttpRouter } from "effect/unstable/http"
|
|
12
|
+
import { port } from "./config.ts"
|
|
13
|
+
import * as Observability from "./observability.ts"
|
|
14
|
+
import { AllRoutes } from "./server/http.ts"
|
|
15
|
+
|
|
16
|
+
const HttpServerLayer = HttpRouter.serve(AllRoutes).pipe(
|
|
17
|
+
Layer.provide(BunHttpServer.layerConfig({ port }))
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
const Main = HttpServerLayer.pipe(
|
|
21
|
+
// Provided at the very end, so every span the app creates is exported.
|
|
22
|
+
Layer.provide(Observability.layer("{{name}}"))
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
Layer.launch(Main).pipe(BunRuntime.runMain)
|
|
@@ -0,0 +1,23 @@
|
|
|
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/index.ts",
|
|
8
|
+
"start": "node --experimental-strip-types src/index.ts",
|
|
9
|
+
"test": "vitest run",
|
|
10
|
+
"typecheck": "tsc --noEmit",
|
|
11
|
+
"client": "node --experimental-strip-types src/client.ts"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@effect/platform-node": "4.0.0-rc.112",
|
|
15
|
+
"effect": "4.0.0-rc.112"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@effect/vitest": "4.0.0-rc.112",
|
|
19
|
+
"@types/node": "^26.4.0",
|
|
20
|
+
"typescript": "7.0.2",
|
|
21
|
+
"vitest": "^4.1.11"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -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
|
+
}
|