@gjermundgaraba/effect-actions 0.19.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 (326) hide show
  1. package/README.md +19 -41
  2. package/dist/Action.d.mts +140 -48
  3. package/dist/Action.mjs +88 -44
  4. package/dist/ActionCli.d.mts +70 -35
  5. package/dist/ActionCli.mjs +53 -34
  6. package/dist/ActionHttp.d.mts +70 -38
  7. package/dist/ActionHttp.mjs +76 -25
  8. package/dist/ActionMcp.d.mts +56 -24
  9. package/dist/ActionMcp.mjs +104 -61
  10. package/dist/ActionRpc.d.mts +222 -57
  11. package/dist/ActionRpc.mjs +301 -49
  12. package/dist/ActionToolkit.d.mts +22 -7
  13. package/dist/ActionToolkit.mjs +37 -14
  14. package/dist/Authentication.d.mts +42 -12
  15. package/dist/Authentication.mjs +38 -32
  16. package/dist/Event.d.mts +370 -0
  17. package/dist/Event.mjs +103 -0
  18. package/dist/Testing.d.mts +1 -3
  19. package/dist/Testing.mjs +2 -3
  20. package/dist/Webhook.d.mts +146 -0
  21. package/dist/Webhook.mjs +72 -0
  22. package/dist/authentication/provider.d.mts +35 -12
  23. package/dist/authentication/provider.mjs +2 -3
  24. package/dist/authentication/refusal.mjs +3 -4
  25. package/dist/cli/commands.d.mts +9 -7
  26. package/dist/cli/commands.mjs +85 -35
  27. package/dist/contract/ast.mjs +5 -3
  28. package/dist/contract/authorizer.d.mts +8 -0
  29. package/dist/contract/authorizer.mjs +20 -0
  30. package/dist/contract/call.d.mts +9 -8
  31. package/dist/contract/call.mjs +49 -8
  32. package/dist/contract/callback.d.mts +7 -0
  33. package/dist/contract/callback.mjs +27 -0
  34. package/dist/contract/caller.d.mts +12 -3
  35. package/dist/contract/caller.mjs +23 -5
  36. package/dist/contract/canonical.mjs +10 -0
  37. package/dist/contract/codec.d.mts +9 -0
  38. package/dist/contract/codec.mjs +9 -0
  39. package/dist/contract/codes.mjs +81 -0
  40. package/dist/contract/declared.d.mts +0 -2
  41. package/dist/contract/declared.mjs +26 -11
  42. package/dist/contract/digest.mjs +6 -0
  43. package/dist/contract/errors.d.mts +9 -5
  44. package/dist/contract/errors.mjs +2 -3
  45. package/dist/contract/feed.d.mts +127 -0
  46. package/dist/contract/feed.mjs +249 -0
  47. package/dist/contract/hints.mjs +1 -2
  48. package/dist/contract/hub.d.mts +94 -0
  49. package/dist/contract/hub.mjs +133 -0
  50. package/dist/contract/idempotency.d.mts +47 -0
  51. package/dist/contract/idempotency.mjs +99 -0
  52. package/dist/contract/implementation.d.mts +73 -51
  53. package/dist/contract/implementation.mjs +94 -60
  54. package/dist/contract/input-check.d.mts +15 -0
  55. package/dist/contract/input-check.mjs +16 -0
  56. package/dist/contract/lease.mjs +28 -0
  57. package/dist/contract/listed.d.mts +4 -0
  58. package/dist/contract/listed.mjs +22 -0
  59. package/dist/contract/log.d.mts +41 -0
  60. package/dist/contract/log.mjs +144 -0
  61. package/dist/contract/media.d.mts +1 -3
  62. package/dist/contract/media.mjs +1 -2
  63. package/dist/contract/names.mjs +15 -6
  64. package/dist/contract/occurrence.d.mts +29 -0
  65. package/dist/contract/occurrence.mjs +45 -0
  66. package/dist/contract/pages.d.mts +13 -0
  67. package/dist/contract/pages.mjs +39 -0
  68. package/dist/contract/progress.d.mts +26 -0
  69. package/dist/contract/progress.mjs +72 -0
  70. package/dist/contract/push.d.mts +20 -0
  71. package/dist/contract/push.mjs +96 -0
  72. package/dist/contract/receiving.d.mts +29 -0
  73. package/dist/contract/receiving.mjs +42 -0
  74. package/dist/contract/recent.mjs +12 -0
  75. package/dist/contract/reported.mjs +16 -0
  76. package/dist/contract/serving.mjs +63 -0
  77. package/dist/contract/status.mjs +15 -7
  78. package/dist/contract/stream.d.mts +34 -0
  79. package/dist/contract/stream.mjs +31 -0
  80. package/dist/contract/subscribe.d.mts +45 -0
  81. package/dist/contract/subscribe.mjs +50 -0
  82. package/dist/contract/transient.mjs +28 -0
  83. package/dist/contract/watch.d.mts +18 -0
  84. package/dist/contract/watch.mjs +105 -0
  85. package/dist/contract/webhooks.d.mts +42 -0
  86. package/dist/contract/webhooks.mjs +53 -0
  87. package/dist/http/client.d.mts +37 -14
  88. package/dist/http/client.mjs +52 -14
  89. package/dist/http/events.d.mts +56 -0
  90. package/dist/http/events.mjs +102 -0
  91. package/dist/http/placed.d.mts +36 -0
  92. package/dist/http/placed.mjs +71 -0
  93. package/dist/http/schema-errors.mjs +2 -3
  94. package/dist/http/streamed.d.mts +21 -0
  95. package/dist/http/streamed.mjs +94 -0
  96. package/dist/http/watch.mjs +50 -0
  97. package/dist/mcp/bridge.mjs +283 -0
  98. package/dist/mcp/client.d.mts +30 -99
  99. package/dist/mcp/client.mjs +60 -134
  100. package/dist/mcp/events.mjs +142 -0
  101. package/dist/mcp/progress.mjs +34 -0
  102. package/dist/mcp/protocol.mjs +40 -15
  103. package/dist/mcp/reply.d.mts +79 -0
  104. package/dist/mcp/reply.mjs +99 -0
  105. package/dist/mcp/request.d.mts +40 -0
  106. package/dist/mcp/request.mjs +51 -0
  107. package/dist/mcp/watch.mjs +115 -0
  108. package/dist/rpc/watch.mjs +39 -0
  109. package/dist/stdio/console.mjs +14 -7
  110. package/dist/testing/memory.d.mts +1 -3
  111. package/dist/testing/memory.mjs +4 -5
  112. package/dist/webhook/address.mjs +47 -0
  113. package/dist/webhook/attempt.mjs +65 -0
  114. package/dist/webhook/receiver.mjs +67 -0
  115. package/dist/webhook/signing.mjs +28 -0
  116. package/dist/webhook/stored.mjs +57 -0
  117. package/dist/webhook/subscriptions.mjs +342 -0
  118. package/docs/Action-failures.md +2 -2
  119. package/docs/Action-streams.md +115 -0
  120. package/docs/Action.md +58 -124
  121. package/docs/ActionCli-failures.md +1 -2
  122. package/docs/ActionCli.md +83 -88
  123. package/docs/ActionHttp-failures.md +3 -3
  124. package/docs/ActionHttp.md +90 -70
  125. package/docs/ActionMcp-failures.md +2 -1
  126. package/docs/ActionMcp.md +120 -92
  127. package/docs/ActionRpc-events.md +126 -0
  128. package/docs/ActionRpc-failures.md +1 -2
  129. package/docs/ActionRpc.md +108 -79
  130. package/docs/ActionToolkit-failures.md +1 -2
  131. package/docs/ActionToolkit.md +33 -90
  132. package/docs/Authentication-advanced.md +57 -61
  133. package/docs/Authentication-failures.md +5 -4
  134. package/docs/Authentication.md +23 -58
  135. package/docs/Event-failures.md +15 -0
  136. package/docs/Event.md +281 -0
  137. package/docs/README.md +51 -82
  138. package/docs/Testing-failures.md +1 -5
  139. package/docs/Testing.md +126 -86
  140. package/docs/Webhook-failures.md +8 -0
  141. package/docs/Webhook.md +319 -0
  142. package/docs/setup-failures.md +2 -3
  143. package/docs/setup.md +32 -53
  144. package/examples/README.md +12 -37
  145. package/examples/assistant/README.md +21 -26
  146. package/examples/assistant/src/agent.ts +66 -24
  147. package/examples/assistant/src/cli.ts +7 -4
  148. package/examples/assistant/src/commands.ts +9 -4
  149. package/examples/assistant/src/identity/Identity.ts +5 -4
  150. package/examples/assistant/src/identity/authorize.ts +13 -5
  151. package/examples/assistant/src/identity/handlers.ts +3 -2
  152. package/examples/assistant/src/identity/permissions.ts +9 -3
  153. package/examples/assistant/src/platform/tasks-file.ts +15 -11
  154. package/examples/assistant/src/scripted.ts +7 -4
  155. package/examples/assistant/src/tasks/Tasks.ts +20 -6
  156. package/examples/assistant/src/tasks/handlers.ts +9 -5
  157. package/examples/assistant/src/tasks/store.ts +38 -11
  158. package/examples/assistant/src/toolkit.ts +11 -7
  159. package/examples/assistant/test/commands.test.ts +53 -27
  160. package/examples/assistant/test/identity/identity.test.ts +27 -11
  161. package/examples/assistant/test/platform/tasks-file.test.ts +39 -23
  162. package/examples/assistant/test/tasks/tasks.test.ts +62 -34
  163. package/examples/assistant/test/toolkit.test.ts +104 -75
  164. package/examples/build-events/README.md +83 -0
  165. package/examples/build-events/src/Api.ts +27 -0
  166. package/examples/build-events/src/Rpc.ts +26 -0
  167. package/examples/build-events/src/builds/Builds.ts +108 -0
  168. package/examples/build-events/src/builds/events.ts +54 -0
  169. package/examples/build-events/src/builds/handlers.ts +58 -0
  170. package/examples/build-events/src/builds/log.ts +30 -0
  171. package/examples/build-events/src/builds/status.ts +28 -0
  172. package/examples/build-events/src/builds/store.ts +130 -0
  173. package/examples/build-events/src/cli.ts +18 -0
  174. package/examples/build-events/src/commands.ts +32 -0
  175. package/examples/build-events/src/deploys/Deploys.ts +44 -0
  176. package/examples/build-events/src/deploys/approvers.ts +55 -0
  177. package/examples/build-events/src/deploys/events.ts +32 -0
  178. package/examples/build-events/src/deploys/handlers.ts +38 -0
  179. package/examples/build-events/src/identity/Identity.ts +29 -0
  180. package/examples/build-events/src/identity/members.ts +7 -0
  181. package/examples/build-events/src/identity/verifier.ts +22 -0
  182. package/examples/build-events/src/notifier/Notices.ts +26 -0
  183. package/examples/build-events/src/notifier/routes.ts +71 -0
  184. package/examples/build-events/src/notifier.ts +48 -0
  185. package/examples/build-events/src/routes.ts +71 -0
  186. package/examples/build-events/src/server.ts +62 -0
  187. package/examples/build-events/test/builds/events.test.ts +68 -0
  188. package/examples/build-events/test/builds/log.test.ts +71 -0
  189. package/examples/build-events/test/builds/progress.test.ts +57 -0
  190. package/examples/build-events/test/builds/status.test.ts +42 -0
  191. package/examples/build-events/test/commands.test.ts +103 -0
  192. package/examples/build-events/test/deploys/events.test.ts +52 -0
  193. package/examples/build-events/test/http.test.ts +95 -0
  194. package/examples/build-events/test/notifier.test.ts +80 -0
  195. package/examples/build-events/test/routes.test.ts +96 -0
  196. package/examples/build-events/test/rpc.test.ts +84 -0
  197. package/examples/build-events/test/support/as.ts +8 -0
  198. package/examples/build-events/test/support/memory.ts +43 -0
  199. package/examples/build-events/test/webhooks.test.ts +143 -0
  200. package/examples/kitchen-sink/README.md +178 -166
  201. package/examples/kitchen-sink/src/Actions.ts +11 -3
  202. package/examples/kitchen-sink/src/Api.ts +12 -6
  203. package/examples/kitchen-sink/src/Rpc.ts +7 -5
  204. package/examples/kitchen-sink/src/admin/Admin.ts +21 -10
  205. package/examples/kitchen-sink/src/admin/authenticate.ts +8 -4
  206. package/examples/kitchen-sink/src/admin/authorize.ts +13 -5
  207. package/examples/kitchen-sink/src/admin/grants.ts +5 -3
  208. package/examples/kitchen-sink/src/admin/handlers.ts +9 -5
  209. package/examples/kitchen-sink/src/agent.ts +14 -6
  210. package/examples/kitchen-sink/src/assistant/Assistant.ts +21 -15
  211. package/examples/kitchen-sink/src/assistant/conversations.ts +14 -9
  212. package/examples/kitchen-sink/src/assistant/demo-model.ts +2 -2
  213. package/examples/kitchen-sink/src/assistant/handlers.ts +42 -22
  214. package/examples/kitchen-sink/src/attachments/Attachments.ts +6 -6
  215. package/examples/kitchen-sink/src/attachments/download.ts +7 -6
  216. package/examples/kitchen-sink/src/attachments/handlers.ts +9 -4
  217. package/examples/kitchen-sink/src/attachments/store.ts +25 -18
  218. package/examples/kitchen-sink/src/browser.ts +10 -4
  219. package/examples/kitchen-sink/src/cli.ts +28 -12
  220. package/examples/kitchen-sink/src/config.ts +1 -1
  221. package/examples/kitchen-sink/src/identity/Identity.ts +24 -10
  222. package/examples/kitchen-sink/src/identity/authenticate.ts +19 -11
  223. package/examples/kitchen-sink/src/identity/authorize.ts +14 -4
  224. package/examples/kitchen-sink/src/identity/directory.ts +16 -7
  225. package/examples/kitchen-sink/src/identity/handlers.ts +1 -0
  226. package/examples/kitchen-sink/src/identity/members.ts +7 -2
  227. package/examples/kitchen-sink/src/identity/quota.ts +23 -20
  228. package/examples/kitchen-sink/src/issues/Issues.ts +91 -15
  229. package/examples/kitchen-sink/src/issues/assistant.ts +4 -2
  230. package/examples/kitchen-sink/src/issues/events.ts +46 -0
  231. package/examples/kitchen-sink/src/issues/export.ts +35 -0
  232. package/examples/kitchen-sink/src/issues/guide.ts +4 -3
  233. package/examples/kitchen-sink/src/issues/handlers.ts +20 -7
  234. package/examples/kitchen-sink/src/issues/store.ts +156 -67
  235. package/examples/kitchen-sink/src/issues/write-limit.ts +44 -18
  236. package/examples/kitchen-sink/src/memory.ts +11 -2
  237. package/examples/kitchen-sink/src/ops.ts +10 -5
  238. package/examples/kitchen-sink/src/request-policy.ts +25 -9
  239. package/examples/kitchen-sink/src/routes.ts +67 -27
  240. package/examples/kitchen-sink/src/sdk.ts +22 -12
  241. package/examples/kitchen-sink/src/seed.ts +15 -9
  242. package/examples/kitchen-sink/src/server.ts +24 -10
  243. package/examples/kitchen-sink/src/stdio.ts +9 -3
  244. package/examples/kitchen-sink/src/workspaces/Workspaces.ts +10 -7
  245. package/examples/kitchen-sink/src/workspaces/handlers.ts +3 -2
  246. package/examples/kitchen-sink/src/workspaces/registry.ts +16 -10
  247. package/examples/kitchen-sink/src/workspaces/tenant.ts +12 -5
  248. package/examples/kitchen-sink/test/admin/admin.test.ts +38 -23
  249. package/examples/kitchen-sink/test/assistant/assistant.test.ts +471 -299
  250. package/examples/kitchen-sink/test/attachments/attachments.test.ts +135 -94
  251. package/examples/kitchen-sink/test/identity/identity.test.ts +110 -82
  252. package/examples/kitchen-sink/test/identity/session.test.ts +65 -43
  253. package/examples/kitchen-sink/test/issues/assistant.test.ts +50 -31
  254. package/examples/kitchen-sink/test/issues/events.test.ts +273 -0
  255. package/examples/kitchen-sink/test/issues/export.test.ts +120 -0
  256. package/examples/kitchen-sink/test/issues/issues.test.ts +105 -75
  257. package/examples/kitchen-sink/test/request-policy.test.ts +130 -33
  258. package/examples/kitchen-sink/test/routes.test.ts +102 -57
  259. package/examples/kitchen-sink/test/support/as.ts +9 -0
  260. package/examples/kitchen-sink/test/support/scripted.ts +6 -5
  261. package/examples/kitchen-sink/test/support/services.ts +17 -0
  262. package/examples/live-rpc/README.md +33 -35
  263. package/examples/live-rpc/src/Rpc.ts +9 -5
  264. package/examples/live-rpc/src/browser.ts +39 -11
  265. package/examples/live-rpc/src/chat/Chat.ts +30 -16
  266. package/examples/live-rpc/src/chat/authorize.ts +2 -1
  267. package/examples/live-rpc/src/chat/events.ts +74 -0
  268. package/examples/live-rpc/src/chat/handlers.ts +13 -5
  269. package/examples/live-rpc/src/chat/room.ts +9 -5
  270. package/examples/live-rpc/src/chat/store.ts +51 -14
  271. package/examples/live-rpc/src/client.ts +22 -5
  272. package/examples/live-rpc/src/identity/Identity.ts +3 -3
  273. package/examples/live-rpc/src/identity/verifier.ts +8 -4
  274. package/examples/live-rpc/src/page.ts +58 -12
  275. package/examples/live-rpc/src/routes.ts +14 -10
  276. package/examples/live-rpc/src/server.ts +15 -5
  277. package/examples/live-rpc/test/browser.test.ts +127 -50
  278. package/examples/live-rpc/test/chat/chat.test.ts +215 -23
  279. package/examples/live-rpc/test/routes.test.ts +130 -46
  280. package/examples/live-rpc/test/server.test.ts +94 -33
  281. package/examples/mcp-tools/README.md +24 -38
  282. package/examples/mcp-tools/src/platform/fake-farm.ts +63 -24
  283. package/examples/mcp-tools/src/platform/print-farm.ts +59 -29
  284. package/examples/mcp-tools/src/printers/Printers.ts +18 -7
  285. package/examples/mcp-tools/src/printers/farm.ts +15 -5
  286. package/examples/mcp-tools/src/printers/handlers.ts +12 -6
  287. package/examples/mcp-tools/src/routes.ts +8 -6
  288. package/examples/mcp-tools/src/server.ts +12 -5
  289. package/examples/mcp-tools/src/stdio.ts +3 -2
  290. package/examples/mcp-tools/test/printers/printers.test.ts +132 -89
  291. package/examples/mcp-tools/test/routes.test.ts +140 -81
  292. package/examples/notes-service/README.md +30 -45
  293. package/examples/notes-service/src/Api.ts +1 -0
  294. package/examples/notes-service/src/browser.ts +15 -14
  295. package/examples/notes-service/src/cli.ts +8 -3
  296. package/examples/notes-service/src/commands.ts +46 -20
  297. package/examples/notes-service/src/identity/Identity.ts +2 -2
  298. package/examples/notes-service/src/identity/authorize.ts +6 -2
  299. package/examples/notes-service/src/identity/handlers.ts +3 -2
  300. package/examples/notes-service/src/identity/users.ts +2 -1
  301. package/examples/notes-service/src/identity/verifier.ts +12 -6
  302. package/examples/notes-service/src/notes/Notes.ts +46 -22
  303. package/examples/notes-service/src/notes/handlers.ts +18 -9
  304. package/examples/notes-service/src/notes/store.ts +70 -43
  305. package/examples/notes-service/src/routes.ts +7 -5
  306. package/examples/notes-service/src/server.ts +12 -5
  307. package/examples/notes-service/src/status/Status.ts +1 -1
  308. package/examples/notes-service/src/status/handlers.ts +5 -3
  309. package/examples/notes-service/src/stdio.ts +7 -3
  310. package/examples/notes-service/src/worker.ts +6 -3
  311. package/examples/notes-service/test/commands.test.ts +164 -91
  312. package/examples/notes-service/test/notes/as.ts +3 -1
  313. package/examples/notes-service/test/notes/idempotency.test.ts +97 -67
  314. package/examples/notes-service/test/notes/in-process.test.ts +26 -15
  315. package/examples/notes-service/test/notes/notes.test.ts +49 -31
  316. package/examples/notes-service/test/routes.test.ts +106 -71
  317. package/examples/notes-service/test/worker.test.ts +21 -13
  318. package/examples/quickstart/README.md +2 -6
  319. package/examples/quickstart/src/Api.ts +1 -0
  320. package/examples/quickstart/src/client.ts +6 -3
  321. package/examples/quickstart/src/greeting/Greeting.ts +1 -1
  322. package/examples/quickstart/src/greeting/handlers.ts +5 -2
  323. package/examples/quickstart/src/routes.ts +3 -2
  324. package/examples/quickstart/src/server.ts +6 -2
  325. package/examples/quickstart/test/routes.test.ts +3 -2
  326. package/package.json +19 -17
