@gjermundgaraba/effect-actions 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (321) hide show
  1. package/README.md +19 -41
  2. package/dist/Action.d.mts +140 -46
  3. package/dist/Action.mjs +87 -42
  4. package/dist/ActionCli.d.mts +69 -33
  5. package/dist/ActionCli.mjs +51 -32
  6. package/dist/ActionHttp.d.mts +69 -35
  7. package/dist/ActionHttp.mjs +75 -23
  8. package/dist/ActionMcp.d.mts +56 -22
  9. package/dist/ActionMcp.mjs +94 -69
  10. package/dist/ActionRpc.d.mts +222 -55
  11. package/dist/ActionRpc.mjs +299 -46
  12. package/dist/ActionToolkit.d.mts +22 -5
  13. package/dist/ActionToolkit.mjs +36 -12
  14. package/dist/Authentication.d.mts +42 -10
  15. package/dist/Authentication.mjs +37 -30
  16. package/dist/Event.d.mts +370 -0
  17. package/dist/Event.mjs +103 -0
  18. package/dist/Testing.mjs +1 -1
  19. package/dist/Webhook.d.mts +146 -0
  20. package/dist/Webhook.mjs +72 -0
  21. package/dist/authentication/provider.d.mts +35 -10
  22. package/dist/authentication/provider.mjs +1 -1
  23. package/dist/authentication/refusal.mjs +2 -2
  24. package/dist/cli/commands.d.mts +9 -5
  25. package/dist/cli/commands.mjs +84 -33
  26. package/dist/contract/ast.mjs +4 -1
  27. package/dist/contract/authorizer.d.mts +8 -0
  28. package/dist/contract/authorizer.mjs +20 -0
  29. package/dist/contract/call.d.mts +9 -6
  30. package/dist/contract/call.mjs +48 -6
  31. package/dist/contract/callback.d.mts +7 -0
  32. package/dist/contract/callback.mjs +27 -0
  33. package/dist/contract/caller.d.mts +13 -2
  34. package/dist/contract/caller.mjs +22 -3
  35. package/dist/contract/canonical.mjs +10 -0
  36. package/dist/contract/codec.d.mts +9 -0
  37. package/dist/contract/codec.mjs +9 -0
  38. package/dist/contract/codes.mjs +81 -0
  39. package/dist/contract/declared.mjs +25 -9
  40. package/dist/contract/digest.mjs +6 -0
  41. package/dist/contract/errors.d.mts +9 -3
  42. package/dist/contract/errors.mjs +1 -1
  43. package/dist/contract/feed.d.mts +127 -0
  44. package/dist/contract/feed.mjs +249 -0
  45. package/dist/contract/hub.d.mts +94 -0
  46. package/dist/contract/hub.mjs +133 -0
  47. package/dist/contract/idempotency.d.mts +47 -0
  48. package/dist/contract/idempotency.mjs +99 -0
  49. package/dist/contract/implementation.d.mts +73 -47
  50. package/dist/contract/implementation.mjs +92 -58
  51. package/dist/contract/input-check.d.mts +15 -0
  52. package/dist/contract/input-check.mjs +16 -0
  53. package/dist/contract/lease.mjs +28 -0
  54. package/dist/contract/listed.d.mts +4 -0
  55. package/dist/contract/listed.mjs +22 -0
  56. package/dist/contract/log.d.mts +41 -0
  57. package/dist/contract/log.mjs +144 -0
  58. package/dist/contract/names.mjs +14 -4
  59. package/dist/contract/occurrence.d.mts +29 -0
  60. package/dist/contract/occurrence.mjs +45 -0
  61. package/dist/contract/pages.d.mts +13 -0
  62. package/dist/contract/pages.mjs +39 -0
  63. package/dist/contract/progress.d.mts +26 -0
  64. package/dist/contract/progress.mjs +72 -0
  65. package/dist/contract/push.d.mts +20 -0
  66. package/dist/contract/push.mjs +96 -0
  67. package/dist/contract/receiving.d.mts +29 -0
  68. package/dist/contract/receiving.mjs +42 -0
  69. package/dist/contract/recent.mjs +12 -0
  70. package/dist/contract/reported.mjs +16 -0
  71. package/dist/contract/serving.mjs +63 -0
  72. package/dist/contract/status.mjs +14 -5
  73. package/dist/contract/stream.d.mts +34 -0
  74. package/dist/contract/stream.mjs +31 -0
  75. package/dist/contract/subscribe.d.mts +45 -0
  76. package/dist/contract/subscribe.mjs +50 -0
  77. package/dist/contract/transient.mjs +28 -0
  78. package/dist/contract/watch.d.mts +18 -0
  79. package/dist/contract/watch.mjs +105 -0
  80. package/dist/contract/webhooks.d.mts +42 -0
  81. package/dist/contract/webhooks.mjs +53 -0
  82. package/dist/http/client.d.mts +37 -12
  83. package/dist/http/client.mjs +51 -12
  84. package/dist/http/events.d.mts +56 -0
  85. package/dist/http/events.mjs +102 -0
  86. package/dist/http/placed.d.mts +36 -0
  87. package/dist/http/placed.mjs +71 -0
  88. package/dist/http/schema-errors.mjs +1 -1
  89. package/dist/http/streamed.d.mts +21 -0
  90. package/dist/http/streamed.mjs +94 -0
  91. package/dist/http/watch.mjs +50 -0
  92. package/dist/mcp/bridge.mjs +283 -0
  93. package/dist/mcp/client.d.mts +30 -97
  94. package/dist/mcp/client.mjs +59 -132
  95. package/dist/mcp/events.mjs +142 -0
  96. package/dist/mcp/progress.mjs +34 -0
  97. package/dist/mcp/protocol.mjs +39 -13
  98. package/dist/mcp/reply.d.mts +79 -0
  99. package/dist/mcp/reply.mjs +99 -0
  100. package/dist/mcp/request.d.mts +40 -0
  101. package/dist/mcp/request.mjs +51 -0
  102. package/dist/mcp/watch.mjs +115 -0
  103. package/dist/rpc/watch.mjs +39 -0
  104. package/dist/stdio/console.mjs +13 -5
  105. package/dist/testing/memory.d.mts +1 -1
  106. package/dist/testing/memory.mjs +3 -3
  107. package/dist/webhook/address.mjs +47 -0
  108. package/dist/webhook/attempt.mjs +65 -0
  109. package/dist/webhook/receiver.mjs +67 -0
  110. package/dist/webhook/signing.mjs +28 -0
  111. package/dist/webhook/stored.mjs +57 -0
  112. package/dist/webhook/subscriptions.mjs +342 -0
  113. package/docs/Action-failures.md +2 -2
  114. package/docs/Action-streams.md +115 -0
  115. package/docs/Action.md +58 -124
  116. package/docs/ActionCli-failures.md +1 -2
  117. package/docs/ActionCli.md +83 -88
  118. package/docs/ActionHttp-failures.md +3 -3
  119. package/docs/ActionHttp.md +90 -70
  120. package/docs/ActionMcp-failures.md +2 -1
  121. package/docs/ActionMcp.md +120 -92
  122. package/docs/ActionRpc-events.md +126 -0
  123. package/docs/ActionRpc-failures.md +1 -2
  124. package/docs/ActionRpc.md +108 -79
  125. package/docs/ActionToolkit-failures.md +1 -2
  126. package/docs/ActionToolkit.md +33 -90
  127. package/docs/Authentication-advanced.md +52 -60
  128. package/docs/Authentication-failures.md +5 -4
  129. package/docs/Authentication.md +23 -58
  130. package/docs/Event-failures.md +15 -0
  131. package/docs/Event.md +281 -0
  132. package/docs/README.md +51 -82
  133. package/docs/Testing-failures.md +1 -5
  134. package/docs/Testing.md +126 -86
  135. package/docs/Webhook-failures.md +8 -0
  136. package/docs/Webhook.md +319 -0
  137. package/docs/setup-failures.md +2 -3
  138. package/docs/setup.md +32 -52
  139. package/examples/README.md +12 -37
  140. package/examples/assistant/README.md +21 -26
  141. package/examples/assistant/src/agent.ts +66 -24
  142. package/examples/assistant/src/cli.ts +7 -4
  143. package/examples/assistant/src/commands.ts +9 -4
  144. package/examples/assistant/src/identity/Identity.ts +5 -4
  145. package/examples/assistant/src/identity/authorize.ts +13 -5
  146. package/examples/assistant/src/identity/handlers.ts +3 -2
  147. package/examples/assistant/src/identity/permissions.ts +9 -3
  148. package/examples/assistant/src/platform/tasks-file.ts +15 -11
  149. package/examples/assistant/src/scripted.ts +7 -4
  150. package/examples/assistant/src/tasks/Tasks.ts +20 -6
  151. package/examples/assistant/src/tasks/handlers.ts +9 -5
  152. package/examples/assistant/src/tasks/store.ts +38 -11
  153. package/examples/assistant/src/toolkit.ts +11 -7
  154. package/examples/assistant/test/commands.test.ts +53 -27
  155. package/examples/assistant/test/identity/identity.test.ts +27 -11
  156. package/examples/assistant/test/platform/tasks-file.test.ts +39 -23
  157. package/examples/assistant/test/tasks/tasks.test.ts +62 -34
  158. package/examples/assistant/test/toolkit.test.ts +104 -75
  159. package/examples/build-events/README.md +83 -0
  160. package/examples/build-events/src/Api.ts +27 -0
  161. package/examples/build-events/src/Rpc.ts +26 -0
  162. package/examples/build-events/src/builds/Builds.ts +108 -0
  163. package/examples/build-events/src/builds/events.ts +54 -0
  164. package/examples/build-events/src/builds/handlers.ts +58 -0
  165. package/examples/build-events/src/builds/log.ts +30 -0
  166. package/examples/build-events/src/builds/status.ts +28 -0
  167. package/examples/build-events/src/builds/store.ts +130 -0
  168. package/examples/build-events/src/cli.ts +18 -0
  169. package/examples/build-events/src/commands.ts +32 -0
  170. package/examples/build-events/src/deploys/Deploys.ts +44 -0
  171. package/examples/build-events/src/deploys/approvers.ts +55 -0
  172. package/examples/build-events/src/deploys/events.ts +32 -0
  173. package/examples/build-events/src/deploys/handlers.ts +38 -0
  174. package/examples/build-events/src/identity/Identity.ts +29 -0
  175. package/examples/build-events/src/identity/members.ts +7 -0
  176. package/examples/build-events/src/identity/verifier.ts +22 -0
  177. package/examples/build-events/src/notifier/Notices.ts +26 -0
  178. package/examples/build-events/src/notifier/routes.ts +71 -0
  179. package/examples/build-events/src/notifier.ts +48 -0
  180. package/examples/build-events/src/routes.ts +71 -0
  181. package/examples/build-events/src/server.ts +62 -0
  182. package/examples/build-events/test/builds/events.test.ts +68 -0
  183. package/examples/build-events/test/builds/log.test.ts +71 -0
  184. package/examples/build-events/test/builds/progress.test.ts +57 -0
  185. package/examples/build-events/test/builds/status.test.ts +42 -0
  186. package/examples/build-events/test/commands.test.ts +103 -0
  187. package/examples/build-events/test/deploys/events.test.ts +52 -0
  188. package/examples/build-events/test/http.test.ts +95 -0
  189. package/examples/build-events/test/notifier.test.ts +80 -0
  190. package/examples/build-events/test/routes.test.ts +96 -0
  191. package/examples/build-events/test/rpc.test.ts +84 -0
  192. package/examples/build-events/test/support/as.ts +8 -0
  193. package/examples/build-events/test/support/memory.ts +43 -0
  194. package/examples/build-events/test/webhooks.test.ts +143 -0
  195. package/examples/kitchen-sink/README.md +178 -166
  196. package/examples/kitchen-sink/src/Actions.ts +11 -3
  197. package/examples/kitchen-sink/src/Api.ts +12 -6
  198. package/examples/kitchen-sink/src/Rpc.ts +7 -5
  199. package/examples/kitchen-sink/src/admin/Admin.ts +21 -10
  200. package/examples/kitchen-sink/src/admin/authenticate.ts +8 -4
  201. package/examples/kitchen-sink/src/admin/authorize.ts +13 -5
  202. package/examples/kitchen-sink/src/admin/grants.ts +5 -3
  203. package/examples/kitchen-sink/src/admin/handlers.ts +9 -5
  204. package/examples/kitchen-sink/src/agent.ts +14 -6
  205. package/examples/kitchen-sink/src/assistant/Assistant.ts +21 -15
  206. package/examples/kitchen-sink/src/assistant/conversations.ts +14 -9
  207. package/examples/kitchen-sink/src/assistant/demo-model.ts +2 -2
  208. package/examples/kitchen-sink/src/assistant/handlers.ts +42 -22
  209. package/examples/kitchen-sink/src/attachments/Attachments.ts +6 -6
  210. package/examples/kitchen-sink/src/attachments/download.ts +7 -6
  211. package/examples/kitchen-sink/src/attachments/handlers.ts +9 -4
  212. package/examples/kitchen-sink/src/attachments/store.ts +25 -18
  213. package/examples/kitchen-sink/src/browser.ts +10 -4
  214. package/examples/kitchen-sink/src/cli.ts +28 -12
  215. package/examples/kitchen-sink/src/config.ts +1 -1
  216. package/examples/kitchen-sink/src/identity/Identity.ts +24 -10
  217. package/examples/kitchen-sink/src/identity/authenticate.ts +19 -11
  218. package/examples/kitchen-sink/src/identity/authorize.ts +14 -4
  219. package/examples/kitchen-sink/src/identity/directory.ts +16 -7
  220. package/examples/kitchen-sink/src/identity/handlers.ts +1 -0
  221. package/examples/kitchen-sink/src/identity/members.ts +7 -2
  222. package/examples/kitchen-sink/src/identity/quota.ts +23 -20
  223. package/examples/kitchen-sink/src/issues/Issues.ts +91 -15
  224. package/examples/kitchen-sink/src/issues/assistant.ts +4 -2
  225. package/examples/kitchen-sink/src/issues/events.ts +46 -0
  226. package/examples/kitchen-sink/src/issues/export.ts +35 -0
  227. package/examples/kitchen-sink/src/issues/guide.ts +4 -3
  228. package/examples/kitchen-sink/src/issues/handlers.ts +20 -7
  229. package/examples/kitchen-sink/src/issues/store.ts +156 -67
  230. package/examples/kitchen-sink/src/issues/write-limit.ts +44 -18
  231. package/examples/kitchen-sink/src/memory.ts +11 -2
  232. package/examples/kitchen-sink/src/ops.ts +10 -5
  233. package/examples/kitchen-sink/src/request-policy.ts +21 -9
  234. package/examples/kitchen-sink/src/routes.ts +67 -27
  235. package/examples/kitchen-sink/src/sdk.ts +22 -12
  236. package/examples/kitchen-sink/src/seed.ts +15 -9
  237. package/examples/kitchen-sink/src/server.ts +24 -10
  238. package/examples/kitchen-sink/src/stdio.ts +9 -3
  239. package/examples/kitchen-sink/src/workspaces/Workspaces.ts +10 -7
  240. package/examples/kitchen-sink/src/workspaces/handlers.ts +3 -2
  241. package/examples/kitchen-sink/src/workspaces/registry.ts +16 -10
  242. package/examples/kitchen-sink/src/workspaces/tenant.ts +12 -5
  243. package/examples/kitchen-sink/test/admin/admin.test.ts +38 -23
  244. package/examples/kitchen-sink/test/assistant/assistant.test.ts +471 -299
  245. package/examples/kitchen-sink/test/attachments/attachments.test.ts +135 -94
  246. package/examples/kitchen-sink/test/identity/identity.test.ts +110 -82
  247. package/examples/kitchen-sink/test/identity/session.test.ts +65 -43
  248. package/examples/kitchen-sink/test/issues/assistant.test.ts +50 -31
  249. package/examples/kitchen-sink/test/issues/events.test.ts +273 -0
  250. package/examples/kitchen-sink/test/issues/export.test.ts +120 -0
  251. package/examples/kitchen-sink/test/issues/issues.test.ts +105 -75
  252. package/examples/kitchen-sink/test/request-policy.test.ts +108 -47
  253. package/examples/kitchen-sink/test/routes.test.ts +102 -57
  254. package/examples/kitchen-sink/test/support/as.ts +9 -0
  255. package/examples/kitchen-sink/test/support/scripted.ts +6 -5
  256. package/examples/kitchen-sink/test/support/services.ts +17 -0
  257. package/examples/live-rpc/README.md +33 -35
  258. package/examples/live-rpc/src/Rpc.ts +9 -5
  259. package/examples/live-rpc/src/browser.ts +39 -11
  260. package/examples/live-rpc/src/chat/Chat.ts +30 -16
  261. package/examples/live-rpc/src/chat/authorize.ts +2 -1
  262. package/examples/live-rpc/src/chat/events.ts +74 -0
  263. package/examples/live-rpc/src/chat/handlers.ts +13 -5
  264. package/examples/live-rpc/src/chat/room.ts +9 -5
  265. package/examples/live-rpc/src/chat/store.ts +51 -14
  266. package/examples/live-rpc/src/client.ts +22 -5
  267. package/examples/live-rpc/src/identity/Identity.ts +3 -3
  268. package/examples/live-rpc/src/identity/verifier.ts +8 -4
  269. package/examples/live-rpc/src/page.ts +58 -12
  270. package/examples/live-rpc/src/routes.ts +14 -10
  271. package/examples/live-rpc/src/server.ts +15 -5
  272. package/examples/live-rpc/test/browser.test.ts +127 -50
  273. package/examples/live-rpc/test/chat/chat.test.ts +215 -23
  274. package/examples/live-rpc/test/routes.test.ts +130 -46
  275. package/examples/live-rpc/test/server.test.ts +94 -33
  276. package/examples/mcp-tools/README.md +24 -38
  277. package/examples/mcp-tools/src/platform/fake-farm.ts +63 -24
  278. package/examples/mcp-tools/src/platform/print-farm.ts +59 -29
  279. package/examples/mcp-tools/src/printers/Printers.ts +18 -7
  280. package/examples/mcp-tools/src/printers/farm.ts +15 -5
  281. package/examples/mcp-tools/src/printers/handlers.ts +12 -6
  282. package/examples/mcp-tools/src/routes.ts +8 -6
  283. package/examples/mcp-tools/src/server.ts +12 -5
  284. package/examples/mcp-tools/src/stdio.ts +3 -2
  285. package/examples/mcp-tools/test/printers/printers.test.ts +132 -89
  286. package/examples/mcp-tools/test/routes.test.ts +140 -81
  287. package/examples/notes-service/README.md +30 -45
  288. package/examples/notes-service/src/Api.ts +1 -0
  289. package/examples/notes-service/src/browser.ts +15 -14
  290. package/examples/notes-service/src/cli.ts +8 -3
  291. package/examples/notes-service/src/commands.ts +46 -20
  292. package/examples/notes-service/src/identity/Identity.ts +2 -2
  293. package/examples/notes-service/src/identity/authorize.ts +6 -2
  294. package/examples/notes-service/src/identity/handlers.ts +3 -2
  295. package/examples/notes-service/src/identity/users.ts +2 -1
  296. package/examples/notes-service/src/identity/verifier.ts +12 -6
  297. package/examples/notes-service/src/notes/Notes.ts +46 -22
  298. package/examples/notes-service/src/notes/handlers.ts +18 -9
  299. package/examples/notes-service/src/notes/store.ts +70 -43
  300. package/examples/notes-service/src/routes.ts +7 -5
  301. package/examples/notes-service/src/server.ts +12 -5
  302. package/examples/notes-service/src/status/Status.ts +1 -1
  303. package/examples/notes-service/src/status/handlers.ts +5 -3
  304. package/examples/notes-service/src/stdio.ts +7 -3
  305. package/examples/notes-service/src/worker.ts +6 -3
  306. package/examples/notes-service/test/commands.test.ts +164 -91
  307. package/examples/notes-service/test/notes/as.ts +3 -1
  308. package/examples/notes-service/test/notes/idempotency.test.ts +97 -67
  309. package/examples/notes-service/test/notes/in-process.test.ts +26 -15
  310. package/examples/notes-service/test/notes/notes.test.ts +49 -31
  311. package/examples/notes-service/test/routes.test.ts +106 -71
  312. package/examples/notes-service/test/worker.test.ts +21 -13
  313. package/examples/quickstart/README.md +2 -6
  314. package/examples/quickstart/src/Api.ts +1 -0
  315. package/examples/quickstart/src/client.ts +6 -3
  316. package/examples/quickstart/src/greeting/Greeting.ts +1 -1
  317. package/examples/quickstart/src/greeting/handlers.ts +5 -2
  318. package/examples/quickstart/src/routes.ts +3 -2
  319. package/examples/quickstart/src/server.ts +6 -2
  320. package/examples/quickstart/test/routes.test.ts +3 -2
  321. package/package.json +10 -4
