@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
@@ -0,0 +1,40 @@
1
+ import { Schema } from "effect";
2
+ import { Headers, HttpClientRequest } from "effect/http";
3
+ /**
4
+ * An MCP request's parameters, as `request` takes them: JSON, with any `_meta`, leaving
5
+ * out an `undefined` one, as a caller may. A helper passing on whole params types them as this,
6
+ * and a tool's `arguments` as `Schema.Json`, never `unknown`.
7
+ */
8
+ export interface Params {
9
+ readonly _meta?: {
10
+ readonly [key: string]: Schema.Json;
11
+ };
12
+ readonly [key: string]: Schema.Json | undefined;
13
+ }
14
+ /** Where a `request` goes, and with what headers. */
15
+ export interface RequestOptions {
16
+ /**
17
+ * The endpoint, resolved by the `HttpClient` that sends the request: relative under `Testing.layer`.
18
+ * Defaults to `/mcp`, as `client`'s does; converting to a web `Request` needs an absolute
19
+ * one.
20
+ */
21
+ readonly url?: string;
22
+ /**
23
+ * Headers sent beside the routing ones `request` fills in, such as `Authorization`; a routing
24
+ * header wins over a given one of its name.
25
+ */
26
+ readonly headers?: Headers.Input;
27
+ }
28
+ /**
29
+ * One stateless MCP request of `method` with `params`, as `layerHttp` serves it,
30
+ * as the native request value: the JSON-RPC envelope, the 2026-07-28 headers (`Accept`,
31
+ * `MCP-Protocol-Version`, `MCP-Method`), `mcp-name` from
32
+ * `params.uri` for `resources/read` and `params.name` otherwise, and the client metadata in
33
+ * `_meta` are filled in, under any `_meta` given, such as a `progressToken`; the protocol
34
+ * version is always the request's own. The test sends it: `HttpClient.execute` answers the
35
+ * response as the endpoint sent it, whatever its status, and `HttpClientRequest.toWebResult`
36
+ * gives the web `Request` a web handler or `fetch` takes, once `url` is absolute. It is for a test
37
+ * asserting on what `client` decodes away, such as `tools/list`, a refusal's challenge, or
38
+ * a call its types would not send.
39
+ */
40
+ export declare const request: (method: string, params?: Params, { headers, url }?: RequestOptions) => HttpClientRequest.HttpClientRequest;
@@ -0,0 +1,51 @@
1
+ import { defaultPath, httpProtocol } from "./protocol.mjs";
2
+ import { Predicate } from "effect";
3
+ import { HttpClientRequest } from "effect/http";
4
+ // src/mcp/request.ts
5
+ /** The routing headers and JSON-RPC body of the request `request` builds. */
6
+ const statelessRequest = (method, params) => {
7
+ const { protocolVersion } = httpProtocol;
8
+ const name = method === "resources/read" ? params["uri"] : params["name"];
9
+ return {
10
+ headers: {
11
+ accept: "application/json, text/event-stream",
12
+ "mcp-protocol-version": protocolVersion,
13
+ "mcp-method": method,
14
+ ...Predicate.isString(name) ? { "mcp-name": name } : {}
15
+ },
16
+ body: {
17
+ jsonrpc: "2.0",
18
+ id: 1,
19
+ method,
20
+ params: {
21
+ ...params,
22
+ _meta: {
23
+ "io.modelcontextprotocol/clientCapabilities": {},
24
+ "io.modelcontextprotocol/clientInfo": {
25
+ name: "effect-actions",
26
+ version: "0"
27
+ },
28
+ ...params._meta,
29
+ "io.modelcontextprotocol/protocolVersion": protocolVersion
30
+ }
31
+ }
32
+ }
33
+ };
34
+ };
35
+ /**
36
+ * One stateless MCP request of `method` with `params`, as `layerHttp` serves it,
37
+ * as the native request value: the JSON-RPC envelope, the 2026-07-28 headers (`Accept`,
38
+ * `MCP-Protocol-Version`, `MCP-Method`), `mcp-name` from
39
+ * `params.uri` for `resources/read` and `params.name` otherwise, and the client metadata in
40
+ * `_meta` are filled in, under any `_meta` given, such as a `progressToken`; the protocol
41
+ * version is always the request's own. The test sends it: `HttpClient.execute` answers the
42
+ * response as the endpoint sent it, whatever its status, and `HttpClientRequest.toWebResult`
43
+ * gives the web `Request` a web handler or `fetch` takes, once `url` is absolute. It is for a test
44
+ * asserting on what `client` decodes away, such as `tools/list`, a refusal's challenge, or
45
+ * a call its types would not send.
46
+ */
47
+ const request = (method, params = {}, { headers = {}, url = defaultPath } = {}) => {
48
+ const { headers: routingHeaders, body } = statelessRequest(method, params);
49
+ return HttpClientRequest.post(url).pipe(HttpClientRequest.setHeaders(headers), HttpClientRequest.setHeaders(routingHeaders), HttpClientRequest.bodyJsonUnsafe(body));
50
+ };
51
+ export { request, statelessRequest };
@@ -0,0 +1,115 @@
1
+ import { InvalidInput } from "../contract/errors.mjs";
2
+ import { isTransientStatus } from "../contract/status.mjs";
3
+ import { Checked, Gap, untaggedOccurrenceOf } from "../contract/occurrence.mjs";
4
+ import { defaultNextPoll } from "../contract/feed.mjs";
5
+ import { reasonOf } from "../contract/codes.mjs";
6
+ import { Receiving, webhookFed } from "../contract/receiving.mjs";
7
+ import { Failed, readOn } from "../contract/push.mjs";
8
+ import { WebhookCallback, WebhookTarget } from "../contract/subscribe.mjs";
9
+ import { failingWhenSilent, failingWhenUnanswered, isTransient } from "../contract/transient.mjs";
10
+ import { Active, Failed as Failed$1, Heartbeat, Occurred, PollEvents, SubscribeEvents, Terminated, UnsubscribeEvents, WireError, codes } from "./events.mjs";
11
+ import { McpCallError, NoReply, ProtocolError, StatusError, bodyOf, callError, failWith, framesOf, isEventStream } from "./reply.mjs";
12
+ import { request } from "./request.mjs";
13
+ import { Duration, Effect, Match, Option, Predicate, Schema, Stream } from "effect";
14
+ // src/mcp/watch.ts
15
+ /** Reads one message as a request's reply: its result, by `success`, or its error. */
16
+ const replyOf = (success) => Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Union([Schema.Struct({ result: success }), Schema.Struct({ error: WireError })])));
17
+ const pollReplyOf = replyOf(PollEvents.successSchema);
18
+ const Frame = Schema.Union([
19
+ Schema.Struct({ error: WireError }),
20
+ Schema.Struct({
21
+ method: Schema.String,
22
+ params: Schema.optional(Schema.Json)
23
+ }),
24
+ Schema.Struct({ result: Schema.Unknown })
25
+ ]);
26
+ const decodeFrame = Schema.decodeUnknownOption(Schema.fromJsonString(Frame));
27
+ const reading = (rpc, read) => {
28
+ const decode = Schema.decodeUnknownOption(rpc.payloadSchema);
29
+ return (params) => Option.map(decode(params), read);
30
+ };
31
+ const subscribeReplyOf = replyOf(SubscribeEvents.successSchema);
32
+ const unsubscribeReplyOf = replyOf(UnsubscribeEvents.successSchema);
33
+ const encodeCallback = Schema.encodeSync(WebhookCallback);
34
+ const encodeTarget = Schema.encodeSync(WebhookTarget);
35
+ const transientReason = (reason) => Match.value(reason).pipe(Match.tag("StatusError", ({ status }) => isTransientStatus(status)), Match.tag("ProtocolError", ({ code }) => code === codes.internal), Match.orElse(() => false));
36
+ const isMcpCallError = Schema.is(McpCallError);
37
+ const transient = Predicate.or(isTransient, Predicate.compose(isMcpCallError, ({ reason }) => transientReason(reason)));
38
+ /**
39
+ * The transport of one subscription over an MCP endpoint served by `layerHttp`: `events/poll`
40
+ * for each page, and `events/stream` read as it arrives, each failing as the endpoint answered.
41
+ */
42
+ const transportOf = ({ http, url, event, refusedWith }, input) => {
43
+ const { name } = event;
44
+ const untagged = untaggedOccurrenceOf(event);
45
+ const occurrenceOf = (wire) => untagged({
46
+ ...wire,
47
+ cursor: wire.cursor ?? null
48
+ });
49
+ const failed = (method, error) => reasonOf(name, error) ?? (error.code === codes.invalidParams ? new InvalidInput({ message: error.message }) : callError(method, name, new ProtocolError(error)));
50
+ const sent = (method, params) => Effect.filterOrElse(http.execute(request(method, {
51
+ name,
52
+ arguments: input,
53
+ ...params
54
+ }, { url })), (response) => response.status === 200, (response) => Effect.flatMap(response.text, (body) => failWith(refusedWith, body, callError(method, name, new StatusError({
55
+ status: response.status,
56
+ body
57
+ })))));
58
+ /** The result of one `method` request, or what its error reply fails with. */
59
+ const answered = (method, params, decodeReply) => Effect.gen(function* () {
60
+ const messages = yield* bodyOf(method, name, yield* sent(method, params));
61
+ const reply = messages.flatMap((message) => Option.toArray(decodeReply(message))).at(-1);
62
+ if (reply === void 0) return yield* callError(method, name, new NoReply({ body: messages.join("\n") }));
63
+ if ("error" in reply) return yield* failed(method, reply.error);
64
+ return reply.result;
65
+ }).pipe(failingWhenUnanswered);
66
+ const poll = (cursor) => Effect.gen(function* () {
67
+ const result = yield* answered("events/poll", { cursor }, pollReplyOf);
68
+ return {
69
+ events: yield* Effect.forEach(result.events, occurrenceOf),
70
+ cursor: result.cursor ?? null,
71
+ truncated: result.truncated === true,
72
+ hasMore: result.hasMore === true,
73
+ nextPoll: result.nextPollMs === void 0 ? defaultNextPoll : Duration.millis(result.nextPollMs)
74
+ };
75
+ });
76
+ const readers = new Map(Object.entries({
77
+ [Active._tag]: reading(Active, ({ cursor = null, truncated }) => Effect.succeed([truncated === true ? Gap.make({ cursor }) : Checked.make({ cursor })])),
78
+ [Occurred._tag]: reading(Occurred, (params) => Effect.map(occurrenceOf(params), (occurrence) => [occurrence])),
79
+ [Heartbeat._tag]: reading(Heartbeat, ({ cursor = null }) => Effect.succeed([Checked.make({ cursor })])),
80
+ [Failed$1._tag]: reading(Failed$1, () => Effect.succeed([Failed.make({})])),
81
+ [Terminated._tag]: reading(Terminated, ({ error }) => Effect.fail(failed("events/stream", error)))
82
+ }));
83
+ const framed = (data) => Option.match(decodeFrame(data), {
84
+ onNone: () => Effect.succeed([]),
85
+ onSome: (frame) => {
86
+ if ("error" in frame) return Effect.fail(failed("events/stream", frame.error));
87
+ if (!("method" in frame)) return Effect.succeed([]);
88
+ const reader = readers.get(frame.method);
89
+ if (reader === void 0) return Effect.succeed([]);
90
+ return Option.getOrElse(reader(frame.params), () => Effect.as(Effect.logWarning(`MCP event "${name}" sent a frame that does not decode: ${data}`), []));
91
+ }
92
+ });
93
+ const read = (response) => framesOf("events/stream", name, response).pipe(failingWhenSilent, Stream.mapEffect(framed), Stream.flattenIterable);
94
+ const stream = (cursor) => Stream.unwrap(Effect.map(failingWhenUnanswered(sent("events/stream", { cursor })), (response) => isEventStream(response) ? read(response) : Stream.fromIterableEffect(failingWhenUnanswered(Effect.flatMap(response.text, framed))))).pipe(readOn("MCP", name));
95
+ return {
96
+ name,
97
+ poll,
98
+ stream,
99
+ webhook: {
100
+ receiver: Effect.service(Receiving),
101
+ subscribe: ({ delivery, cursor, ttlMs }) => answered("events/subscribe", {
102
+ delivery: {
103
+ mode: "webhook",
104
+ ...encodeCallback(delivery)
105
+ },
106
+ cursor,
107
+ ...ttlMs === void 0 ? {} : { ttlMs }
108
+ }, subscribeReplyOf),
109
+ unsubscribe: ({ delivery }) => Effect.asVoid(answered("events/unsubscribe", { delivery: encodeTarget(delivery) }, unsubscribeReplyOf)),
110
+ read: webhookFed(event)
111
+ },
112
+ transient
113
+ };
114
+ };
115
+ export { transportOf };
@@ -0,0 +1,39 @@
1
+ import { NotServed, SubscriptionError, Unsupported } from "../Event.mjs";
2
+ import { readOn } from "../contract/push.mjs";
3
+ import { isTransientReason, receivedFrom } from "../contract/transient.mjs";
4
+ import { Cause, Effect, Match, Predicate, Schema, Stream } from "effect";
5
+ import { HttpClientError } from "effect/http";
6
+ import { RpcClientError } from "effect/rpc";
7
+ // src/rpc/watch.ts
8
+ const isRpcClientError = Schema.is(RpcClientError.RpcClientError);
9
+ const isAnsweredReason = Schema.is(Schema.Union([
10
+ Schema.instanceOf(HttpClientError.TransportError),
11
+ Schema.instanceOf(HttpClientError.StatusCodeError),
12
+ Schema.instanceOf(HttpClientError.DecodeError)
13
+ ]));
14
+ const cutResponses = ["HTTP response ended before RPC request completed", "Received empty HTTP response"];
15
+ /** Whether a watch's failure over an rpc protocol is one a reconnect may outlast. */
16
+ const isRpcTransient = Predicate.or(Cause.isTimeoutError, (error) => isRpcClientError(error) && Match.value(error.reason).pipe(Match.tag("SocketReadError", "SocketWriteError", "SocketOpenError", () => true), Match.tag("SocketCloseError", ({ code }) => code < 4e3 || code > 4999), Match.tag("HttpError", ({ cause }) => isAnsweredReason(cause) && isTransientReason(cause)), Match.tag("RpcClientDefect", ({ message }) => cutResponses.some((fragment) => message.includes(fragment))), Match.orElse(() => false)));
17
+ const isUnknownTag = (tag) => {
18
+ const message = `Unknown request tag: ${tag}`;
19
+ return (cause) => cause.reasons.some((reason) => Cause.isDieReason(reason) && (Predicate.hasProperty(reason.defect, "message") ? reason.defect.message === message : reason.defect === message));
20
+ };
21
+ /**
22
+ * The transport of one subscription to `event` over its stream rpc: the stream alone, since an
23
+ * rpc serves no poll, failing as `NotServed` where the server serves no such rpc, and once it goes
24
+ * silent where `silenceEnds`.
25
+ */
26
+ const transportOf = (event, stream, silenceEnds) => {
27
+ const notServed = Stream.fail(SubscriptionError.of(event.name, new NotServed()));
28
+ const unknownTag = isUnknownTag(event.name);
29
+ return {
30
+ name: event.name,
31
+ poll: () => Effect.fail(SubscriptionError.of(event.name, new Unsupported({
32
+ feature: "deliveryMode",
33
+ value: "poll"
34
+ }))),
35
+ stream: (cursor) => stream(cursor).pipe(Stream.catchCauseIf(unknownTag, () => notServed), silenceEnds ? receivedFrom("RPC", event.name) : readOn("RPC", event.name)),
36
+ transient: isRpcTransient
37
+ };
38
+ };
39
+ export { isRpcTransient, transportOf };
@@ -3,7 +3,9 @@ import { Clock, Console, Effect, Function, Predicate } from "effect";
3
3
  const stderrConsole = (console, clock) => {
4
4
  const counts = /* @__PURE__ */ new Map();
5
5
  const timers = /* @__PURE__ */ new Map();
6
- const write = (...args) => console.error(...args);
6
+ const write = (...args) => {
7
+ console.error(...args);
8
+ };
7
9
  const elapsed = (label, data) => {
8
10
  const start = timers.get(label);
9
11
  if (start !== void 0) {
@@ -32,7 +34,9 @@ const stderrConsole = (console, clock) => {
32
34
  counts.delete(label);
33
35
  },
34
36
  debug: write,
35
- dir: (item) => write("%O", item),
37
+ dir: (item) => {
38
+ write("%O", item);
39
+ },
36
40
  dirxml: write,
37
41
  error: write,
38
42
  group,
@@ -40,7 +44,9 @@ const stderrConsole = (console, clock) => {
40
44
  groupEnd: Function.constVoid,
41
45
  info: write,
42
46
  log: write,
43
- table: (data) => write(data),
47
+ table: (data) => {
48
+ write(data);
49
+ },
44
50
  time: (label = "default") => {
45
51
  if (!timers.has(label)) timers.set(label, clock.monotonicTimeNanosUnsafe());
46
52
  },
@@ -48,9 +54,11 @@ const stderrConsole = (console, clock) => {
48
54
  elapsed(label, []);
49
55
  timers.delete(label);
50
56
  },
51
- timeLog: (label = "default", ...data) => elapsed(label, data),
57
+ timeLog: (label = "default", ...data) => {
58
+ elapsed(label, data);
59
+ },
52
60
  trace: (...args) => {
53
- const { stack = "" } = /* @__PURE__ */ new Error();
61
+ const { stack = "" } = /* @__PURE__ */ new Error("console.trace");
54
62
  const [_header, _traceFrame, ...callerFrames] = stack.split("\n");
55
63
  const callerStack = callerFrames.join("\n");
56
64
  writeLabelKeepingFormat("Trace", args);
@@ -1,4 +1,4 @@
1
1
  import { Layer } from "effect";
2
2
  import { HttpClient, HttpRouter, HttpServer } from "effect/http";
3
3
  /** What in-memory routes may leave to the host: the router, the platform, nothing per request. */
4
- export type Served = HttpRouter.HttpRouter | HttpRouter.Request<"Requires", never> | HttpRouter.Request<"GlobalRequires", never> | HttpRouter.Request<"Error", any> | HttpRouter.Request<"GlobalError", any> | Layer.Success<typeof HttpServer.layerServices>;
4
+ export type Served = HttpRouter.HttpRouter | HttpRouter.Request<"Requires", never> | HttpRouter.Request<"GlobalRequires", never> | HttpRouter.Request<"Error", unknown> | HttpRouter.Request<"GlobalError", unknown> | Layer.Success<typeof HttpServer.layerServices>;
@@ -3,7 +3,7 @@ import { FetchHttpClient, HttpBody, HttpClient, HttpClientRequest } from "effect
3
3
  // src/testing/memory.ts
4
4
  const relativeUrlOrigin = "http://localhost/";
5
5
  const withWebStreamBodyInCurrentContext = (request) => {
6
- const body = request.body;
6
+ const { body } = request;
7
7
  if (!Predicate.isTagged(body, "Stream")) return Effect.succeed(request);
8
8
  return Effect.map(Stream.toReadableStreamEffect(body.stream), (readable) => HttpClientRequest.setBody(request, HttpBody.raw(readable, {
9
9
  contentType: body.contentType,
@@ -11,10 +11,10 @@ const withWebStreamBodyInCurrentContext = (request) => {
11
11
  })).pipe(HttpClientRequest.updateHeaders(() => request.headers)));
12
12
  };
13
13
  const clientOf = (handler) => {
14
- const fetch = (input, init) => {
14
+ const fetch = async (input, init) => {
15
15
  const request = new Request(input, init);
16
16
  if (!request.headers.has("host")) request.headers.set("host", new URL(request.url).host);
17
- return handler(request);
17
+ return await handler(request);
18
18
  };
19
19
  return Layer.effect(HttpClient.HttpClient, Effect.map(HttpClient.HttpClient, (native) => HttpClient.makeWith((request) => Effect.flatMap(request, (mapped) => {
20
20
  const resolved = URL.canParse(mapped.url) ? null : URL.parse(mapped.url, relativeUrlOrigin);
@@ -0,0 +1,47 @@
1
+ import { Effect, Function, Result } from "effect";
2
+ import { Host, IpNetwork, NetAddress } from "effect/net";
3
+ // src/webhook/address.ts
4
+ const notGlobal = [
5
+ "0.0.0.0/8",
6
+ "10.0.0.0/8",
7
+ "100.64.0.0/10",
8
+ "127.0.0.0/8",
9
+ "169.254.0.0/16",
10
+ "172.16.0.0/12",
11
+ "192.0.0.0/24",
12
+ "192.0.2.0/24",
13
+ "192.88.99.0/24",
14
+ "192.168.0.0/16",
15
+ "198.18.0.0/15",
16
+ "198.51.100.0/24",
17
+ "203.0.113.0/24",
18
+ "224.0.0.0/4",
19
+ "240.0.0.0/4",
20
+ "::/127",
21
+ "::ffff:0:0/96",
22
+ "64:ff9b::/96",
23
+ "64:ff9b:1::/48",
24
+ "100::/64",
25
+ "2001::/23",
26
+ "2001:db8::/32",
27
+ "2002::/16",
28
+ "3fff::/20",
29
+ "5f00::/16",
30
+ "fc00::/7",
31
+ "fe80::/10",
32
+ "ff00::/8"
33
+ ].map(IpNetwork.fromStringUnsafe);
34
+ /** Whether `address` is globally reachable, in no special-purpose range. */
35
+ const isGlobal = (address) => !notGlobal.some((network) => IpNetwork.contains(network, address));
36
+ /**
37
+ * Checks the host of `url`, failing unless every address it names is globally reachable: a
38
+ * literal address is checked as it is, a name through `dns`, or passed where there is none, and a
39
+ * scoped IPv6 literal refused.
40
+ */
41
+ const checkedHost = (url, dns) => {
42
+ const host = Result.getOrUndefined(Host.hostFromString(url.hostname.replace(/^\[(?<address>.*)\]$/u, "$<address>")));
43
+ if (!NetAddress.isIpAddress(host) && !Host.isDomainName(host)) return Effect.fail(void 0);
44
+ const addresses = NetAddress.isIpAddress(host) ? Effect.succeed([host]) : dns?.lookup(host) ?? Effect.succeed([]);
45
+ return Effect.flatMap(Effect.mapError(addresses, Function.constUndefined), (resolved) => resolved.every(isGlobal) ? Effect.void : Effect.fail(void 0));
46
+ };
47
+ export { checkedHost };
@@ -0,0 +1,65 @@
1
+ import { signature } from "./signing.mjs";
2
+ import { Clock, Data, Duration, Effect, Option, Schema, Stream } from "effect";
3
+ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
4
+ // src/webhook/attempt.ts
5
+ /**
6
+ * One attempt of a delivery that failed: `category` is SEP-3415's for it, and `final` says a
7
+ * retry would not help, as for a `410 Gone` or a `413 Payload Too Large`.
8
+ */
9
+ var AttemptFailed = class extends Data.TaggedError("AttemptFailed") {};
10
+ const timeout = Duration.seconds(5);
11
+ const statusCategory = (status) => status >= 500 ? "http_5xx" : "http_4xx";
12
+ const outbound = (effect) => Effect.flatMap(Effect.serviceOption(FetchHttpClient.RequestInit), (init) => effect.pipe(Effect.provideService(FetchHttpClient.RequestInit, {
13
+ ...Option.getOrUndefined(init),
14
+ redirect: "manual"
15
+ }), Effect.provideService(HttpClient.TracerPropagationEnabled, false)));
16
+ const sent = ({ url, subscriptionId, webhookId, body, keys }, checked, answer) => Effect.gen(function* () {
17
+ const unreached = new AttemptFailed({
18
+ category: "connection_refused",
19
+ final: false
20
+ });
21
+ const timestamp = Math.floor((yield* Clock.currentTimeMillis) / 1e3);
22
+ const signed = yield* signature(keys, webhookId, timestamp, body);
23
+ const client = HttpClient.withScope(yield* HttpClient.HttpClient);
24
+ const request = HttpClientRequest.post(url).pipe(HttpClientRequest.setHeaders({
25
+ "webhook-id": webhookId,
26
+ "webhook-timestamp": String(timestamp),
27
+ "webhook-signature": signed,
28
+ "x-mcp-subscription-id": subscriptionId
29
+ }), HttpClientRequest.bodyText(body, "application/json"));
30
+ const answered = Effect.flatMap(Effect.mapError(client.execute(request), () => unreached), (response) => response.status >= 200 && response.status < 300 ? answer(response) : Effect.fail(new AttemptFailed({
31
+ category: statusCategory(response.status),
32
+ final: response.status === 410 || response.status === 413
33
+ })));
34
+ return yield* Effect.andThen(Effect.mapError(checked, () => unreached), outbound(answered)).pipe(Effect.timeoutOrElse({
35
+ duration: timeout,
36
+ orElse: () => Effect.fail(new AttemptFailed({
37
+ category: "timeout",
38
+ final: false
39
+ }))
40
+ }), Effect.scoped);
41
+ });
42
+ /**
43
+ * POSTs `body` to the callback, signed with a fresh timestamp, once `checked`, its address check,
44
+ * passes, and succeeds at a 2xx without reading its body. The timeout bounds the check and the
45
+ * POST together.
46
+ */
47
+ const attempt = (posting, checked) => sent(posting, checked, () => Effect.void);
48
+ const Challenged = Schema.fromJsonString(Schema.Struct({ challenge: Schema.String }));
49
+ const answerLimit = 4096;
50
+ const textOf = (response) => Effect.suspend(() => {
51
+ const decoder = new TextDecoder();
52
+ return response.stream.pipe(Stream.scan(() => ({
53
+ bytes: 0,
54
+ text: ""
55
+ }), ({ bytes, text }, chunk) => ({
56
+ bytes: bytes + chunk.length,
57
+ text: text + decoder.decode(chunk, { stream: true })
58
+ })), Stream.takeUntil(({ bytes }) => bytes > answerLimit), Stream.runLast, Effect.map((last) => Option.map(Option.filter(last, ({ bytes }) => bytes <= answerLimit), ({ text }) => text + decoder.decode())), Effect.orElseSucceed(() => Option.none()));
59
+ });
60
+ /**
61
+ * `attempt` of a verification: the challenge its 2xx answer echoes, where its first 4 KiB hold
62
+ * one.
63
+ */
64
+ const challenged = (posting, checked) => sent(posting, checked, (response) => Effect.map(textOf(response), (text) => Option.getOrUndefined(Option.map(Option.flatMap(text, Schema.decodeUnknownOption(Challenged)), ({ challenge }) => challenge))));
65
+ export { AttemptFailed, attempt, challenged };
@@ -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 };