@@ -0,0 +1,67 @@
1
+ import { watchParameter } from "../contract/receiving.mjs";
2
+ import { makeRecent } from "../contract/recent.mjs";
3
+ import { constantTimeEqual, signature } from "./signing.mjs";
4
+ import { Clock, Effect, Option, Predicate, Schema } from "effect";
5
+ import { HttpServerRequest, HttpServerResponse } from "effect/http";
6
+ import { Base64, Base64Url } from "effect/encoding";
7
+ // src/webhook/receiver.ts
8
+ /** How old a delivery's `webhook-timestamp` may be, or how far ahead, before it is refused. */
9
+ const tolerance = 300;
10
+ const dedupeWindow = 1e3;
11
+ const Body = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Json));
12
+ const decodeBody = Schema.decodeUnknownOption(Body);
13
+ const gone = HttpServerResponse.empty({ status: 410 });
14
+ const unauthorized = HttpServerResponse.empty({ status: 401 });
15
+ /**
16
+ * The receiver of one `Webhook.layerReceiver` at `path`: `receiving`, where each watch keeps its
17
+ * place under an id and a secret of its own, and `handler`, which verifies a POST against the
18
+ * secret of the watch its URL names and hands its body to that watch.
19
+ */
20
+ const makeReceiver = (path) => Effect.sync(() => {
21
+ const places = /* @__PURE__ */ new Map();
22
+ const register = (deliver) => Effect.acquireRelease(Effect.sync(() => {
23
+ const id = Base64Url.encode(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16)));
24
+ const key = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32));
25
+ places.set(id, {
26
+ key,
27
+ deliver,
28
+ seen: makeRecent(dedupeWindow)
29
+ });
30
+ return {
31
+ id,
32
+ secret: {
33
+ text: `whsec_${Base64.encode(key)}`,
34
+ key
35
+ }
36
+ };
37
+ }), ({ id }) => Effect.sync(() => places.delete(id)));
38
+ const handler = Effect.gen(function* () {
39
+ const request = yield* HttpServerRequest.HttpServerRequest;
40
+ const named = (yield* HttpServerRequest.ParsedSearchParams)[watchParameter];
41
+ const place = Predicate.isString(named) ? places.get(named) : void 0;
42
+ if (place === void 0) return gone;
43
+ const id = request.headers["webhook-id"];
44
+ const timestamp = Number(request.headers["webhook-timestamp"]);
45
+ const signatures = (request.headers["webhook-signature"] ?? "").split(" ");
46
+ const now = Math.floor((yield* Clock.currentTimeMillis) / 1e3);
47
+ if (id === void 0 || !Number.isInteger(timestamp) || Math.abs(now - timestamp) > tolerance) return unauthorized;
48
+ const text = yield* Effect.orElseSucceed(request.text, () => "");
49
+ const expected = yield* signature([place.key], id, timestamp, text);
50
+ if (!signatures.some((each) => constantTimeEqual(each, expected))) return unauthorized;
51
+ const body = decodeBody(text);
52
+ if (Option.isNone(body)) return HttpServerResponse.empty({ status: 400 });
53
+ if (body.value["type"] === "verification") return HttpServerResponse.jsonUnsafe({ challenge: body.value["challenge"] });
54
+ if (place.seen.has(id)) return HttpServerResponse.empty();
55
+ if (!place.deliver(body.value)) return HttpServerResponse.empty({ status: 503 });
56
+ place.seen.add(id);
57
+ return HttpServerResponse.empty();
58
+ });
59
+ return {
60
+ receiving: {
61
+ path,
62
+ register
63
+ },
64
+ handler
65
+ };
66
+ });
67
+ export { makeReceiver };
@@ -0,0 +1,28 @@
1
+ import { sha256Hex } from "../contract/digest.mjs";
2
+ import { Effect } from "effect";
3
+ import { Base64, Hex } from "effect/encoding";
4
+ // src/webhook/signing.ts
5
+ const encoder = new TextEncoder();
6
+ const hmac = (key, content) => Effect.promise(async () => {
7
+ const imported = await crypto.subtle.importKey("raw", Uint8Array.from(key), {
8
+ name: "HMAC",
9
+ hash: "SHA-256"
10
+ }, false, ["sign"]);
11
+ return new Uint8Array(await crypto.subtle.sign("HMAC", imported, encoder.encode(content)));
12
+ });
13
+ /**
14
+ * A delivery's `webhook-signature`: Standard Webhooks' `v1,` HMAC-SHA256 of `id.timestamp.body`
15
+ * under each key, space apart, so a receiver mid-rotation verifies under either.
16
+ */
17
+ const signature = (keys, id, timestamp, body) => Effect.map(Effect.forEach(keys, (key) => hmac(key, `${id}.${timestamp}.${body}`)), (signed) => signed.map((bytes) => `v1,${Base64.encode(bytes)}`).join(" "));
18
+ /** `sub_` and the first 16 bytes of the SHA-256 of `key`, in hex: a subscription's id. */
19
+ const subscriptionId = (key) => Effect.map(sha256Hex(key), (hex) => `sub_${hex.slice(0, 32)}`);
20
+ /** 16 random bytes in hex, for a challenge or an envelope's `webhook-id`. */
21
+ const randomToken = Effect.sync(() => Hex.encode(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16))));
22
+ /** Whether `a` and `b` are equal, in a time that does not depend on where they differ. */
23
+ const constantTimeEqual = (a, b) => {
24
+ const left = encoder.encode(a);
25
+ const right = encoder.encode(b);
26
+ return left.length === right.length && left.reduce((differs, byte, index) => differs | byte ^ (right[index] ?? 0), 0) === 0;
27
+ };
28
+ export { constantTimeEqual, randomToken, signature, subscriptionId };
@@ -0,0 +1,57 @@
1
+ import { reported } from "../contract/reported.mjs";
2
+ import { CallbackUrl, WebhookSecret } from "../contract/callback.mjs";
3
+ import { DeliveryStatus } from "../contract/subscribe.mjs";
4
+ import { Duration, Effect, Option, Queue, Schema, Semaphore } from "effect";
5
+ import { KeyValueStore } from "effect/persistence";
6
+ // src/webhook/stored.ts
7
+ /**
8
+ * A webhook subscription as `persist` keeps it, enough to start delivering again after a
9
+ * restart: its callback, event, encoded input and identity, secret, expiry, watermark and
10
+ * status. Its callback and secret are decoded again as it is read, as a request's are, and its
11
+ * subscriber's principal is derived again from the identity.
12
+ */
13
+ const Stored = Schema.Struct({
14
+ url: CallbackUrl,
15
+ event: Schema.String,
16
+ input: Schema.Json,
17
+ identity: Schema.Json,
18
+ secret: WebhookSecret,
19
+ expiresAt: Schema.Finite,
20
+ watermark: Schema.NullOr(Schema.String),
21
+ status: DeliveryStatus
22
+ });
23
+ const prefix = "effect-actions/webhook";
24
+ const Index = Schema.Array(Schema.String);
25
+ const flushTimeout = Duration.seconds(5);
26
+ /**
27
+ * A storage over `store`. Its writes run on a fiber of their own, never in the request or the
28
+ * delivery that marked them, so a slow or failing store holds up neither: a failure is reported,
29
+ * and written again with the next mark or as the layer closes.
30
+ * A store whose index cannot be read fails the layer as it builds.
31
+ */
32
+ const makeStorage = (store) => Effect.gen(function* () {
33
+ const records = KeyValueStore.toSchemaStore(KeyValueStore.prefix(store, `${prefix}/`), Stored);
34
+ const index = KeyValueStore.toSchemaStore(store, Index);
35
+ let indexed = new Set(yield* Effect.orDie(Effect.map(index.get(prefix), Option.getOrElse(() => []))));
36
+ const pending = /* @__PURE__ */ new Map();
37
+ const marked = yield* Queue.sliding(1);
38
+ const writing = Semaphore.makeUnsafe(1);
39
+ const listed = (next) => Effect.andThen(index.set(prefix, [...next]), Effect.sync(() => {
40
+ indexed = next;
41
+ }));
42
+ const written = (id, record) => record === void 0 ? Effect.andThen(records.remove(id), Effect.suspend(() => indexed.has(id) ? listed(new Set([...indexed].filter((each) => each !== id))) : Effect.void)) : Effect.andThen(Effect.suspend(() => indexed.has(id) ? Effect.void : listed(/* @__PURE__ */ new Set([...indexed, id]))), records.set(id, record));
43
+ const flushed = writing.withPermits(1)(Effect.suspend(() => Effect.forEach([...pending], ([id, record]) => written(id, record).pipe(Effect.andThen(Effect.sync(() => {
44
+ if (pending.get(id) === record) pending.delete(id);
45
+ })), Effect.catchCause(reported)), { discard: true })));
46
+ yield* Effect.forkScoped(Effect.forever(Effect.andThen(Queue.take(marked), flushed)));
47
+ yield* Effect.addFinalizer(() => flushed.pipe(Effect.timeout(flushTimeout), Effect.ignore));
48
+ return {
49
+ mark: (id, record) => {
50
+ pending.set(id, record);
51
+ Queue.offerUnsafe(marked, void 0);
52
+ },
53
+ ids: () => [.../* @__PURE__ */ new Set([...indexed, ...pending.keys()])],
54
+ get: (id) => pending.has(id) ? Effect.succeed(Option.fromUndefinedOr(pending.get(id))) : records.get(id)
55
+ };
56
+ });
57
+ export { Stored, makeStorage };
@@ -0,0 +1,342 @@
1
+ import { canonicalInput } from "../contract/canonical.mjs";
2
+ import { reported } from "../contract/reported.mjs";
3
+ import { encodeUntaggedOf } from "../contract/occurrence.mjs";
4
+ import { CallbackFailed, NotSubscribed, SubscriptionError } from "../Event.mjs";
5
+ import { sentError } from "../contract/codes.mjs";
6
+ import { randomToken, subscriptionId } from "./signing.mjs";
7
+ import { pushed, refusalIn } from "../contract/push.mjs";
8
+ import { checkedHost } from "./address.mjs";
9
+ import { attempt, challenged } from "./attempt.mjs";
10
+ import { Cause, Clock, Context, DateTime, Deferred, Duration, Effect, Exit, Fiber, FiberMap, Match, Option, RcMap, Schedule, Schema, Scope, Semaphore, Stream, Struct } from "effect";
11
+ import { HttpRouter, HttpServerRequest } from "effect/http";
12
+ // src/webhook/subscriptions.ts
13
+ const minTtl = Duration.minutes(1);
14
+ const rotationGrace = Duration.minutes(5);
15
+ const maxBody = 262144;
16
+ const encoder = new TextEncoder();
17
+ const lastAttempts = Schedule.exponential(Duration.seconds(1)).pipe(Schedule.upTo({ times: 3 }));
18
+ const iso = (millis) => DateTime.formatIso(DateTime.makeUnsafe(millis));
19
+ const active = (status) => status.active ? status : {
20
+ ...Struct.omit(status, ["failedSince"]),
21
+ active: true
22
+ };
23
+ const grantOf = (ttlMs, policy) => Duration.clamp(ttlMs === null || ttlMs === void 0 ? policy.maxTtl : Duration.millis(ttlMs), {
24
+ minimum: minTtl,
25
+ maximum: policy.maxTtl
26
+ });
27
+ const trusted = (url, prefixes) => prefixes.some(({ origin, pathname }) => url.origin === origin && (url.pathname === pathname || url.pathname.startsWith(pathname.endsWith("/") ? pathname : `${pathname}/`)));
28
+ const snapshotOf = Context.omit(Scope.Scope, HttpServerRequest.HttpServerRequest, HttpServerRequest.ParsedSearchParams, HttpRouter.RouteContext);
29
+ const unencoded = Effect.die(/* @__PURE__ */ new Error("A webhook subscriber's identity did not encode by its authentication's schema (give Authentication.make a schema that encodes every identity its verifier provides)"));
30
+ /**
31
+ * The webhook subscriptions of one `Webhook.layer`: each a worker reading its feed as the
32
+ * subscriber and POSTing what it reads to the callback, until it expires, is unsubscribed, or
33
+ * is refused.
34
+ */
35
+ const confirmedAt = (cursor) => Effect.tap(Deferred.make(), (confirmation) => Deferred.succeed(confirmation, {
36
+ cursor,
37
+ truncated: false
38
+ }));
39
+ const failedCallback = (feed, category) => SubscriptionError.of(feed.event.name, new CallbackFailed({ category }));
40
+ const idOf = ({ feed, principal, input, delivery: { url } }) => Effect.flatMap(canonicalInput(feed.event.input, input), (encoded) => subscriptionId(`${principal}\u0000${url.href}\u0000${feed.event.name}\u0000${encoded}`));
41
+ const named = (feeds) => feeds.map(({ event }) => `"${event.name}"`).join(", ");
42
+ const makeWebhooks = (policy) => Effect.gen(function* () {
43
+ const built = yield* Effect.context();
44
+ const workers = yield* FiberMap.make();
45
+ const held = /* @__PURE__ */ new Map();
46
+ const served = /* @__PURE__ */ new Map();
47
+ const { storage } = policy;
48
+ const holds = (subscription) => held.get(subscription.id) === subscription;
49
+ const kept = (subscription) => Effect.sync(() => {
50
+ if (storage !== void 0 && holds(subscription)) storage.mark(subscription.id, {
51
+ ...subscription.kept,
52
+ url: subscription.url,
53
+ event: subscription.feed.event.name,
54
+ secret: subscription.secret,
55
+ expiresAt: subscription.expiresAt,
56
+ watermark: subscription.watermark,
57
+ status: subscription.status
58
+ });
59
+ });
60
+ const dropped = (id) => Effect.sync(() => storage?.mark(id, void 0));
61
+ const verified = /* @__PURE__ */ new Map();
62
+ const locks = yield* RcMap.make({ lookup: (_id) => Semaphore.make(1) });
63
+ const retryForMillis = Duration.toMillis(policy.retryFor);
64
+ const failingFor = ({ status }) => Effect.map(Clock.currentTimeMillis, (now) => status.failedSince === void 0 ? 0 : now - Date.parse(status.failedSince));
65
+ const retrying = (subscription) => ({
66
+ schedule: Schedule.modifyDelay(Schedule.forever, () => Effect.map(failingFor(subscription), (failing) => Duration.clamp(Duration.millis(failing), {
67
+ minimum: Duration.seconds(1),
68
+ maximum: Duration.minutes(5)
69
+ }))),
70
+ while: (failure) => failure.final ? Effect.succeed(false) : Effect.map(failingFor(subscription), (failing) => failing < retryForMillis)
71
+ });
72
+ const locked = (id) => (effect) => Effect.scoped(Effect.flatMap(RcMap.get(locks, id), (lock) => lock.withPermits(1)(effect)));
73
+ const asSubscriber = (subscription, effect) => Effect.updateContext(effect, () => subscription.context);
74
+ const post = (subscription, webhookId, body) => Effect.flatMap(Clock.currentTimeMillis, (now) => Effect.provideContext(attempt({
75
+ url: subscription.url,
76
+ subscriptionId: subscription.id,
77
+ webhookId,
78
+ body,
79
+ keys: subscription.previous !== void 0 && now < subscription.previous.until ? [subscription.secret.key, subscription.previous.key] : [subscription.secret.key]
80
+ }, trusted(subscription.url, policy.allowedCallbacks) ? Effect.void : checkedHost(subscription.url, policy.dns)), built));
81
+ const recorded = (subscription, failure) => Effect.flatMap(Clock.currentTimeMillis, (now) => Effect.suspend(() => {
82
+ const { status } = subscription;
83
+ subscription.status = failure === void 0 ? {
84
+ active: true,
85
+ lastDeliveryAt: iso(now),
86
+ lastError: null
87
+ } : {
88
+ ...status,
89
+ lastError: failure.category,
90
+ failedSince: status.failedSince ?? iso(now)
91
+ };
92
+ return failure !== void 0 && status.lastError === failure.category && status.failedSince !== void 0 ? Effect.void : kept(subscription);
93
+ }));
94
+ const suspended = (subscription) => Effect.andThen(Effect.andThen(Effect.sync(() => {
95
+ subscription.status = {
96
+ ...subscription.status,
97
+ active: false
98
+ };
99
+ }), kept(subscription)), Effect.never);
100
+ const abandoned = (subscription) => Effect.andThen(Effect.sync(() => {
101
+ subscription.status = Struct.omit(subscription.status, ["failedSince"]);
102
+ }), kept(subscription));
103
+ const delivered = (subscription, webhookId, body) => post(subscription, webhookId, body).pipe(Effect.tapError((failure) => recorded(subscription, failure)), Effect.retry(retrying(subscription)), Effect.matchEffect({
104
+ onSuccess: () => Effect.as(recorded(subscription, void 0), true),
105
+ onFailure: (failure) => failure.final ? Effect.as(abandoned(subscription), false) : suspended(subscription)
106
+ }));
107
+ const gapped = (subscription, cursor) => Effect.flatMap(randomToken, (token) => delivered(subscription, `msg_gap_${token}`, JSON.stringify({
108
+ type: "gap",
109
+ cursor
110
+ })));
111
+ const deliverTo = (subscription, input, confirmation) => {
112
+ const encode = encodeUntaggedOf(subscription.feed.event);
113
+ const moved = (cursor) => cursor === null || cursor === subscription.watermark ? Effect.void : Effect.andThen(Effect.sync(() => {
114
+ subscription.watermark = cursor;
115
+ }), kept(subscription));
116
+ const confirmed = (cursor, truncated) => Effect.andThen(moved(cursor), Deferred.succeed(confirmation, {
117
+ cursor,
118
+ truncated
119
+ }));
120
+ const skipped = (cursor) => Effect.andThen(gapped(subscription, cursor), moved(cursor));
121
+ return (sent) => Match.valueTags(sent, {
122
+ Checked: ({ cursor }) => Deferred.isDoneUnsafe(confirmation) ? moved(cursor) : confirmed(cursor, false),
123
+ Gap: ({ cursor }) => Deferred.isDoneUnsafe(confirmation) ? skipped(cursor) : confirmed(cursor, true),
124
+ Occurrence: (occurrence) => Effect.gen(function* () {
125
+ const body = yield* asSubscriber(subscription, subscription.feed.admit(input)).pipe(Effect.andThen(Effect.map(encode(occurrence), JSON.stringify)));
126
+ if (encoder.encode(body).length > maxBody) {
127
+ yield* Effect.logWarning(`Webhook delivery of ${occurrence.eventId} abandoned: its body is over 256 KiB`);
128
+ return yield* skipped(occurrence.cursor);
129
+ }
130
+ if (yield* delivered(subscription, occurrence.eventId, body)) return yield* moved(occurrence.cursor);
131
+ return yield* skipped(occurrence.cursor);
132
+ }),
133
+ Failed: () => Effect.void
134
+ });
135
+ };
136
+ const expired = ({ expiresAt }) => Effect.flatMap(Clock.currentTimeMillis, (now) => Effect.sleep(Duration.millis(Math.max(expiresAt - now, 0))));
137
+ const terminated = (subscription, cause) => {
138
+ const refusal = refusalIn(cause);
139
+ if (refusal === void 0) return Cause.hasInterruptsOnly(cause) ? Effect.void : reported(cause);
140
+ return Effect.flatMap(randomToken, (token) => post(subscription, `msg_terminated_${token}`, JSON.stringify({
141
+ type: "terminated",
142
+ error: sentError(refusal)
143
+ })).pipe(Effect.retry({
144
+ schedule: lastAttempts,
145
+ while: (failure) => !failure.final
146
+ }), Effect.ignore));
147
+ };
148
+ const ended = (subscription) => Effect.sync(() => {
149
+ if (holds(subscription)) held.delete(subscription.id);
150
+ });
151
+ const finished = (subscription) => Effect.suspend(() => holds(subscription) ? Effect.andThen(ended(subscription), dropped(subscription.id)) : Effect.void);
152
+ const workerOf = (subscription, input, cursor, confirmation) => Effect.raceFirst(pushed((from) => Stream.unwrap(Effect.map(subscription.feed.stream(input), (read) => read(from))), cursor, deliverTo(subscription, input, confirmation), {
153
+ inOrder: true,
154
+ resumesUnconfirmed: Deferred.isDoneUnsafe(confirmation)
155
+ }), expired(subscription)).pipe(Effect.exit, Effect.tap(() => finished(subscription)), Effect.flatMap((exit) => {
156
+ if (Deferred.isDoneUnsafe(confirmation)) return Exit.isFailure(exit) ? terminated(subscription, exit.cause) : Effect.void;
157
+ return Exit.isFailure(exit) ? Deferred.failCause(confirmation, exit.cause) : Deferred.succeed(confirmation, {
158
+ cursor,
159
+ truncated: false
160
+ });
161
+ }), Effect.onInterrupt(() => Effect.andThen(ended(subscription), Deferred.interrupt(confirmation))), (worker) => asSubscriber(subscription, worker));
162
+ const remembered = ({ verifiedAs, expiresAt }, now) => Effect.sync(() => {
163
+ for (const [each, expiry] of verified) if (expiry <= now) verified.delete(each);
164
+ verified.set(verifiedAs, Math.max(expiresAt, verified.get(verifiedAs) ?? 0));
165
+ });
166
+ const verify = ({ verifiedAs, url, id, secret, feed }, now) => {
167
+ if ((verified.get(verifiedAs) ?? 0) > now || trusted(url, policy.allowedCallbacks)) return Effect.void;
168
+ return Effect.gen(function* () {
169
+ const challenge = yield* randomToken;
170
+ if ((yield* Effect.provideContext(challenged({
171
+ url,
172
+ subscriptionId: id,
173
+ webhookId: `msg_verification_${yield* randomToken}`,
174
+ body: JSON.stringify({
175
+ type: "verification",
176
+ challenge
177
+ }),
178
+ keys: [secret.key]
179
+ }, checkedHost(url, policy.dns)), built).pipe(Effect.mapError(({ category }) => failedCallback(feed, category)))) !== challenge) return yield* failedCallback(feed, "challenge_failed");
180
+ });
181
+ };
182
+ const handover = (id, next, worker) => Effect.uninterruptible(Effect.gen(function* () {
183
+ const previous = yield* FiberMap.get(workers, id);
184
+ if (next === void 0) held.delete(id);
185
+ else held.set(id, next);
186
+ yield* FiberMap.run(workers, id, Option.match(previous, {
187
+ onNone: () => worker,
188
+ onSome: (fiber) => Effect.andThen(Fiber.await(fiber), worker)
189
+ }));
190
+ }));
191
+ const refreshed = (subscription, granted, input, now) => Effect.gen(function* () {
192
+ const { status, watermark } = subscription;
193
+ const renewed = {
194
+ ...subscription,
195
+ ...granted,
196
+ ...granted.secret.text === subscription.secret.text ? {} : { previous: {
197
+ key: subscription.secret.key,
198
+ until: now + Duration.toMillis(rotationGrace)
199
+ } },
200
+ status: active(status)
201
+ };
202
+ const confirmation = yield* confirmedAt(watermark);
203
+ yield* handover(renewed.id, renewed, workerOf(renewed, input, watermark, confirmation));
204
+ yield* remembered(renewed, now);
205
+ yield* kept(renewed);
206
+ return {
207
+ id: renewed.id,
208
+ refreshBefore: iso(renewed.expiresAt),
209
+ cursor: watermark,
210
+ truncated: false,
211
+ deliveryStatus: status
212
+ };
213
+ });
214
+ const heldOf = (fields) => ({
215
+ ...fields,
216
+ verifiedAs: `${fields.principal}\u0000${fields.url.href}`,
217
+ previous: void 0
218
+ });
219
+ const keptOf = ({ feed, keyed, input }) => Effect.gen(function* () {
220
+ const { schema } = keyed;
221
+ if (storage === void 0 || schema === void 0) return {
222
+ input: null,
223
+ identity: null
224
+ };
225
+ const identity = yield* Effect.service(feed.event.caller);
226
+ return {
227
+ input: yield* Effect.orDie(Schema.encodeUnknownEffect(Schema.toCodecJson(feed.event.input))(input)),
228
+ identity: yield* Effect.catch(Schema.encodeUnknownEffect(Schema.toCodecJson(schema))(identity), () => unencoded)
229
+ };
230
+ });
231
+ const restoredFrom = (store, feeds, keyed, schema, id) => Effect.gen(function* () {
232
+ const record = Option.getOrUndefined(yield* store.get(id));
233
+ if (record === void 0) return yield* dropped(id);
234
+ const feed = feeds.find(({ event }) => event.name === record.event);
235
+ if (feed === void 0) return;
236
+ const { expiresAt, url, secret } = record;
237
+ if (expiresAt <= (yield* Clock.currentTimeMillis)) return yield* dropped(id);
238
+ const input = yield* Schema.decodeEffect(Schema.toCodecJson(feed.event.input))(record.input);
239
+ const identity = yield* Schema.decodeEffect(Schema.toCodecJson(schema))(record.identity);
240
+ const principal = keyed.principal(identity);
241
+ const current = yield* idOf({
242
+ feed,
243
+ principal,
244
+ input,
245
+ delivery: { url }
246
+ });
247
+ if (current !== id) yield* dropped(id);
248
+ if (held.has(current)) return;
249
+ const subscription = heldOf({
250
+ id: current,
251
+ url,
252
+ feed,
253
+ principal,
254
+ kept: {
255
+ input: record.input,
256
+ identity: record.identity
257
+ },
258
+ secret,
259
+ context: Context.add(built, feed.event.caller, identity),
260
+ expiresAt,
261
+ status: record.status,
262
+ watermark: record.watermark
263
+ });
264
+ const confirmation = yield* confirmedAt(record.watermark);
265
+ yield* handover(current, subscription, record.status.active ? workerOf(subscription, input, record.watermark, confirmation) : Effect.andThen(expired(subscription), finished(subscription)));
266
+ if (current !== id) yield* kept(subscription);
267
+ }).pipe(Effect.catchCause(reported));
268
+ const serve = (feeds, keyed) => Effect.gen(function* () {
269
+ const { schema } = keyed;
270
+ const namesake = feeds.filter(({ event }) => served.has(event.name) && served.get(event.name)?.event !== event);
271
+ if (namesake.length > 0) return yield* Effect.die(/* @__PURE__ */ new Error(`Webhook.layer serves ${named(namesake)} for two event types of that name (give each event type a name of its own, or each surface its own Webhook.layer)`));
272
+ if (storage !== void 0 && feeds.length > 0 && schema === void 0) return yield* Effect.die(/* @__PURE__ */ new Error(`Webhook.layer persists webhook subscriptions, but the authentication of ${named(feeds)} has no schema to keep a subscriber's identity by (give its Authentication.make a schema)`));
273
+ const twice = feeds.filter(({ event }) => served.has(event.name) && served.get(event.name)?.schema !== schema);
274
+ if (storage !== void 0 && twice.length > 0) return yield* Effect.die(/* @__PURE__ */ new Error(`Webhook.layer persists webhook subscriptions, but ${named(twice)} is served by two authentications with different schemas (serve each event with one authentication, or give them one schema)`));
275
+ for (const { event } of feeds) served.set(event.name, {
276
+ event,
277
+ schema
278
+ });
279
+ if (storage === void 0 || schema === void 0) return;
280
+ for (const id of storage.ids()) yield* locked(id)(Effect.suspend(() => held.has(id) ? Effect.void : restoredFrom(storage, feeds, keyed, schema, id)));
281
+ });
282
+ const refused = (id, cause) => Effect.gen(function* () {
283
+ const existing = held.get(id);
284
+ if (existing !== void 0 && refusalIn(cause) !== void 0 && (yield* Clock.currentTimeMillis) < existing.expiresAt) {
285
+ yield* handover(id, void 0, terminated(existing, cause));
286
+ yield* dropped(id);
287
+ }
288
+ return yield* Effect.failCause(cause);
289
+ });
290
+ const subscribe = (subscribing) => Effect.gen(function* () {
291
+ const { feed, principal, input, delivery: { url, secret }, cursor, ttlMs } = subscribing;
292
+ const id = yield* idOf(subscribing);
293
+ return yield* locked(id)(Effect.gen(function* () {
294
+ yield* Effect.catchCause(feed.admit(input), (cause) => refused(id, cause));
295
+ const now = yield* Clock.currentTimeMillis;
296
+ const granted = {
297
+ secret,
298
+ context: Context.merge(built, snapshotOf(yield* Effect.context())),
299
+ kept: yield* keptOf(subscribing),
300
+ expiresAt: now + Duration.toMillis(grantOf(ttlMs, policy))
301
+ };
302
+ const existing = held.get(id);
303
+ if (existing !== void 0 && now < existing.expiresAt) return yield* refreshed(existing, granted, input, now);
304
+ const subscription = heldOf({
305
+ ...granted,
306
+ id,
307
+ url,
308
+ feed,
309
+ principal,
310
+ status: {
311
+ active: true,
312
+ lastDeliveryAt: null,
313
+ lastError: null
314
+ },
315
+ watermark: cursor
316
+ });
317
+ yield* verify(subscription, now);
318
+ yield* remembered(subscription, now);
319
+ const confirmation = yield* Deferred.make();
320
+ yield* handover(id, subscription, workerOf(subscription, input, cursor, confirmation));
321
+ const started = yield* Deferred.await(confirmation);
322
+ yield* kept(subscription);
323
+ return {
324
+ id,
325
+ refreshBefore: iso(subscription.expiresAt),
326
+ cursor: started.cursor,
327
+ truncated: started.truncated
328
+ };
329
+ }));
330
+ });
331
+ const unsubscribe = (subscription) => Effect.flatMap(idOf(subscription), (id) => locked(id)(Effect.gen(function* () {
332
+ if ((yield* Clock.currentTimeMillis) < (held.get(id)?.expiresAt ?? 0)) return yield* Effect.andThen(handover(id, void 0, Effect.void), dropped(id));
333
+ if (storage?.ids().includes(id) === true) return yield* dropped(id);
334
+ return yield* SubscriptionError.of(subscription.feed.event.name, new NotSubscribed());
335
+ })));
336
+ return {
337
+ serve,
338
+ subscribe,
339
+ unsubscribe
340
+ };
341
+ });
342
+ export { makeWebhooks };
@@ -1,7 +1,6 @@
1
1
  # Action 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 [Action.md](Action.md).