package/docs/Testing.md CHANGED
@@ -1,25 +1,18 @@
1
1
  # Testing
2
2
 
3
- Two ways to test, and neither opens a port. `Action.client` calls an implementation in process:
4
- what its handlers and its `authorize` do. `Testing.layer` answers the real routes in memory,
5
- through Effect's own `HttpClient`: what a surface adds, such as authentication, statuses,
6
- challenges and tool results as sent. The examples put a domain's tests in `test/<domain>/` and
7
- tests of the surfaces beside them, in `it.effect` from `@effect/vitest`, which supplies a
8
- `Scope`, a `TestClock` and a `TestConsole` (both imported from `effect/testing`).
3
+ Two ways to test, and neither opens a port. `Action.client` calls an implementation in process: what its handlers and its `authorize` do. `Testing.layer` answers the real routes in memory, through Effect's own `HttpClient`: what a surface adds, such as authentication, statuses, challenges and tool results as sent. The examples put a domain's tests in `test/<domain>/` and tests of the surfaces beside them, in `it.effect` from `@effect/vitest`, which supplies a `Scope`, a `TestClock` and a `TestConsole` (both imported from `effect/testing`).
9
4
 
10
5
  A message the library throws says its fix; for a compiler diagnostic or Effect's own error, search [Testing-failures.md](Testing-failures.md). For one symbol, `grep -rn <symbol> node_modules/@gjermundgaraba/effect-actions/dist/`: each declaration's JSDoc carries its rules and gotchas, and this page only what an example cannot show.
