@danieljvdm/dev-kit 0.11.3 → 0.13.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/README.md +111 -78
- package/dev-kit.example.jsonc +0 -4
- package/package.json +10 -6
- package/schema/dev-kit.schema.json +1 -46
- package/skills/build-effect-apis/SKILL.md +77 -0
- package/skills/build-effect-apis/agents/openai.yaml +4 -0
- package/skills/build-effect-apis/references/cloudflare-workers.md +71 -0
- package/skills/build-effect-apis/references/effect-atom-client.md +161 -0
- package/skills/build-effect-apis/references/effect-atom-lifecycle.md +78 -0
- package/skills/build-effect-apis/references/effect-atom-testing.md +74 -0
- package/skills/build-effect-apis/references/runtime-assembly.md +56 -0
- package/skills/build-effect-apis/references/server-and-middleware.md +174 -0
- package/skills/build-effect-apis/references/shared-contracts.md +108 -0
- package/skills/build-effect-apis/references/tanstack-start.md +86 -0
- package/skills/build-effect-apis/references/verification.md +50 -0
- package/skills/build-effect-clis/SKILL.md +61 -0
- package/skills/build-effect-clis/agents/openai.yaml +4 -0
- package/skills/build-effect-clis/references/command-design.md +93 -0
- package/skills/build-effect-clis/references/entrypoints-and-testing.md +59 -0
- package/skills/build-effect-clis/references/processes-and-platform.md +73 -0
- package/skills/dev-kit/SKILL.md +77 -48
- package/skills/effect-architecture-audit/SKILL.md +26 -0
- package/skills/effect-architecture-audit/agents/openai.yaml +4 -0
- package/skills/effect-architecture-audit/references/service-and-boundary-audit.md +150 -0
- package/skills/effect-ts/SKILL.md +21 -256
- package/skills/effect-ts/agents/openai.yaml +3 -3
- package/skills/testing/SKILL.md +5 -0
- package/src/catalog-manager.ts +16 -17
- package/src/catalog.ts +71 -16
- package/src/effect-source.ts +46 -24
- package/src/effect-tsgo.ts +86 -24
- package/src/gitignore.ts +5 -5
- package/src/index.ts +3 -6
- package/src/manifest.ts +0 -34
- package/src/node-symbolic-link.ts +2 -2
- package/src/oxfmt.js +5 -0
- package/src/oxfmt.ts +5 -0
- package/src/oxlint.js +5 -0
- package/src/oxlint.ts +5 -0
- package/src/package-skill-source.ts +51 -59
- package/src/path-digest.ts +7 -7
- package/src/project-package.ts +8 -7
- package/src/project-process-lock.ts +17 -12
- package/src/project-state.ts +1 -1
- package/src/skill-manager.ts +18 -16
- package/src/skill-selector.ts +12 -0
- package/src/sync.ts +181 -131
- package/src/tool-ignore-patterns.js +9 -0
- package/src/tool-ignore-patterns.ts +15 -0
- package/src/vendor.ts +67 -61
- package/src/vite-plus-dependency.ts +10 -11
- package/src/vite-plus-hooks.ts +24 -14
- package/src/vite-plus-quality.ts +21 -172
- package/src/vite-plus.js +81 -0
- package/src/vite-plus.ts +102 -0
- package/templates/AGENTS.md +1 -1
- package/skills/effect-ts/UPSTREAM.md +0 -28
- package/skills/effect-ts/references/atom-cache-lifecycle.md +0 -78
- package/skills/effect-ts/references/atom-http-and-invalidation.md +0 -97
- package/skills/effect-ts/references/atom-tanstack-start.md +0 -69
- package/skills/effect-ts/references/atom-testing.md +0 -67
- package/skills/effect-ts/references/audit-services.md +0 -144
- package/skills/effect-ts/references/features.md +0 -525
- package/skills/effect-ts/references/guide-atom-data-fetching.md +0 -44
- package/skills/effect-ts/references/guide-cli.md +0 -107
- package/skills/effect-ts/references/guide-datetime.md +0 -72
- package/skills/effect-ts/references/guide-effect.md +0 -440
- package/skills/effect-ts/references/guide-error-handling.md +0 -565
- package/skills/effect-ts/references/guide-http-boundaries.md +0 -55
- package/skills/effect-ts/references/guide-layers.md +0 -989
- package/skills/effect-ts/references/guide-observability.md +0 -746
- package/skills/effect-ts/references/guide-retries.md +0 -434
- package/skills/effect-ts/references/guide-schedule.md +0 -343
- package/skills/effect-ts/references/guide-schema.md +0 -664
- package/skills/effect-ts/references/guide-sql.md +0 -536
- package/skills/effect-ts/references/guide-testing.md +0 -532
- package/skills/effect-ts/references/guide-type-safety-and-boundaries.md +0 -131
- package/skills/effect-ts/references/version-and-source.md +0 -86
- package/templates/vite-plus/vite.config.ts +0 -22
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Effect API clients
|
|
2
|
+
|
|
3
|
+
Derive clients from the shared `HttpApi`; keep request types, response types,
|
|
4
|
+
and expected errors owned by the contract.
|
|
5
|
+
|
|
6
|
+
- [Inventory the client boundary](#inventory-the-client-boundary)
|
|
7
|
+
- [Build one Atom API service](#build-one-atom-api-service)
|
|
8
|
+
- [Define queries](#define-queries)
|
|
9
|
+
- [Define mutations and invalidation](#define-mutations-and-invalidation)
|
|
10
|
+
- [Use a direct client outside React](#use-a-direct-client-outside-react)
|
|
11
|
+
|
|
12
|
+
## Inventory the client boundary
|
|
13
|
+
|
|
14
|
+
Before changing Atom code, locate every `RegistryProvider`, runtime factory,
|
|
15
|
+
`AtomHttpApi.Service`, query atom, mutation atom, reactivity-key constructor,
|
|
16
|
+
and route aggregate. Confirm the installed `effect` and `@effect/atom-react`
|
|
17
|
+
versions before copying signatures.
|
|
18
|
+
|
|
19
|
+
## Build one Atom API service
|
|
20
|
+
|
|
21
|
+
Create one module-scoped runtime factory and one `AtomHttpApi.Service`. Share a
|
|
22
|
+
memo map when multiple client services must reuse layers.
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { Effect, Layer } from "effect";
|
|
26
|
+
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
|
|
27
|
+
import { HttpApiMiddleware } from "effect/unstable/httpapi";
|
|
28
|
+
import { Atom, AtomHttpApi } from "effect/unstable/reactivity";
|
|
29
|
+
import { ApplicationApi, Authenticate } from "@app/domain";
|
|
30
|
+
|
|
31
|
+
export const AuthenticateClient = HttpApiMiddleware.layerClient(
|
|
32
|
+
Authenticate,
|
|
33
|
+
Effect.fn("AuthenticateClient")(function* ({ next, request }) {
|
|
34
|
+
const token = yield* readAccessToken;
|
|
35
|
+
return yield* next(HttpClientRequest.bearerToken(request, token));
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const ApiHttpClient = Layer.mergeAll(FetchHttpClient.layer, AuthenticateClient);
|
|
40
|
+
|
|
41
|
+
export const appRuntime = Atom.context({
|
|
42
|
+
memoMap: Layer.makeMemoMapUnsafe(),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export const ApiClient = AtomHttpApi.Service()("ApiClient", {
|
|
46
|
+
api: ApplicationApi,
|
|
47
|
+
httpClient: ApiHttpClient,
|
|
48
|
+
runtime: appRuntime,
|
|
49
|
+
baseUrl: "/api",
|
|
50
|
+
transformClient: (client) =>
|
|
51
|
+
HttpClient.mapRequest(client, (request) =>
|
|
52
|
+
HttpClientRequest.setHeader(request, "x-request-id", requestId()),
|
|
53
|
+
),
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Define the runtime, service, key constructors, queries, and mutations outside
|
|
58
|
+
React renders. Keep browser-only identity access behind the client boundary.
|
|
59
|
+
When contract middleware sets `requiredForClient`, satisfy it with
|
|
60
|
+
`HttpApiMiddleware.layerClient` in the `httpClient` layer. Reserve
|
|
61
|
+
`transformClient` for transport-wide behavior such as correlation headers,
|
|
62
|
+
base URL changes, tracing, or retries; do not bypass declared API security with
|
|
63
|
+
an unrelated raw header transform.
|
|
64
|
+
|
|
65
|
+
## Define queries
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
export const projectKeys = {
|
|
69
|
+
collection: ["projects"] as const,
|
|
70
|
+
project: (id: ProjectId) => [`project:${id}`] as const,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const projectAtom = Atom.family((projectId: ProjectId) =>
|
|
74
|
+
ApiClient.query("projects", "getProject", {
|
|
75
|
+
params: { projectId },
|
|
76
|
+
timeToLive: "5 minutes",
|
|
77
|
+
reactivityKeys: projectKeys.project(projectId),
|
|
78
|
+
}).pipe(Atom.swr({ staleTime: "30 seconds", revalidateOnMount: true })),
|
|
79
|
+
);
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`query(group, endpoint, request)` returns an `Atom<AsyncResult<...>>`. The
|
|
83
|
+
service memoizes encoded request keys internally; a public `Atom.family` still
|
|
84
|
+
expresses domain ownership and cache policy through a stable scalar or Effect
|
|
85
|
+
`Hash`/`Equal` key.
|
|
86
|
+
|
|
87
|
+
Query options have distinct jobs:
|
|
88
|
+
|
|
89
|
+
- `timeToLive` controls idle registry retention;
|
|
90
|
+
- `reactivityKeys` registers invalidation subscriptions;
|
|
91
|
+
- `serializationKey` enables decoded-only hydration serialization and is not
|
|
92
|
+
the runtime cache key;
|
|
93
|
+
- `responseMode` changes the response and error shape.
|
|
94
|
+
|
|
95
|
+
Keep secrets out of serialization keys, URL state, hydration payloads, and
|
|
96
|
+
client-visible layers.
|
|
97
|
+
|
|
98
|
+
## Define mutations and invalidation
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
export const updateProjectMutation = ApiClient.mutation("projects", "updateProject");
|
|
102
|
+
|
|
103
|
+
const updateProject = useAtomSet(updateProjectMutation, { mode: "promise" });
|
|
104
|
+
|
|
105
|
+
await updateProject({
|
|
106
|
+
params: { projectId },
|
|
107
|
+
payload: patch,
|
|
108
|
+
reactivityKeys: [...projectKeys.collection, ...projectKeys.project(projectId)],
|
|
109
|
+
});
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Successful mutations invalidate matching keys; failed mutations do not.
|
|
113
|
+
Centralize reactivity-key constructors because mismatched strings fail silently.
|
|
114
|
+
When one mutation affects several exact keys, flatten their arrays; an array
|
|
115
|
+
containing key arrays registers those nested arrays as different keys.
|
|
116
|
+
|
|
117
|
+
Exact array keys invalidate only exact matches. Record-form keys are
|
|
118
|
+
hierarchical in current Effect v4 implementations: a property registers both
|
|
119
|
+
its broad name and each `property:id` combination. Confirm this against the
|
|
120
|
+
installed version before relying on it.
|
|
121
|
+
|
|
122
|
+
Choose invalidation breadth from the server write:
|
|
123
|
+
|
|
124
|
+
- invalidate an entity key when only its detail changed;
|
|
125
|
+
- invalidate the collection when membership, ordering, totals, or filters can
|
|
126
|
+
change;
|
|
127
|
+
- invalidate every affected namespace when a write crosses aggregates.
|
|
128
|
+
|
|
129
|
+
Combine invalidation with manual refresh only when two requests are intended.
|
|
130
|
+
Keep navigation, toasts, form reset, and presentation-level optimistic state
|
|
131
|
+
with the initiating UI.
|
|
132
|
+
|
|
133
|
+
## Use a direct client outside React
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
import { Effect } from "effect";
|
|
137
|
+
import { FetchHttpClient, HttpClientRequest } from "effect/unstable/http";
|
|
138
|
+
import { HttpApiClient, HttpApiMiddleware } from "effect/unstable/httpapi";
|
|
139
|
+
import { ApplicationApi, Authenticate } from "@app/domain";
|
|
140
|
+
import { AccessToken, AccessTokenLive } from "./AccessToken";
|
|
141
|
+
|
|
142
|
+
const AuthenticateClient = HttpApiMiddleware.layerClient(
|
|
143
|
+
Authenticate,
|
|
144
|
+
Effect.fn("AuthenticateClient")(function* ({ next, request }) {
|
|
145
|
+
const token = yield* AccessToken;
|
|
146
|
+
return yield* next(HttpClientRequest.bearerToken(request, token.value));
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const program = Effect.gen(function* () {
|
|
151
|
+
const client = yield* HttpApiClient.make(ApplicationApi, {
|
|
152
|
+
baseUrl: "https://api.example.com",
|
|
153
|
+
});
|
|
154
|
+
return yield* client.projects.getProject({ params: { projectId } });
|
|
155
|
+
}).pipe(Effect.provide([FetchHttpClient.layer, AuthenticateClient, AccessTokenLive]));
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Use the direct client in services, scripts, tests, and non-React applications.
|
|
159
|
+
Provide every required contract middleware at the client composition root.
|
|
160
|
+
Transform the underlying `HttpClient` once for non-contract headers, tracing,
|
|
161
|
+
retry, or base behavior rather than repeating it at every call site.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Effect Atom lifecycle
|
|
2
|
+
|
|
3
|
+
Treat query atoms as shared read state and mutations as actions owned by an
|
|
4
|
+
initiating UI or workflow. Choose registry scope, atom identity, retention,
|
|
5
|
+
freshness, polling, and cancellation independently.
|
|
6
|
+
|
|
7
|
+
## Registry and runtime scope
|
|
8
|
+
|
|
9
|
+
`RegistryProvider` creates its registry on first render; later option changes do
|
|
10
|
+
not rebuild it. Provider unmount schedules disposal after a short grace period,
|
|
11
|
+
while moving or keying the provider changes the cache boundary. Place one
|
|
12
|
+
provider around the application subtree that should share client state; nested
|
|
13
|
+
or route-local providers create separate caches.
|
|
14
|
+
|
|
15
|
+
The registry stores nodes, values, subscriptions, idle timers, and finalizers.
|
|
16
|
+
An Atom runtime supplies Effect services, and `Atom.context({ memoMap })` lets
|
|
17
|
+
several runtimes share layer construction. Create neither a registry nor a memo
|
|
18
|
+
map per query or render. Keep request-specific authentication out of a
|
|
19
|
+
process-global server memo map.
|
|
20
|
+
|
|
21
|
+
## Stable identity and families
|
|
22
|
+
|
|
23
|
+
Export fixed queries directly. Use `Atom.family` when a parameter selects the
|
|
24
|
+
resource. Prefer a primitive family argument; give object keys deliberate Effect
|
|
25
|
+
`Equal`/`Hash` semantics or reuse the same object. Calling a family with a stable
|
|
26
|
+
primitive during render already returns its stable member; an extra `useMemo`
|
|
27
|
+
does not improve identity.
|
|
28
|
+
|
|
29
|
+
## Three independent clocks
|
|
30
|
+
|
|
31
|
+
| Control | Clock starts | Effect | It does not mean |
|
|
32
|
+
| ----------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------- | -------------------------- |
|
|
33
|
+
| Registry/default idle TTL or query `timeToLive` | When the atom becomes unused | Retain the cached node until idle eviction | The value remains fresh |
|
|
34
|
+
| `Atom.swr({ staleTime })` | At the latest success | Revalidate stale data on configured mount/focus signals while showing the prior success | The node survives unmount |
|
|
35
|
+
| `Atom.withRefresh(interval)` | While its wrapper is mounted | Force periodic refresh until disposal | Fresh requests are skipped |
|
|
36
|
+
|
|
37
|
+
Set retention long enough for the intended navigation/remount reuse window.
|
|
38
|
+
`staleTime` cannot rescue an already evicted node. Manual refresh, invalidation,
|
|
39
|
+
and polling are forceful and do not consult SWR freshness. Applying `keepAlive`
|
|
40
|
+
to a polling wrapper intentionally keeps that polling lifetime alive.
|
|
41
|
+
|
|
42
|
+
Atom registries are in-memory caches, not durable or shared storage. New tabs,
|
|
43
|
+
hard reloads, processes, and providers have separate caches. Idle TTL is not a
|
|
44
|
+
maximum-entry bound; finite TTLs are important for unbounded family keys.
|
|
45
|
+
|
|
46
|
+
## React mounts and action ownership
|
|
47
|
+
|
|
48
|
+
- `useAtomValue(atom)` subscribes for rendering;
|
|
49
|
+
- `useAtomSet(atom)` mounts a writable atom and returns a setter;
|
|
50
|
+
- `useAtom(atom)` subscribes and writes when one component owns both behaviors.
|
|
51
|
+
|
|
52
|
+
Use `useAtom` instead of pairing `useAtomValue` and `useAtomSet` for the same
|
|
53
|
+
atom in one component. The split form creates a value subscription plus a
|
|
54
|
+
separate mount and obscures ownership.
|
|
55
|
+
|
|
56
|
+
Unmount releases only that hook's subscription or mount. Registry eviction waits
|
|
57
|
+
for remaining consumers and idle TTL, then runs node finalizers. Component
|
|
58
|
+
unmount, registry eviction, and `Atom.Interrupt` are distinct events.
|
|
59
|
+
|
|
60
|
+
Use `Atom.Interrupt` for explicit cancellation. A cleanup write publishes an
|
|
61
|
+
interrupted `AsyncResult.Failure`; it is not passive unmount release, can run
|
|
62
|
+
during React Strict Mode replay, and can cancel work owned by another consumer.
|
|
63
|
+
|
|
64
|
+
Place a multi-request action in an owner that lives for the whole sequence. If
|
|
65
|
+
navigation or disconnect must not leave a partially completed fan-out, use a
|
|
66
|
+
stable workflow atom/service or one durable server-side command.
|
|
67
|
+
|
|
68
|
+
## Keep aggregates stable
|
|
69
|
+
|
|
70
|
+
`AsyncResult.all` returns the first non-success input, so an aggregate is only
|
|
71
|
+
as reusable as its least-stable input. A newly allocated or evicted input
|
|
72
|
+
returns to `Initial` and makes the aggregate appear reset even while other
|
|
73
|
+
queries remain cached.
|
|
74
|
+
|
|
75
|
+
Define every input as a module-scoped singleton or stable family member with
|
|
76
|
+
compatible retention. Aggregate inside a derived atom so the registry owns
|
|
77
|
+
recomputation; memoizing only the `AsyncResult.all` call does not repair an
|
|
78
|
+
unstable input atom.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Deterministic Effect Atom testing
|
|
2
|
+
|
|
3
|
+
Test cache policy below React first with `AtomRegistry.make()`. Add a React
|
|
4
|
+
integration test for provider placement, hook behavior, browser-only SSR
|
|
5
|
+
boundaries, hydration, or Strict Mode ownership.
|
|
6
|
+
|
|
7
|
+
Use fake timers, a request counter, controllable Effects, and explicit mounts:
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
const registry = AtomRegistry.make({ defaultIdleTTL: 1_000 });
|
|
11
|
+
const unmount = registry.mount(queryAtom);
|
|
12
|
+
const first = registry.get(queryAtom);
|
|
13
|
+
|
|
14
|
+
// Flush Effect work and advance the test runner's fake timers.
|
|
15
|
+
|
|
16
|
+
unmount();
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Use the repository's established `Effect.yieldNow` or test-clock pattern rather
|
|
20
|
+
than wall-clock sleeps. Prefer installed APIs and predicates such as
|
|
21
|
+
`AsyncResult.isSuccess`; use the cleanup returned by `registry.mount(atom)`.
|
|
22
|
+
|
|
23
|
+
## Remount reuse
|
|
24
|
+
|
|
25
|
+
1. Mount and resolve the query; assert request count `1`.
|
|
26
|
+
2. Unmount, advance less than idle TTL, and remount the same atom identity.
|
|
27
|
+
3. Assert cached success is immediately available and no fresh request occurs.
|
|
28
|
+
|
|
29
|
+
## Stale refresh
|
|
30
|
+
|
|
31
|
+
1. Resolve once through an SWR wrapper.
|
|
32
|
+
2. Advance past `staleTime` but not idle TTL.
|
|
33
|
+
3. Remount or emit the injected focus signal.
|
|
34
|
+
4. Assert the previous success remains with `waiting: true`, then a second
|
|
35
|
+
success arrives and request count becomes `2`.
|
|
36
|
+
5. Prove a fresh mount or focus signal does not request.
|
|
37
|
+
|
|
38
|
+
## TTL eviction
|
|
39
|
+
|
|
40
|
+
1. Resolve and unmount.
|
|
41
|
+
2. Advance to just before TTL and assert reuse.
|
|
42
|
+
3. Advance through TTL, flush disposal, and remount.
|
|
43
|
+
4. Assert initial/waiting behavior and a new request.
|
|
44
|
+
|
|
45
|
+
## Polling cleanup
|
|
46
|
+
|
|
47
|
+
1. Mount the `Atom.withRefresh` wrapper and resolve once.
|
|
48
|
+
2. Advance one interval and assert one forced refresh.
|
|
49
|
+
3. Unmount, advance several intervals, and assert the counter stops changing.
|
|
50
|
+
|
|
51
|
+
## Mutation invalidation
|
|
52
|
+
|
|
53
|
+
1. Mount list and detail queries with explicit keys.
|
|
54
|
+
2. Run a successful mutation and assert only intended queries refresh.
|
|
55
|
+
3. Run a failed mutation and assert nothing invalidates.
|
|
56
|
+
4. Dispose the queries and assert invalidation handlers are removed.
|
|
57
|
+
|
|
58
|
+
## Strict Mode and action ownership
|
|
59
|
+
|
|
60
|
+
Render the owner inside `StrictMode`. Prove development setup-cleanup replay
|
|
61
|
+
does not write `Atom.Interrupt` or publish an interrupted failure before an
|
|
62
|
+
explicit cancellation. For multi-request actions, unmount during the sequence
|
|
63
|
+
and prove the chosen policy. If the atom is shared, prove unmounting one
|
|
64
|
+
consumer does not cancel work still owned by another.
|
|
65
|
+
|
|
66
|
+
## Aggregate stability
|
|
67
|
+
|
|
68
|
+
Mount a route atom using `AsyncResult.all`, resolve every input, unmount, and
|
|
69
|
+
remount inside the retention window. Assert the aggregate never returns to
|
|
70
|
+
`Initial`. Then expire one input and prove that input causes the reset.
|
|
71
|
+
|
|
72
|
+
For runtime/layer tests, seed `runtime.layer` through `RegistryProvider`
|
|
73
|
+
`initialValues` with a deterministic test layer. Replace network services
|
|
74
|
+
without changing the production atom graph.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Standard runtime assembly
|
|
2
|
+
|
|
3
|
+
Use this branch for conventional Node/Bun processes, generated documentation,
|
|
4
|
+
and Web-standard handlers. Confirm platform package names against the installed
|
|
5
|
+
versions.
|
|
6
|
+
|
|
7
|
+
## Compose routes before choosing the runtime
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { NodeHttpServer, NodeRuntime } from "@effect/platform-node";
|
|
11
|
+
import { Layer } from "effect";
|
|
12
|
+
import { HttpRouter, HttpServer } from "effect/unstable/http";
|
|
13
|
+
import { HttpApiBuilder, HttpApiScalar } from "effect/unstable/httpapi";
|
|
14
|
+
import { createServer } from "node:http";
|
|
15
|
+
import { ApplicationApi } from "@app/domain/http";
|
|
16
|
+
|
|
17
|
+
const ApplicationHttpLive = Layer.mergeAll(ProjectsHandlersLive, AuthenticateLive).pipe(
|
|
18
|
+
Layer.provide(ApplicationServicesLive),
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const ApiRoutes = HttpApiBuilder.layer(ApplicationApi, {
|
|
22
|
+
openapiPath: "/openapi.json",
|
|
23
|
+
}).pipe(Layer.provide(ApplicationHttpLive));
|
|
24
|
+
|
|
25
|
+
const DocsRoute = HttpApiScalar.layer(ApplicationApi, { path: "/docs" });
|
|
26
|
+
const AllRoutes = Layer.mergeAll(ApiRoutes, DocsRoute);
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Build the typed API route and documentation route from the same contract. Merge
|
|
30
|
+
raw routes here only when the API schema cannot represent their transport, and
|
|
31
|
+
ensure they independently enforce equivalent identity and security behavior.
|
|
32
|
+
|
|
33
|
+
## Run a long-lived server
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
const HttpServerLive = HttpRouter.serve(AllRoutes).pipe(
|
|
37
|
+
Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
Layer.launch(HttpServerLive).pipe(NodeRuntime.runMain);
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Use the matching platform server layer for Bun or another runtime. Keep the
|
|
44
|
+
platform choice at this composition root.
|
|
45
|
+
|
|
46
|
+
## Export a Web handler
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
export const { handler, dispose } = HttpRouter.toWebHandler(
|
|
50
|
+
AllRoutes.pipe(Layer.provide(HttpServer.layerServices)),
|
|
51
|
+
);
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Use the Web handler for serverless and framework adapters that accept standard
|
|
55
|
+
`Request` and `Response` values. Preserve and call `dispose` when the host has a
|
|
56
|
+
lifecycle hook; do not recreate the handler and its runtime for every request.
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# Server handlers and middleware
|
|
2
|
+
|
|
3
|
+
Implement the shared contract with group handlers, application services, and
|
|
4
|
+
layers. Confirm names against the installed Effect version; examples here use
|
|
5
|
+
the Effect v4 `effect/unstable/httpapi` surface.
|
|
6
|
+
|
|
7
|
+
- [Implement thin group handlers](#implement-thin-group-handlers)
|
|
8
|
+
- [Declare security middleware in the shared contract](#declare-security-middleware-in-the-shared-contract)
|
|
9
|
+
- [Implement security middleware on the server](#implement-security-middleware-on-the-server)
|
|
10
|
+
- [Assemble layers at the runtime edge](#assemble-layers-at-the-runtime-edge)
|
|
11
|
+
- [Use response escape hatches deliberately](#use-response-escape-hatches-deliberately)
|
|
12
|
+
|
|
13
|
+
## Implement thin group handlers
|
|
14
|
+
|
|
15
|
+
Resolve application services once in the group builder, then map every endpoint
|
|
16
|
+
identifier to one handler. Use `Effect.fn` for callbacks that return Effects.
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { Effect } from "effect";
|
|
20
|
+
import { HttpApiBuilder } from "effect/unstable/httpapi";
|
|
21
|
+
import { ApplicationApi } from "@app/domain/http";
|
|
22
|
+
import { Projects } from "../services/Projects";
|
|
23
|
+
|
|
24
|
+
export const ProjectsHandlersLive = HttpApiBuilder.group(
|
|
25
|
+
ApplicationApi,
|
|
26
|
+
"projects",
|
|
27
|
+
Effect.fn("ProjectsApi.handlers")(function* (handlers) {
|
|
28
|
+
const projects = yield* Projects;
|
|
29
|
+
|
|
30
|
+
return handlers
|
|
31
|
+
.handle("listProjects", () => projects.list().pipe(Effect.orDie))
|
|
32
|
+
.handle("getProject", ({ params }) =>
|
|
33
|
+
projects
|
|
34
|
+
.get(params.projectId)
|
|
35
|
+
.pipe(Effect.catchReasons("ProjectsError", { ProjectNotFound: Effect.fail }, Effect.die)),
|
|
36
|
+
);
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Handlers receive decoded `params`, `query`, `headers`, and `payload`. Check
|
|
42
|
+
cross-field invariants at this boundary when schemas cannot express them, then
|
|
43
|
+
call one application workflow. Keep persistence, transactions, retries, and
|
|
44
|
+
multi-service orchestration in services.
|
|
45
|
+
|
|
46
|
+
Reconcile the service error channel with the endpoint contract here. Use
|
|
47
|
+
`Effect.orDie` when an endpoint declares no service failures. Use
|
|
48
|
+
`Effect.catchReason`, `Effect.catchReasons`, or `Effect.unwrapReason` when an
|
|
49
|
+
application service wraps domain reasons: re-fail only reasons declared by the
|
|
50
|
+
endpoint and turn unexpected infrastructure or invariant failures into defects.
|
|
51
|
+
Finish when each handler's expected error type is a subset of the endpoint's
|
|
52
|
+
declared error schema.
|
|
53
|
+
|
|
54
|
+
## Declare security middleware in the shared contract
|
|
55
|
+
|
|
56
|
+
Keep the middleware declaration in the shared contract package so server,
|
|
57
|
+
OpenAPI, and generated clients see the same requirement. Keep its implementation
|
|
58
|
+
out of that package.
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
// packages/domain/src/http/authorization.ts
|
|
62
|
+
import { Context, Schema } from "effect";
|
|
63
|
+
import { HttpApiGroup, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi";
|
|
64
|
+
|
|
65
|
+
export class CurrentActor extends Context.Service<CurrentActor, { readonly id: string }>()(
|
|
66
|
+
"app/CurrentActor",
|
|
67
|
+
) {}
|
|
68
|
+
|
|
69
|
+
export class Unauthorized extends Schema.TaggedError<Unauthorized>()(
|
|
70
|
+
"Unauthorized",
|
|
71
|
+
{ message: Schema.String },
|
|
72
|
+
{ httpApiStatus: 401 },
|
|
73
|
+
) {}
|
|
74
|
+
|
|
75
|
+
export class Authenticate extends HttpApiMiddleware.Service<
|
|
76
|
+
Authenticate,
|
|
77
|
+
{
|
|
78
|
+
readonly provides: CurrentActor;
|
|
79
|
+
readonly requires: never;
|
|
80
|
+
}
|
|
81
|
+
>()("app/Authenticate", {
|
|
82
|
+
requiredForClient: true,
|
|
83
|
+
security: { bearer: HttpApiSecurity.bearer },
|
|
84
|
+
error: Unauthorized,
|
|
85
|
+
}) {}
|
|
86
|
+
|
|
87
|
+
export const ProjectsApi = HttpApiGroup.make("projects")
|
|
88
|
+
.add(GetProjectEndpoint, ListProjectsEndpoint)
|
|
89
|
+
.middleware(Authenticate)
|
|
90
|
+
.prefix("/projects");
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`requiredForClient` makes the generated client require a matching client-side
|
|
94
|
+
middleware layer. The security declaration also owns credential decoding and
|
|
95
|
+
OpenAPI security metadata; do not manually decode an `Authorization` header for
|
|
96
|
+
this case.
|
|
97
|
+
|
|
98
|
+
## Implement security middleware on the server
|
|
99
|
+
|
|
100
|
+
A security middleware implementation is keyed by the names in its `security`
|
|
101
|
+
object. It can use server-only services without leaking them into clients.
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
// apps/api/src/http/Authenticate.ts
|
|
105
|
+
import { Effect, Layer, Redacted } from "effect";
|
|
106
|
+
import { Authenticate, CurrentActor, Unauthorized } from "@app/domain/http";
|
|
107
|
+
import { Actors } from "../services/Actors";
|
|
108
|
+
|
|
109
|
+
export const AuthenticateLive = Layer.effect(
|
|
110
|
+
Authenticate,
|
|
111
|
+
Effect.gen(function* () {
|
|
112
|
+
const actors = yield* Actors;
|
|
113
|
+
|
|
114
|
+
return Authenticate.of({
|
|
115
|
+
bearer: Effect.fn("Authenticate.bearer")(function* (httpEffect, { credential }) {
|
|
116
|
+
const actor = yield* actors
|
|
117
|
+
.authenticate(Redacted.value(credential))
|
|
118
|
+
.pipe(
|
|
119
|
+
Effect.catch(() => Effect.fail(new Unauthorized({ message: "Invalid bearer token" }))),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
return yield* Effect.provideService(httpEffect, CurrentActor, actor);
|
|
123
|
+
}),
|
|
124
|
+
});
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Use ordinary `HttpApiMiddleware.Service` implementations for request logging,
|
|
130
|
+
tenancy, correlation IDs, or other cross-cutting work that is not a declared
|
|
131
|
+
security scheme. Those implementations may access `HttpServerRequest`, but they
|
|
132
|
+
must decode raw headers, cookies, and external values before providing services.
|
|
133
|
+
When middleware uses `requires` and `provides`, confirm ordering from the
|
|
134
|
+
installed docs: the outer middleware that requires a service is attached before
|
|
135
|
+
the inner middleware that provides it.
|
|
136
|
+
|
|
137
|
+
Every error a middleware can return belongs in its `error` schema. Keep the
|
|
138
|
+
server layer separate from the shared declaration so importing the contract
|
|
139
|
+
never pulls secrets, repositories, or platform services into a client bundle.
|
|
140
|
+
|
|
141
|
+
## Assemble layers at the runtime edge
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
const ProjectsHandlersProvided = ProjectsHandlersLive.pipe(Layer.provide(ApplicationServicesLive));
|
|
145
|
+
|
|
146
|
+
const ApiLive = HttpApiBuilder.layer(ApplicationApi, {
|
|
147
|
+
openapiPath: "/api/openapi.json",
|
|
148
|
+
}).pipe(
|
|
149
|
+
Layer.provide(ProjectsHandlersProvided),
|
|
150
|
+
Layer.provide(AuthenticateLive),
|
|
151
|
+
Layer.provideMerge(HttpRuntimePrerequisitesLive),
|
|
152
|
+
);
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The exact HTTP platform/router/server layers vary by runtime. Keep that wiring
|
|
156
|
+
at the entrypoint, provide all group and middleware layers, and let the type
|
|
157
|
+
system expose missing requirements. Verify that the OpenAPI path composes with
|
|
158
|
+
any API prefix as intended.
|
|
159
|
+
|
|
160
|
+
For a conventional Node/Bun process, generated docs route, or serverless web
|
|
161
|
+
handler, read [runtime-assembly.md](runtime-assembly.md).
|
|
162
|
+
|
|
163
|
+
Transport-wide middleware such as CORS may wrap the final HTTP application.
|
|
164
|
+
Keep API middleware for contract-visible behavior and runtime HTTP middleware
|
|
165
|
+
for concerns that also apply to raw or non-API routes.
|
|
166
|
+
|
|
167
|
+
## Use response escape hatches deliberately
|
|
168
|
+
|
|
169
|
+
Return typed success values and declared errors for ordinary endpoints. Use
|
|
170
|
+
`HttpServerResponse` when the boundary must pass through an upstream response,
|
|
171
|
+
redirect, set cookies, stream, or control a non-default success status or body.
|
|
172
|
+
Use Effect request and cookie APIs to parse boundary state. Reserve unsafe JSON
|
|
173
|
+
response constructors for values whose safety is established elsewhere; encode
|
|
174
|
+
ordinary typed 4xx failures through the endpoint's declared error schemas.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Shared contracts
|
|
2
|
+
|
|
3
|
+
Use a runtime-neutral domain package as the protocol boundary shared by the
|
|
4
|
+
server and every typed client.
|
|
5
|
+
|
|
6
|
+
## Package shape
|
|
7
|
+
|
|
8
|
+
```text
|
|
9
|
+
packages/domain/
|
|
10
|
+
├── package.json
|
|
11
|
+
└── src/
|
|
12
|
+
├── models/
|
|
13
|
+
│ ├── ids.ts
|
|
14
|
+
│ ├── errors.ts
|
|
15
|
+
│ └── views.ts
|
|
16
|
+
└── http/
|
|
17
|
+
├── endpoints/
|
|
18
|
+
│ ├── get-project.ts
|
|
19
|
+
│ └── update-project.ts
|
|
20
|
+
├── authorization.ts
|
|
21
|
+
├── project-api.ts
|
|
22
|
+
├── application-api.ts
|
|
23
|
+
└── index.ts
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Export the root API, contract-visible middleware declarations, and any genuinely
|
|
27
|
+
reusable schemas. Keep middleware implementations, server handlers, React,
|
|
28
|
+
database clients, secrets, platform bindings, and runtime layers out of this
|
|
29
|
+
package. A workspace export map can expose `./http`, `./models`, and endpoint
|
|
30
|
+
subpaths when consumers need them.
|
|
31
|
+
|
|
32
|
+
## Model the wire
|
|
33
|
+
|
|
34
|
+
Use schemas for every external value. Prefer reusable named classes for DTOs,
|
|
35
|
+
branded scalar schemas for identifiers, and serializable tagged errors with
|
|
36
|
+
HTTP status metadata for expected failures.
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { Schema } from "effect";
|
|
40
|
+
import { HttpApiSchema } from "effect/unstable/httpapi";
|
|
41
|
+
|
|
42
|
+
export const ProjectId = Schema.String.pipe(Schema.brand("ProjectId"));
|
|
43
|
+
export type ProjectId = typeof ProjectId.Type;
|
|
44
|
+
|
|
45
|
+
export class ProjectDto extends Schema.Class<ProjectDto>("ProjectDto")({
|
|
46
|
+
id: ProjectId,
|
|
47
|
+
name: Schema.NonEmptyString,
|
|
48
|
+
}) {}
|
|
49
|
+
|
|
50
|
+
export class ProjectNotFoundError extends Schema.TaggedError<ProjectNotFoundError>()(
|
|
51
|
+
"ProjectNotFoundError",
|
|
52
|
+
{ id: ProjectId },
|
|
53
|
+
) {}
|
|
54
|
+
|
|
55
|
+
export const ProjectNotFound = ProjectNotFoundError.pipe(HttpApiSchema.status("NotFound"));
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Keep success schemas limited to successful values. Put every expected 4xx/5xx
|
|
59
|
+
failure in `error`; defects remain defects. Reuse a tuple of transport errors
|
|
60
|
+
only when the same set is genuinely standard across endpoints.
|
|
61
|
+
|
|
62
|
+
Make DTOs describe the wire exactly. If clients receive `id`, expose `id` even
|
|
63
|
+
when a persistence model uses `publicId`; map between them in a small boundary
|
|
64
|
+
function. Keep endpoint-specific request DTOs beside their endpoint and move a
|
|
65
|
+
DTO into shared models only when multiple contracts reuse the same concept.
|
|
66
|
+
|
|
67
|
+
## Define one endpoint per file
|
|
68
|
+
|
|
69
|
+
An endpoint declares the complete request and response contract inline.
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { HttpApiEndpoint } from "effect/unstable/httpapi";
|
|
73
|
+
import { ProjectDto, ProjectId, ProjectNotFound } from "../../models";
|
|
74
|
+
import { RequestHeaders } from "../headers";
|
|
75
|
+
|
|
76
|
+
export const GetProjectEndpoint = HttpApiEndpoint.get("getProject", "/projects/:projectId", {
|
|
77
|
+
params: { projectId: ProjectId },
|
|
78
|
+
headers: RequestHeaders,
|
|
79
|
+
success: ProjectDto,
|
|
80
|
+
error: ProjectNotFound,
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Use the operation identifier as a stable protocol name: handlers and generated
|
|
85
|
+
clients address it directly. Represent optional query values with the installed
|
|
86
|
+
version's optional schema operator, such as `Schema.optionalKey`.
|
|
87
|
+
|
|
88
|
+
If headers use `Schema.Class`, generated client input may require a constructed
|
|
89
|
+
class instance rather than a structurally similar object. Encode a representative
|
|
90
|
+
instance in a test so this contract stays explicit.
|
|
91
|
+
|
|
92
|
+
## Compose groups and the root
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { HttpApi, HttpApiGroup } from "effect/unstable/httpapi";
|
|
96
|
+
import { GetProjectEndpoint, UpdateProjectEndpoint } from "./endpoints";
|
|
97
|
+
|
|
98
|
+
export const ProjectsApi = HttpApiGroup.make("projects")
|
|
99
|
+
.add(GetProjectEndpoint, UpdateProjectEndpoint)
|
|
100
|
+
.prefix("/projects");
|
|
101
|
+
|
|
102
|
+
export const ApplicationApi = HttpApi.make("application").add(ProjectsApi).prefix("/api");
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Group and root files compose; endpoint files specify. Re-export through index
|
|
106
|
+
files so server and clients import the same `ApplicationApi` value. A contract
|
|
107
|
+
change is complete only after all affected handlers, callers, tests, and
|
|
108
|
+
OpenAPI output agree with it.
|