3
+ What you see when something is wrong, and the fix. Search for a fragment of the message. The tasks are in [Action.md](Action.md).
5
4
 
6
5
  ## At `make`
7
6
 
@@ -30,6 +29,7 @@ The tasks are in [Action.md](Action.md).
30
29
  - `HttpRouter.Request<"Requires", X>` still owed, or `Type 'X' is not assignable to type 'never'` at `runMain`, although `X`, a store the authorizer reads, is provided at startup: what a plain authorizer yields is a request-time requirement, and this one yields `X` on every call. Make `authorize` an Effect that yields `X` once and returns the function ([Authorize calls](Action.md#authorize-calls)).
31
30
  - `unknown` among a surface's requirements, and a type error where it is served or run, such as `Argument of type 'Layer<never, unknown, unknown>' is not assignable` at `HttpRouter.toWebHandler` or `Type 'unknown' is not assignable to type 'never'`: the implementations are typed `Action.AnyImplementation`, as a helper's parameter or a list's annotation. Make the helper generic over them, `<const Apps extends Action.AnyImplementation | ReadonlyArray<Action.AnyImplementation>>(apps: Apps)`, or drop the annotation.
32
31
  - `Request<"Requires", unknown>` where an implementation is served, or `unknown` among the services a call or a run still requires: TypeScript inferred no requirements for one handler of a record, as a data-first call whose type takes a parameter from no argument causes, `Effect.catchTag(effect, tag, f)` for one. Write that handler's Effect `effect.pipe(Effect.catchTag(tag, f))`, or as a generator.
32
+ - `Service not found: effect-actions/Action/Idempotency`, a defect where an action naming `idempotencyKey` is built: its actions were typed `Action.Any`, so no type required `Action.Idempotency`. Provide `Action.layerIdempotency` around the surfaces.
33
33
 
34
34
  ## At `client` and its calls
35
35
 
@@ -0,0 +1,115 @@
1
+ # Progress and streamed answers
2
+
3
+ An action answers once, at the end. Two options let a caller see more while it runs: `progress`, values the handler reports along the way, which a caller may miss some of, and `Action.Stream`, an answer of many elements, each arriving as it is produced. Both are declared on the contract ([Action.md](Action.md#define-a-contract)) and carried by every surface its own way: the declaration's JSDoc says how.
4
+
5
+ A message the library throws says its fix; for a compiler diagnostic or Effect's own error, search [Action-failures.md](Action-failures.md). For one symbol, `grep -rn <symbol> node_modules/@gjermundgaraba/effect-actions/dist/`.
6
+
7
+ ## Report progress
8
+
9
+ An action declaring `progress`, a schema or struct fields, gets a second argument in its handler, `{ progress }`, which reports a value to the caller without waiting for it; a client's call takes `{ onProgress }` after its input ([build-events/test/builds/progress.test.ts](../examples/build-events/test/builds/progress.test.ts)). The contract is `StartBuild` in [build-events/src/builds/Builds.ts](../examples/build-events/src/builds/Builds.ts).
10
+
11
+ ```ts example=build-events/src/builds/handlers.ts
12
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
13
+ import * as Event from "@gjermundgaraba/effect-actions/Event";
14
+ import { Effect } from "effect";
15
+
16
+ import {
17
+ BuildFinished,
18
+ FinishBuild,
19
+ PipelineStatus,
20
+ StartBuild,
21
+ } from "./Builds.js";
22
+ import type { Build } from "./Builds.js";
23
+ import { BuildStore } from "./store.js";
24
+
25
+ // DEMO ONLY: each step passes at once. A runner's steps take their time, which is why a caller
26
+ // wants to see them.
27
+ const steps = ["install", "build", "test"];
28
+
29
+ export const builds = Action.implement(
30
+ [FinishBuild, StartBuild],
31
+ Effect.gen(function* () {
32
+ const store = yield* BuildStore;
33
+ const events = yield* Event.Events;
34
+
35
+ // `build.finished` is a check source over the store: write the build there first, then wake
36
+ // its subscribers, who read it now rather than at their next poll. `pipeline.status` is a
37
+ // current source: the notify moves its pipeline's revision, so its subscribers alone read it.
38
+ const finished = (build: Effect.Effect<Build>) =>
39
+ Effect.tap(build, ({ pipeline }) =>
40
+ Effect.andThen(
41
+ events.notify(BuildFinished),
42
+ events.notify(PipelineStatus, { key: pipeline })
43
+ )
44
+ );
45
+
46
+ return {
47
+ finishBuild: ({ pipeline, status }) =>
48
+ finished(store.finish(pipeline, status, [status])),
49
+ // `progress` never waits for the caller: the build runs at its own pace.
50
+ startBuild: ({ pipeline }, { progress }) =>
51
+ Effect.andThen(
52
+ Effect.forEach(
53
+ steps,
54
+ (step, done) => progress({ step, done, total: steps.length }),
55
+ {
56
+ discard: true,
57
+ }
58
+ ),
59
+ finished(
60
+ store.finish(pipeline, "passed", [
61
+ ...steps.map((step) => `${step}: passed`),
62
+ "passed",
63
+ ])
64
+ )
65
+ ),
66
+ };
67
+ }),
68
+ { authorize: Action.allowAll }
69
+ );
70
+ ```
71
+
72
+ The rules are on `Action.Options.progress`; how each surface carries the values, on `ActionHttp.layer`, `ActionRpc.make`, `ActionMcp.layerHttp`, `ActionToolkit.make` and `ActionCli.command`.
73
+
74
+ - **Report a snapshot to show, never an event to count.** A caller falling behind loses the oldest values, never the answer: what a caller must receive each time is the answer, or an [event](Event.md).
75
+
76
+ ## Answer a stream
77
+
78
+ An action whose `success` is `Action.Stream(element)` has a handler returning a `Stream` of elements, and every client's call returns one ([build-events/test/builds/log.test.ts](../examples/build-events/test/builds/log.test.ts)). The contract is `TailLog` in [Builds.ts](../examples/build-events/src/builds/Builds.ts).
79
+
80
+ ```ts example=build-events/src/builds/log.ts
81
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
82
+ import { Effect, Stream } from "effect";
83
+
84
+ import { CurrentMember } from "../identity/Identity.js";
85
+ import { TailLog } from "./Builds.js";
86
+ import { BuildStore } from "./store.js";
87
+
88
+ export const logs = Action.implement(
89
+ TailLog,
90
+ Effect.gen(function* () {
91
+ const store = yield* BuildStore;
92
+
93
+ // The handler returns a `Stream`, run in a scope of its own that closes when the caller
94
+ // stops reading, so what it acquires is released then.
95
+ return ({ buildId }) =>
96
+ Stream.unwrap(
97
+ Effect.gen(function* () {
98
+ const member = yield* CurrentMember;
99
+ // A failure before the first line, `BuildNotFound` here, fails the call as an action
100
+ // answering one value fails, a 404 over HTTP; one after a line ends the stream, the
101
+ // lines before it already read.
102
+ const log = yield* store.logOf(member.pipelines, buildId);
103
+
104
+ return Stream.fromIterable(log.map((line) => ({ line })));
105
+ })
106
+ );
107
+ }),
108
+ // Which builds a member may read is the store's check, by pipeline.
109
+ { authorize: Action.allowAll }
110
+ );
111
+ ```
112
+
113
+ How each surface carries it is on `ActionHttp.layer`, `ActionRpc.make`, `ActionMcp.layerHttp`, `ActionToolkit.make` and `ActionCli.command`.
114
+
115
+ - **A tool collects it whole.** A tool answers one result, so an MCP endpoint and a toolkit answer the elements as one array once the stream ends, and `ActionMcp.client`'s method streams that array again: a stream that never ends never answers a tool, and a long one fills a model's context, so [kitchen-sink/src/Actions.ts](../examples/kitchen-sink/src/Actions.ts) leaves `exportIssues` out of its tools.