11
6
 
12
7
  ## Test an implementation in process
13
8
 
14
- Acquire `Action.client(implementation)` and call its methods
15
- ([Action.md](Action.md#call-actions-in-process)), as
16
- [notes-service/test/notes/in-process.test.ts](../examples/notes-service/test/notes/in-process.test.ts)
17
- does. The rules are on `Action.client`.
9
+ Acquire `Action.client(implementation)` and call its methods ([Action.md](Action.md#call-actions-in-process)), as [notes-service/test/notes/in-process.test.ts](../examples/notes-service/test/notes/in-process.test.ts) does. The rules are on `Action.client`.
18
10
 
19
11
  ```ts example=notes-service/test/notes/in-process.test.ts
20
12
  import { describe, expect, it } from "@effect/vitest";
21
- import { Effect } from "effect";
22
13
  import * as Action from "@gjermundgaraba/effect-actions/Action";
14
+ import { Effect } from "effect";
15
+
23
16
  import { CurrentUser } from "../../src/identity/Identity.js";
24
17
  import { users } from "../../src/identity/users.js";
25
18
  import { notes } from "../../src/notes/handlers.js";
@@ -29,31 +22,41 @@ import { NoteStore } from "../../src/notes/store.js";
29
22
  // What the implementation does, with no transport: `Action.client` runs the same decoding,
30
23
  // `authorize` and handler a server runs. The caller is provided around each call, as a server
31
24
  // provides it per request.
32
- const as = (user: keyof typeof users) => Effect.provideService(CurrentUser, users[user]);
25
+ const as = (user: keyof typeof users) =>
26
+ Effect.provideService(CurrentUser, users[user]);
33
27
 
34
28
  describe("notes, in process", () => {
35
29
  it.effect("keeps each user's notes their own", () =>
36
30
  Effect.gen(function* () {
37
31
  const client = yield* Action.client(notes);
38
- const note = yield* client.createNote({ title: "Plan", body: "Draft" }).pipe(as("ada"));
39
32
 
40
- expect(yield* client.listNotes().pipe(as("ada"))).toEqual({
33
+ const note = yield* client
34
+ .createNote({ title: "Plan", body: "Draft" })
35
+ .pipe(as("ada"));
36
+
37
+ expect(yield* client.listNotes().pipe(as("ada"))).toStrictEqual({
41
38
  notes: [{ id: note.id, title: "Plan" }],
42
39
  });
43
- expect(yield* client.listNotes().pipe(as("grace"))).toEqual({ notes: [] });
40
+ expect(yield* client.listNotes().pipe(as("grace"))).toStrictEqual({
41
+ notes: [],
42
+ });
44
43
 
45
44
  const missing = new NoteNotFound({ id: note.id });
46
45
  const update = { id: note.id, body: "Mine now", revision: note.revision };
47
46
 
48
- expect(yield* Effect.flip(client.getNote({ id: note.id }).pipe(as("grace")))).toEqual(
49
- missing,
50
- );
51
- expect(yield* Effect.flip(client.updateNote(update).pipe(as("grace")))).toEqual(missing);
52
- expect(yield* Effect.flip(client.deleteNote({ id: note.id }).pipe(as("grace")))).toEqual(
53
- missing,
54
- );
55
- expect(yield* client.getNote({ id: note.id }).pipe(as("ada"))).toEqual(note);
56
- }).pipe(Effect.provide(NoteStore.layerMemory)),
47
+ expect(
48
+ yield* Effect.flip(client.getNote({ id: note.id }).pipe(as("grace")))
49
+ ).toStrictEqual(missing);
50
+ expect(
51
+ yield* Effect.flip(client.updateNote(update).pipe(as("grace")))
52
+ ).toStrictEqual(missing);
53
+ expect(
54
+ yield* Effect.flip(client.deleteNote({ id: note.id }).pipe(as("grace")))
55
+ ).toStrictEqual(missing);
56
+ expect(
57
+ yield* client.getNote({ id: note.id }).pipe(as("ada"))
58
+ ).toStrictEqual(note);
59
+ }).pipe(Effect.provide(NoteStore.layerMemory))
57
60
  );
58
61
 
59
62
  it.effect("refuses a write from a caller holding the read scope alone", () =>
@@ -64,36 +67,29 @@ describe("notes, in process", () => {
64
67
 
65
68
  expect(refusal).toBeInstanceOf(Action.Forbidden);
66
69
  expect(refusal.message).toBe("Requires notes:write.");
67
- }).pipe(Effect.provide(NoteStore.layerMemory)),
70
+ }).pipe(Effect.provide(NoteStore.layerMemory))
68
71
  );
69
72
  });
70
73
  ```
71
74
 
72
- - Replace the edge, not the service under test:
73
- [mcp-tools/test/printers/printers.test.ts](../examples/mcp-tools/test/printers/printers.test.ts)
74
- runs the real upstream service over a fake `HttpClient` Layer.
75
+ - Replace the edge, not the service under test: [mcp-tools/test/printers/printers.test.ts](../examples/mcp-tools/test/printers/printers.test.ts) runs the real upstream service over a fake `HttpClient` Layer.
75
76
  - `it.effect` runs on a test clock: fork a call that waits, `TestClock.adjust`, then join it.
76
- - A stdio server runs the same implementations: test them here. A subprocess test adds only what
77
- the process adds, stdout carrying nothing but the protocol.
78
- - A Toolkit and a CLI need no `Testing` export: call `handle` on `yield* toolkit`, or script a
79
- `LanguageModel` ([assistant/test/toolkit.test.ts](../examples/assistant/test/toolkit.test.ts)),
80
- and run commands without a process
81
- ([ActionCli.md](ActionCli.md#run-actions-as-local-commands)).
77
+ - A stdio server runs the same implementations: test them here. A subprocess test adds only what the process adds, stdout carrying nothing but the protocol.
78
+ - A Toolkit and a CLI need no `Testing` export: call `handle` on `yield* toolkit`, or script a `LanguageModel` ([assistant/test/toolkit.test.ts](../examples/assistant/test/toolkit.test.ts)), and run commands without a process ([ActionCli.md](ActionCli.md#run-actions-as-local-commands)).
82
79
 
83
80
  ## Test routes in memory
84
81
 
85
- Export the routes still requiring what an entrypoint chooses, give the test its own services, and
86
- provide `Testing.layer(routes)` to each test. What it serves, the requests it reaches, a remote
87
- `ActionCli` command's included, and what it cannot do are on `Testing.layer`.
82
+ Export the routes still requiring what an entrypoint chooses, give the test its own services, and provide `Testing.layer(routes)` to each test. What it serves, the requests it reaches, a remote `ActionCli` command's included, and what it cannot do are on `Testing.layer`.
88
83
 
89
84
  The smallest test:
90
85
 
91
86
  ```ts example=quickstart/test/routes.test.ts
92
87
  import { expect, it } from "@effect/vitest";
93
- import { Effect } from "effect";
94
88
  import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
95
89
  import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
96
90
  import * as Testing from "@gjermundgaraba/effect-actions/Testing";
91
+ import { Effect } from "effect";
92
+
97
93
  import { Http } from "../src/Api.js";
98
94
  import { Greet } from "../src/greeting/Greeting.js";
99
95
  import { routes } from "../src/routes.js";
@@ -106,72 +102,116 @@ it.effect("greets over HTTP and as an MCP tool", () =>
106
102
 
107
103
  expect(yield* http.greet({ name: "Ada" })).toBe("Hello, Ada!");
108
104
  expect(yield* mcp.greet({ name: "Ada" })).toBe("Hello, Ada!");
109
- }).pipe(Effect.provide(Testing.layer(routes))),
105
+ }).pipe(Effect.provide(Testing.layer(routes)))
110
106
  );
111
107
  ```
112
108
 
113
- [notes-service/test/routes.test.ts](../examples/notes-service/test/routes.test.ts) provides a
114
- store a builder yields, calls the host's own routes with `HttpClient.get("/healthz")` and signs
115
- callers in.
109
+ [notes-service/test/routes.test.ts](../examples/notes-service/test/routes.test.ts) provides a store a builder yields, calls the host's own routes with `HttpClient.get("/healthz")` and signs callers in.
116
110
 
117
- - A service the test reads too:
118
- [kitchen-sink/test/identity/session.test.ts](../examples/kitchen-sink/test/identity/session.test.ts).
119
- - Several `layer`s sharing one build:
120
- [kitchen-sink/test/routes.test.ts](../examples/kitchen-sink/test/routes.test.ts)
121
- ([Action.md](Action.md#share-one-build-across-servers)).
111
+ - A service the test reads too: [kitchen-sink/test/identity/session.test.ts](../examples/kitchen-sink/test/identity/session.test.ts).
112
+ - Several `layer`s sharing one build: [kitchen-sink/test/routes.test.ts](../examples/kitchen-sink/test/routes.test.ts) ([Action.md](Action.md#share-one-build-across-servers)).
122
113
  - A fake upstream: [mcp-tools/test/routes.test.ts](../examples/mcp-tools/test/routes.test.ts).
123
- - Raw web `Request`s beside the typed clients, through one web handler the test disposes of,
124
- given to `Testing.layer(handler)`:
125
- [kitchen-sink/test/request-policy.test.ts](../examples/kitchen-sink/test/request-policy.test.ts).
114
+ - Raw web `Request`s beside the typed clients, through one web handler the test disposes of, given to `Testing.layer(handler)`: [kitchen-sink/test/request-policy.test.ts](../examples/kitchen-sink/test/request-policy.test.ts).
126
115
 
127
116
  ## Test as a signed-in caller
128
117
 
129
- Serve the routes behind their provider
130
- ([Authentication.md](Authentication.md#verify-credentials)), and give each client its caller's
131
- credential in `transformClient`, as `as` in
132
- [notes-service/test/routes.test.ts](../examples/notes-service/test/routes.test.ts) does for
133
- `ActionHttp.client` and `ActionMcp.client` alike.
118
+ Serve the routes behind their provider ([Authentication.md](Authentication.md#verify-credentials)), and give each client its caller's credential in `transformClient`, as `as` in [notes-service/test/routes.test.ts](../examples/notes-service/test/routes.test.ts) does for `ActionHttp.client` and `ActionMcp.client` alike. An OAuth access token is such a credential: signing in, or again for more scopes, is the MCP client's part, so an application's tests need no MCP client package.
134
119
 
135
- To change what a credential verifies as, replace the service the verifier reads (`servedWith` in
136
- [kitchen-sink/test/identity/identity.test.ts](../examples/kitchen-sink/test/identity/identity.test.ts)),
137
- or provide a test verifier: another `Authentication.layer` of the same descriptor.
120
+ To change what a credential verifies as, replace the service the verifier reads (`servedWith` in [kitchen-sink/test/identity/identity.test.ts](../examples/kitchen-sink/test/identity/identity.test.ts)), or provide a test verifier: another `Authentication.layer` of the same descriptor.
138
121
 
139
- A typed client decodes a refusal to its error. To assert on the answer as sent, its status and
140
- its `www-authenticate` challenge, send the request with `HttpClient.execute`, as that file does;
141
- a tool call's request is built with `ActionMcp.request` ([Test MCP tools](#test-mcp-tools)).
142
- The status and body of each case are on `ActionHttp.layer` and on each built-in error.
122
+ A typed client decodes a refusal to its error. To assert on the answer as sent, its status and its `www-authenticate` challenge, send the request with `HttpClient.execute`, as that file does; a tool call's request is built with `ActionMcp.request` ([Test MCP tools](#test-mcp-tools)). The status and body of each case are on `ActionHttp.layer` and on each built-in error.
143
123
 
144
124
  ## Test MCP tools
145
125
 
146
- `ActionMcp.client(actions, options?)` gives one method per action, calling its tool. What it
147
- decodes and how it fails are on `ActionMcp.client` and `McpCallError`
148
- ([ActionMcp-failures.md](ActionMcp-failures.md)).
126
+ `ActionMcp.client(actions, options?)` gives one method per action, calling its tool. What it decodes and how it fails are on `ActionMcp.client` and `McpCallError` ([ActionMcp-failures.md](ActionMcp-failures.md)).
149
127
 
150
- For what the client's types will not send or its decoding hides, build the request with
151
- `ActionMcp.request`, which says how to send it:
152
- [kitchen-sink/test/identity/identity.test.ts](../examples/kitchen-sink/test/identity/identity.test.ts)
153
- asserts a 401, and
154
- [mcp-tools/test/routes.test.ts](../examples/mcp-tools/test/routes.test.ts) reads `tools/list`.
128
+ For what the client's types will not send or its decoding hides, build the request with `ActionMcp.request`, which says how to send it: [kitchen-sink/test/identity/identity.test.ts](../examples/kitchen-sink/test/identity/identity.test.ts) asserts a 401, and [mcp-tools/test/routes.test.ts](../examples/mcp-tools/test/routes.test.ts) reads `tools/list`.
155
129
 
156
130
  ## Test rpcs
157
131
 
158
- Export the rpc layer without its protocol ([ActionRpc.md](ActionRpc.md#serve-the-rpcs)) and
159
- serve it under `Testing.layer`, as
160
- [live-rpc/test/routes.test.ts](../examples/live-rpc/test/routes.test.ts) does; the protocol each
161
- side takes is on `Testing.layer`.
162
- [live-rpc/test/browser.test.ts](../examples/live-rpc/test/browser.test.ts) tests a page's client
163
- the same way, and
164
- [kitchen-sink/test/identity/session.test.ts](../examples/kitchen-sink/test/identity/session.test.ts)
165
- serves JSON routes and rpcs in one `Testing.layer`.
132
+ Export the rpc layer without its protocol ([ActionRpc.md](ActionRpc.md#serve-the-rpcs)) and serve it under `Testing.layer`, as [live-rpc/test/routes.test.ts](../examples/live-rpc/test/routes.test.ts) does; the protocol each side takes is on `Testing.layer`. [live-rpc/test/browser.test.ts](../examples/live-rpc/test/browser.test.ts) tests a page's client the same way, and [kitchen-sink/test/identity/session.test.ts](../examples/kitchen-sink/test/identity/session.test.ts) serves JSON routes and rpcs in one `Testing.layer`.
133
+
134
+ ## Test events
135
+
136
+ Watch an event with the client of the implementations, as here, or of the routes under `Testing.layer`, giving each test its own `Event.layerMemory`:
137
+
138
+ ```ts example=build-events/test/builds/events.test.ts
139
+ import { expect, it } from "@effect/vitest";
140
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
141
+ import * as Event from "@gjermundgaraba/effect-actions/Event";
142
+ import { Effect, Fiber, Stream } from "effect";
143
+ import { TestClock } from "effect/testing";
144
+
145
+ import { BuildFinished } from "../../src/builds/Builds.js";
146
+ import { buildEvents } from "../../src/builds/events.js";
147
+ import { builds } from "../../src/builds/handlers.js";
148
+ import { BuildStore } from "../../src/builds/store.js";
149
+ import { as } from "../support/as.js";
150
+
151
+ it.effect(
152
+ "delivers each build a member may see as it finishes, as the subscription selects",
153
+ () =>
154
+ Effect.gen(function* () {
155
+ // One client holds both: the action that finishes builds and the event that reports them.
156
+ const client = yield* Action.client([builds, buildEvents]);
157
+
158
+ const failed = yield* client
159
+ .watch(BuildFinished, { status: "failed" })
160
+ .pipe(Stream.take(1), Stream.runCollect, as("grace"), Effect.forkChild);
161
+
162
+ // The watch starts from now: let it take its first position before builds finish.
163
+ yield* TestClock.adjust("0 millis");
164
+ yield* client
165
+ .finishBuild({ pipeline: "api", status: "failed" })
166
+ .pipe(as("ada"));
167
+ yield* client
168
+ .finishBuild({ pipeline: "web", status: "passed" })
169
+ .pipe(as("ada"));
170
+ // Each build finished notifies: the watch reads at once, with no wait for its next poll.
171
+ yield* client
172
+ .finishBuild({ pipeline: "web", status: "failed" })
173
+ .pipe(as("ada"));
174
+
175
+ // grace sees only the web pipeline, and asked only for failures.
176
+ expect(yield* Fiber.join(failed)).toMatchObject([
177
+ {
178
+ _tag: "Occurrence",
179
+ data: { id: "build-3", pipeline: "web", status: "failed" },
180
+ },
181
+ ]);
182
+ }).pipe(Effect.provide([BuildStore.layerMemory, Event.layerMemory]))
183
+ );
184
+
185
+ it.effect(
186
+ "sends a gap first to a subscriber resuming from a cursor the store never gave",
187
+ () =>
188
+ Effect.gen(function* () {
189
+ const client = yield* Action.client([builds, buildEvents]);
190
+
191
+ yield* client
192
+ .finishBuild({ pipeline: "web", status: "passed" })
193
+ .pipe(as("ada"));
194
+
195
+ // A cursor of the store before a restart, its offset one this store has too: what it named
196
+ // is gone, so the watch is told.
197
+ const resumed = yield* client
198
+ .watch(BuildFinished, {}, { cursor: "an-earlier-epoch:0" })
199
+ .pipe(Stream.take(2), Stream.runCollect, as("grace"));
200
+
201
+ expect(resumed).toMatchObject([
202
+ { _tag: "Gap" },
203
+ { _tag: "Occurrence", data: { id: "build-1", pipeline: "web" } },
204
+ ]);
205
+ }).pipe(Effect.provide([BuildStore.layerMemory, Event.layerMemory]))
206
+ );
207
+ ```
208
+
209
+ A watch forked before what it should see starts from now, so let it take its first position before acting, with `TestClock.adjust("0 millis")`. An announcement, `notify` or `publish`, is read at once; without one, a check source is asked again on its own schedule, every 5 seconds unless it says otherwise, which `TestClock.adjust` moves past. Move the clock only past what the test waits for: a webhook's POST still in flight times out after 5 seconds of it.
210
+
211
+ Webhook delivery needs an `HttpClient` answering for the subscriber and a `Dns` resolving its callback to a public address, as [build-events/test/support/memory.ts](../examples/build-events/test/support/memory.ts) provides: `Testing.layer` of the subscriber's routes or of a handler, and `Dns.layerStatic`. A subscription made through requests in memory runs on promises the test clock does not drive, so wait for it as that file's `settled` does.
166
212
 
167
213
  ## Assert on errors
168
214
 
169
- Take the failure with `Effect.flip(call)`, which alone asserts only that the call failed, then
170
- assert its class and its `message` separately, as the last test of
171
- [notes-service/test/notes/in-process.test.ts](../examples/notes-service/test/notes/in-process.test.ts) does.
215
+ Take the failure with `Effect.flip(call)`, which alone asserts only that the call failed, then assert its class and its `message` separately, as the last test of [notes-service/test/notes/in-process.test.ts](../examples/notes-service/test/notes/in-process.test.ts) does.
172
216
 
173
- `toEqual` never compares a tagged error's `message`, which is not an enumerable property:
174
- `expect(error).toEqual(new Action.Forbidden({ message: "anything" }))` passes for every message,
175
- and so does `toStrictEqual`. Other fields are compared, so `toEqual(new NoteNotFound({ id }))`
176
- asserts the `id`, and `toEqual(new Action.Forbidden({ scopes: ["issues:write"] }))` still needs the
177
- message asserted beside it.
217
+ `toEqual` never compares a tagged error's `message`, which is not an enumerable property: `expect(error).toEqual(new Action.Forbidden({ message: "anything" }))` passes for every message, and so does `toStrictEqual`. Other fields are compared, so `toEqual(new NoteNotFound({ id }))` asserts the `id`, and `toEqual(new Action.Forbidden({ scopes: ["issues:write"] }))` still needs the message asserted beside it.
@@ -0,0 +1,8 @@
1
+ # Webhook failure modes
2
+
3
+ What you see when something is wrong, and the fix. Search for a fragment of the message. The tasks are in [Webhook.md](Webhook.md).
4
+
5
+ ## At startup
6
+
7
+ - `Type 'Dns | HttpClient' is not assignable to type 'never'`, or `Type 'Dns' is not assignable to type 'never'`, at `Layer.launch` or `it.effect`: `Webhook.layer` needs an `HttpClient` to POST and `Dns` to check callbacks. Provide `FetchHttpClient.layer` and `NodeDns.layer` where the server starts, or in a test `Testing.layer(receiver)` and `Dns.layerStatic`.
8
+ - `Type 'Receiving' is not assignable to type 'never'`, where a watch given `mode: Webhook.delivery(...)` runs: the watch requires this process's receiver. Provide `Webhook.layerReceiver({ path })` to it, the layer that also serves the route (`Layer.provideMerge`).
@@ -0,0 +1,319 @@
1
+ # Webhook
2
+
3
+ `Webhook.layer` adds webhook delivery to the MCP endpoints and HTTP routes that serve [events](Event.md): a subscriber names a callback URL and a secret, the server checks that the callback asked for them, and POSTs each occurrence the subscriber may see there, signed, until the subscription lapses, is unsubscribed or is refused. A subscriber keeps it by subscribing again before the time each answer gives. It follows SEP-3415's webhook delivery, so it is experimental, as [events](Event.md) are.
4
+
5
+ A message the library throws says its fix; for a compiler diagnostic or Effect's own error, search [Webhook-failures.md](Webhook-failures.md). For one symbol, `grep -rn <symbol> node_modules/@gjermundgaraba/effect-actions/dist/`: each declaration's JSDoc carries its rules and gotchas, and this page only what an example cannot show.
6
+
7
+ ## Deliver events by webhook
8
+
9
+ Provide `Webhook.layer()` to the routes. One layer holds the subscriptions of every surface it is provided to, so a subscription made over MCP is the one HTTP refreshes. Which events it offers is its first gotcha.
10
+
11
+ ```ts example=build-events/src/routes.ts
12
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
13
+ import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
14
+ import * as ActionRpc from "@gjermundgaraba/effect-actions/ActionRpc";
15
+ import * as Webhook from "@gjermundgaraba/effect-actions/Webhook";
16
+ import { Layer } from "effect";
17
+ import { RpcSerialization, RpcServer } from "effect/rpc";
18
+
19
+ import { Http } from "./Api.js";
20
+ import { buildEvents } from "./builds/events.js";
21
+ import { builds } from "./builds/handlers.js";
22
+ import { logs } from "./builds/log.js";
23
+ import { statusEvents } from "./builds/status.js";
24
+ import { deployEvents } from "./deploys/events.js";
25
+ import { deploys } from "./deploys/handlers.js";
26
+ import { Login } from "./identity/Identity.js";
27
+ import { authenticate } from "./identity/verifier.js";
28
+ import { Rpc } from "./Rpc.js";
29
+
30
+ // Every domain's implementation, once: the events beside the actions, in one list.
31
+ const implementations = [
32
+ builds,
33
+ logs,
34
+ buildEvents,
35
+ statusEvents,
36
+ deploys,
37
+ deployEvents,
38
+ ];
39
+
40
+ // The actions as MCP tools and the events as MCP events, at `/mcp`. A client polls with
41
+ // `events/poll`, or holds `events/stream` open and is pushed each occurrence. Both events are
42
+ // protected, so each request needs a member's token, and each subscriber sees what its token
43
+ // lets it. This long-lived process holds the deploy event's `subscription` hook between polls and
44
+ // its log in memory, and pushes each occurrence: a `requestScoped` endpoint would serve the events
45
+ // by poll and webhook alone, and a serverless host, where no process lasts, check sources alone,
46
+ // without `Webhook.layer`.
47
+ //
48
+ // A tool answers one result, so `tailLog` would collect a whole log before answering, all of it
49
+ // in a model's context: its implementation, `logs`, is left out, and the endpoint serves every
50
+ // other action and every event.
51
+ const mcp = ActionMcp.layerHttp(
52
+ [builds, buildEvents, statusEvents, deploys, deployEvents],
53
+ {
54
+ name: "build-events",
55
+ version: "1.0.0",
56
+ authentication: Login,
57
+ }
58
+ );
59
+
60
+ // The same implementations as routes under `/api`, for a client that speaks HTTP rather than
61
+ // MCP: one implementation of each event serves every surface.
62
+ const api = ActionHttp.layer(Http, implementations);
63
+
64
+ // And as Effect RPC at `/rpc`, over HTTP. A watch is a stream rpc that never ends, so the
65
+ // serialization frames each element as it is sent: `layerNdjson` does, where `layerJson` would
66
+ // answer once every stream of the request ended, which a watch never does.
67
+ const rpc = ActionRpc.layer(Rpc, implementations).pipe(
68
+ Layer.provide(RpcServer.layerProtocolHttp({ path: "/rpc" })),
69
+ Layer.provide(RpcSerialization.layerNdjson)
70
+ );
71
+
72
+ // Webhook delivery for MCP and HTTP alike: a member names a callback URL and a secret, with
73
+ // MCP's `events/subscribe` or `POST /api/events/<name>/subscribe`, and each occurrence it may see
74
+ // is POSTed there, signed. One layer holds the subscriptions of both, so either surface refreshes
75
+ // one made over the other. `persist` keeps each in a `KeyValueStore`, so a restart goes on
76
+ // delivering from where it reached, as the subscriber `Login`'s `schema` keeps. It needs an
77
+ // `HttpClient` to POST, `Dns` to check where each callback resolves, and the store, which
78
+ // `server.ts` provides.
79
+ export const routes = Layer.mergeAll(mcp, api, rpc).pipe(
80
+ Layer.provide(authenticate),
81
+ Layer.provide(Webhook.layer({ persist: true }))
82
+ );
83
+ ```
84
+
85
+ The layer needs an `HttpClient` to POST, `Dns` to check where each callback resolves, unless `resolveCallbacks` is `false`, and, with `persist`, a `KeyValueStore`: the entrypoint, [build-events/src/server.ts](../examples/build-events/src/server.ts), provides all three.
86
+
87
+ Its options, how long a subscription is granted, which callbacks skip the handshake and the address check, and how long a failing delivery is retried, are `Webhook.Options`; what each POST holds is on `layer`.
88
+
89
+ - **Which deployment.** Subscriptions are delivered from the server's memory, so a long-lived process serves them and a serverless host cannot; a Cloudflare Durable Object can, with `@gjermundgaraba/effect-actions-cloudflare`'s `ActionDurableObject.layerWebhooks`. A restart drops them until each subscriber's next refresh, unless the layer keeps them: `Webhook.layer({ persist: true })` writes each to the application's `KeyValueStore`, and each surface restores its own as it builds, the subscriber's identity kept by its authentication's `schema`, as `server.ts` and [build-events/src/identity/Identity.ts](../examples/build-events/src/identity/Identity.ts) show. What a restored delivery lacks is on `Webhook.Options.persist`. The layer is its store's one writer: give each process a store of its own, since replicas sharing one would each deliver every subscription and lose each other's writes.
90
+
91
+ ## Subscribe a callback
92
+
93
+ Over MCP a subscriber sends SEP-3415's `events/subscribe` and `events/unsubscribe`; over HTTP it POSTs to the binding's `<prefix>/events/<name>/subscribe` and `/unsubscribe`, as below, whose OpenAPI document describes each field. A subscription is one per subscriber, event, input and URL: subscribing again refreshes it, and a new secret replaces the old one, as `Webhook.layer` says.
94
+
95
+ ```ts example=build-events/test/webhooks.test.ts
96
+ import { describe, expect, it } from "@effect/vitest";
97
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
98
+ import * as Testing from "@gjermundgaraba/effect-actions/Testing";
99
+ import { Effect, Layer, Queue, Schema } from "effect";
100
+ import { HttpClient, HttpClientRequest } from "effect/http";
101
+
102
+ import { Http } from "../src/Api.js";
103
+ import { routes } from "../src/routes.js";
104
+ import { inMemory } from "./support/memory.js";
105
+
106
+ // The subscriber's secret: `whsec_` and 24 to 64 random bytes in base64. The server signs each
107
+ // POST with it, and the subscriber checks each POST it receives against it.
108
+ const secret = `whsec_${Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64")}`;
109
+
110
+ // A subscriber's check, as Standard Webhooks defines it: an HMAC-SHA256 of the id, the
111
+ // timestamp and the body exactly as received, among the space-separated `v1,` signatures.
112
+ const signed = async (request: Request, body: string) => {
113
+ const key = await crypto.subtle.importKey(
114
+ "raw",
115
+ Buffer.from(secret.slice("whsec_".length), "base64"),
116
+ { name: "HMAC", hash: "SHA-256" },
117
+ false,
118
+ ["sign"]
119
+ );
120
+
121
+ const signedContent = [
122
+ request.headers.get("webhook-id"),
123
+ request.headers.get("webhook-timestamp"),
124
+ body,
125
+ ].join(".");
126
+
127
+ const expected = Buffer.from(
128
+ await crypto.subtle.sign(
129
+ "HMAC",
130
+ key,
131
+ new TextEncoder().encode(signedContent)
132
+ )
133
+ ).toString("base64");
134
+
135
+ return (request.headers.get("webhook-signature") ?? "")
136
+ .split(" ")
137
+ .includes(`v1,${expected}`);
138
+ };
139
+
140
+ const Delivered = Schema.fromJsonString(
141
+ Schema.Record(Schema.String, Schema.Json)
142
+ );
143
+
144
+ interface Received {
145
+ readonly body: typeof Delivered.Type;
146
+ readonly signed: boolean;
147
+ }
148
+
149
+ // grace's server: it answers the verification POST by echoing its challenge, which proves it
150
+ // asked for these deliveries, and acknowledges each delivery with a 2xx.
151
+ const subscriber =
152
+ (received: Queue.Enqueue<Received>) => async (request: Request) => {
153
+ const text = await request.text();
154
+ const body = Schema.decodeSync(Delivered)(text);
155
+
156
+ Queue.offerUnsafe(received, { body, signed: await signed(request, text) });
157
+
158
+ return body["type"] === "verification"
159
+ ? Response.json({ challenge: body["challenge"] })
160
+ : new Response(null);
161
+ };
162
+
163
+ const served = (received: Queue.Enqueue<Received>) =>
164
+ Testing.layer(routes).pipe(
165
+ Layer.provide(inMemory(Testing.layer(subscriber(received))))
166
+ );
167
+
168
+ const Subscribed = Schema.Struct({
169
+ id: Schema.String,
170
+ refreshBefore: Schema.NullOr(Schema.String),
171
+ deliveryStatus: Schema.optional(
172
+ Schema.Struct({
173
+ active: Schema.Boolean,
174
+ lastError: Schema.NullOr(Schema.String),
175
+ })
176
+ ),
177
+ });
178
+
179
+ const subscribe = Effect.flatMap(HttpClient.HttpClient, (client) =>
180
+ client.execute(
181
+ HttpClientRequest.post("/api/events/build.finished/subscribe").pipe(
182
+ HttpClientRequest.bearerToken("grace"),
183
+ HttpClientRequest.bodyJsonUnsafe({
184
+ input: { status: "passed" },
185
+ delivery: { url: "https://hooks.example.com/grace", secret },
186
+ })
187
+ )
188
+ )
189
+ ).pipe(
190
+ Effect.flatMap((response) => response.json),
191
+ Effect.flatMap(Schema.decodeUnknownEffect(Subscribed))
192
+ );
193
+
194
+ describe("the service, by webhook", () => {
195
+ it.effect("POSTs each build a member may see to its callback, signed", () =>
196
+ Effect.flatMap(Queue.unbounded<Received>(), (received) =>
197
+ Effect.gen(function* () {
198
+ const ada = yield* ActionHttp.client(Http, {
199
+ transformClient: HttpClient.mapRequest(
200
+ HttpClientRequest.bearerToken("ada")
201
+ ),
202
+ });
203
+
204
+ // The answer names the subscription, which each POST's `x-mcp-subscription-id`
205
+ // carries, and when to subscribe again to keep it: one not refreshed by then ends.
206
+ const subscription = yield* subscribe;
207
+ const verification = yield* Queue.take(received);
208
+
209
+ yield* ada.finishBuild({ pipeline: "api", status: "passed" });
210
+ // Each build finished notifies `build.finished`: the server asks the store at once, and
211
+ // POSTs what it answers.
212
+ yield* ada.finishBuild({ pipeline: "web", status: "passed" });
213
+
214
+ // grace sees only the web pipeline.
215
+ const delivery = yield* Queue.take(received);
216
+
217
+ // The same request again refreshes the subscription, and answers how it is going.
218
+ const refreshed = yield* subscribe;
219
+
220
+ expect(verification).toMatchObject({
221
+ body: { type: "verification" },
222
+ signed: true,
223
+ });
224
+ expect(delivery).toMatchObject({
225
+ body: {
226
+ name: "build.finished",
227
+ data: { id: "build-2", pipeline: "web" },
228
+ },
229
+ signed: true,
230
+ });
231
+ expect(refreshed).toMatchObject({
232
+ id: subscription.id,
233
+ deliveryStatus: { active: true, lastError: null },
234
+ });
235
+ }).pipe(Effect.provide(served(received)))
236
+ )
237
+ );
238
+ });
239
+ ```
240
+
241
+ ## Watch by webhook
242
+
243
+ A client of an MCP endpoint or of HTTP routes watches by webhook with `mode: Webhook.delivery({ baseUrl })`: the watch keeps a place at `Webhook.layerReceiver`, a route this process serves, subscribes that route's path under `baseUrl` with the place's id and secret, reads what the server POSTs there, refreshes the subscription while it is read, and unsubscribes when it ends. Such a watch requires the receiver in its type; it is built once, the watch given it beside the routes (`Layer.provideMerge`). It is served by the process that watches: behind an edge, route its path there, and where nothing can reach the process, watch by stream or poll instead.
244
+
245
+ ```ts example=build-events/src/notifier/routes.ts
246
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
247
+ import * as Webhook from "@gjermundgaraba/effect-actions/Webhook";
248
+ import { Effect, Layer, Predicate, Stream } from "effect";
249
+ import type { Redacted } from "effect";
250
+ import { HttpClient, HttpClientRequest } from "effect/http";
251
+ import { KeyValueStore } from "effect/persistence";
252
+
253
+ import { Http } from "../Api.js";
254
+ import { BuildFinished } from "../builds/Builds.js";
255
+ import { Notices } from "./Notices.js";
256
+
257
+ /** Where build-events is, who the notifier signs in as there, and where it is reached itself. */
258
+ export interface NotifierOptions {
259
+ readonly buildEvents: string;
260
+ readonly token: string | Redacted.Redacted;
261
+ /** This process's public https origin, which build-events POSTs to. */
262
+ readonly publicUrl: string;
263
+ }
264
+
265
+ // The route build-events POSTs each failed build to, where the watch below reads them: the
266
+ // watch's delivery appends its path to this process's public URL.
267
+ const receiver = Webhook.layerReceiver({ path: "/hooks/builds" });
268
+
269
+ // A second service, the notifier: it watches build.finished for failures by webhook, the
270
+ // server POSTing each to it, rather than holding a stream open to build-events.
271
+ const watcher = ({ buildEvents, token, publicUrl }: NotifierOptions) =>
272
+ Layer.effectDiscard(
273
+ Effect.gen(function* () {
274
+ const notices = yield* Notices;
275
+
276
+ const builds = yield* ActionHttp.client(Http, {
277
+ baseUrl: buildEvents,
278
+ transformClient: HttpClient.mapRequest(
279
+ HttpClientRequest.bearerToken(token)
280
+ ),
281
+ });
282
+
283
+ yield* Effect.forkScoped(
284
+ builds
285
+ .watch(
286
+ BuildFinished,
287
+ { status: "failed" },
288
+ {
289
+ mode: Webhook.delivery({ baseUrl: publicUrl }),
290
+ // Where it reached is kept, so a restart resumes there; failures the server no
291
+ // longer keeps arrive as a Gap instead.
292
+ resume: {
293
+ store: yield* KeyValueStore.KeyValueStore,
294
+ key: "failed-builds",
295
+ },
296
+ }
297
+ )
298
+ .pipe(
299
+ // A Gap says failures were missed, which no notice can name: it is logged, for an
300
+ // operator to look the builds up.
301
+ Stream.runForEach((received) =>
302
+ Predicate.isTagged(received, "Occurrence")
303
+ ? notices.add(received.data)
304
+ : Effect.logWarning("Failed builds were missed", received)
305
+ ),
306
+ // A failure no reconnect outlasts, such as a wrong PUBLIC_URL or a revoked token,
307
+ // ends the watch: it is logged here, for a deployment to alert on or restart by.
308
+ Effect.tapCause(Effect.logError)
309
+ )
310
+ );
311
+ })
312
+ );
313
+
314
+ /** The notifier's routes: the receiver, and the watch reading from that same receiver. */
315
+ export const notifier = (options: NotifierOptions) =>
316
+ watcher(options).pipe(Layer.provideMerge(receiver));
317
+ ```
318
+
319
+ [build-events/test/notifier.test.ts](../examples/build-events/test/notifier.test.ts) runs both services in memory. What the receiver accepts is on `layerReceiver`, and what a watch needs on `delivery`.
@@ -1,7 +1,6 @@
1
1
  # Setup failure modes
2
2
 
3
- What you see when something is wrong, and the fix. Search for a fragment of the message.
4
- The tasks are in [setup.md](setup.md).
3
+ What you see when something is wrong, and the fix. Search for a fragment of the message. The tasks are in [setup.md](setup.md).
5
4
 
6
5
  - `TS1149: File name '…/assistant.ts' differs from already included file name '…/Assistant.ts' only in casing`: a lowercase file is named for its domain beside the domain's contract. Name lowercase files for their role, such as `handlers.ts` or `turns.ts`: on a disk that ignores case the two are one file.
7
6
  - `Cannot find module '@gjermundgaraba/effect-actions'`: there is no root export. Import a subpath from [Entry points](setup.md#entry-points), never the root or a `dist/` path.
@@ -9,5 +8,5 @@ The tasks are in [setup.md](setup.md).
9
8
  - A page that loads Effect from an import map still bundles a second copy of Effect or of some of its modules, or fails to resolve `effect/Schema` in the browser when its bundler externalizes subpaths, as esbuild's `--external:effect` does: a module of the page imports an Effect specifier the map does not serve. Serve the specifiers [Bundle for a browser](setup.md#bundle-for-a-browser) lists, and import Effect through them in the page's own modules too.
10
9
  - A page loading Effect from an import map fails with `Failed to resolve module specifier "effect/Cause"` in Chromium: the map points at Effect's published files and lacks a specifier Effect imports itself. Map `effect/Cause`, `effect/Effect`, `effect/Exit` and `effect/Function` to the same files, or serve Effect from a build that resolves its own imports.
11
10
  - A page fails at load with `does not provide an export named …`: it serves only the names it uses of an Effect specifier, from a vendored module or a trimmed bundle, and the package imports another. Serve each specifier whole.
12
- - Type errors inside `effect/*` modules after install, an unmet peer warning for `effect`, such as `found 4.1.0`, or a start failing with `Cannot find module '…/effect/dist/…'` under an `@effect/*` package: more than one Effect release is installed, or one outside the peer range. Install the `effect` range [Install](setup.md#install) names, with every `@effect/*` package at the version `effect` resolved to, such as `@effect/platform-node@4.0.4` beside `effect@4.0.4`.
11
+ - Type errors inside `effect/*` modules after install, an unmet peer warning for `effect`, such as `found 4.1.0`, or a start failing with `Cannot find module '…/effect/dist/…'` under an `@effect/*` package: more than one Effect release is installed, or one outside the peer range. Install the `effect` range [Install](setup.md#install) names, with every `@effect/*` package at the version `effect` resolved to, such as `@effect/platform-node@4.0.5` beside `effect@4.0.5`.
13
12
  - `Cannot find module 'effect/unstable/http'` (or `…/httpapi`, `…/cli`, `…/ai`): these modules are now `effect/http`, `effect/http-api`, `effect/cli` and `effect/ai`.