@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,74 @@
1
+ import * as Event from "@gjermundgaraba/effect-actions/Event";
2
+ import { Effect, Option, Schema } from "effect";
3
+
4
+ import { authorize } from "./authorize.js";
5
+ import { MessagePosted } from "./Chat.js";
6
+ import { CurrentRoom } from "./room.js";
7
+ import { MessageStore } from "./store.js";
8
+
9
+ const idOf = Schema.decodeUnknownOption(
10
+ Schema.NumberFromString.pipe(Schema.check(Schema.isInt()))
11
+ );
12
+
13
+ // A cursor this source gives is `<epoch>:<id>`, the store's epoch and the id of the last message
14
+ // a page holds, so one from before a restart of the memory store is told apart. A page starting
15
+ // a watch gives the id alone, as `readMessages`' `after` is, which is the store's now.
16
+ const positionOf = (epoch: string, cursor: string | null) => {
17
+ const parts =
18
+ cursor?.includes(":") === true ? cursor.split(":") : [epoch, cursor];
19
+
20
+ return parts.length === 2 && parts[0] === epoch
21
+ ? idOf(parts[1])
22
+ : Option.none();
23
+ };
24
+
25
+ // The room's messages as an event, read from the store that keeps them: a check source.
26
+ // `postMessage` notifies after each post, so a page receives it at once, not at its next read
27
+ // every 5 seconds.
28
+ export const messageEvents = Event.implement(
29
+ MessagePosted,
30
+ Effect.gen(function* () {
31
+ const store = yield* MessageStore;
32
+
33
+ return {
34
+ check: (_input, cursor) =>
35
+ Effect.gen(function* () {
36
+ const room = yield* CurrentRoom;
37
+ const last = yield* store.last(room);
38
+
39
+ const after = Option.filter(
40
+ positionOf(store.epoch, cursor),
41
+ (id) => id >= 0 && id <= last
42
+ );
43
+
44
+ // No cursor starts after the room's last message. One this room never gave, such as
45
+ // one from before a restart, is answered `truncated` from there: the page learns its
46
+ // messages may be another run's, and reads the room again with `readMessages`.
47
+ if (Option.isNone(after)) {
48
+ return {
49
+ cursor: `${store.epoch}:${last}`,
50
+ events: [],
51
+ truncated: cursor !== null,
52
+ };
53
+ }
54
+
55
+ const messages = yield* store.read(room, after.value);
56
+
57
+ return {
58
+ cursor: `${store.epoch}:${messages.at(-1)?.id ?? after.value}`,
59
+ // A message's id counts within its room and the store's epoch, so the event's id
60
+ // names all three: a watch drops an id it has seen, and after a restart of the memory
61
+ // store `lobby:1` is another message.
62
+ events: messages.map((data) => ({
63
+ eventId: `${store.epoch}:${room}:${data.id}`,
64
+ data,
65
+ })),
66
+ };
67
+ }),
68
+ // A notify for one room wakes that room's pages alone: the key is the room the watch's
69
+ // header names, read as `check` reads it.
70
+ key: () => CurrentRoom,
71
+ };
72
+ }),
73
+ { authorize }
74
+ );
@@ -1,8 +1,10 @@
1
- import { Effect } from "effect";
2
1
  import * as Action from "@gjermundgaraba/effect-actions/Action";
2
+ import * as Event from "@gjermundgaraba/effect-actions/Event";
3
+ import { Effect } from "effect";
4
+
3
5
  import { CurrentMember } from "../identity/Identity.js";
4
6
  import { authorize } from "./authorize.js";
5
- import { ListRooms, Messages } from "./Chat.js";
7
+ import { ListRooms, MessagePosted, Messages } from "./Chat.js";
6
8
  import { CurrentRoom } from "./room.js";
7
9
  import { MessageStore } from "./store.js";
8
10
 
@@ -13,7 +15,7 @@ export const rooms = Action.implement(
13
15
  const store = yield* MessageStore;
14
16
 
15
17
  return () => Effect.map(store.rooms, (found) => ({ rooms: found }));
16
- }),
18
+ })
17
19
  );
18
20
 
19
21
  // The builder runs once, at startup: it is where a handler gets its startup services. The
@@ -22,6 +24,7 @@ export const messages = Action.implement(
22
24
  Messages,
23
25
  Effect.gen(function* () {
24
26
  const store = yield* MessageStore;
27
+ const events = yield* Event.Events;
25
28
 
26
29
  return {
27
30
  readMessages: ({ after }) =>
@@ -30,12 +33,17 @@ export const messages = Action.implement(
30
33
 
31
34
  return { messages: yield* store.read(room, after) };
32
35
  }),
36
+ // The message is in the store, which `MessagePosted`'s check reads: notifying wakes the
37
+ // pages watching this room to read it now.
33
38
  postMessage: ({ text }) =>
34
39
  Effect.gen(function* () {
35
40
  const room = yield* CurrentRoom;
36
41
  const member = yield* CurrentMember;
42
+ const message = yield* store.post(room, member.id, text);
43
+
44
+ yield* events.notify(MessagePosted, { key: room });
37
45
 
38
- return yield* store.post(room, member.id, text);
46
+ return message;
39
47
  }),
40
48
  deleteMessage: ({ id }) =>
41
49
  Effect.gen(function* () {
@@ -46,5 +54,5 @@ export const messages = Action.implement(
46
54
  }),
47
55
  };
48
56
  }),
49
- { authorize },
57
+ { authorize }
50
58
  );
@@ -1,23 +1,27 @@
1
1
  import { Context, Effect, Layer } from "effect";
2
2
  import { RpcMiddleware } from "effect/rpc";
3
+
3
4
  import { defaultRoom, roomHeader } from "./Chat.js";
4
5
 
5
6
  /**
6
7
  * The room a message is about. A request-time service: handlers and `authorize` read it per
7
8
  * call, and it has no Layer, because no one value of it is right for a whole server.
8
9
  */
9
- export class CurrentRoom extends Context.Service<CurrentRoom, string>()("live-rpc/CurrentRoom") {}
10
+ export class CurrentRoom extends Context.Service<CurrentRoom, string>()(
11
+ "live-rpc/CurrentRoom"
12
+ ) {}
10
13
 
11
14
  // Native Effect RPC middleware, declared as providing `CurrentRoom`. Listed in
12
15
  // `ActionRpc.layer`'s `middleware`, it runs once per message.
13
- export class ResolveRoom extends RpcMiddleware.Service<ResolveRoom, { provides: CurrentRoom }>()(
14
- "live-rpc/ResolveRoom",
15
- ) {}
16
+ export class ResolveRoom extends RpcMiddleware.Service<
17
+ ResolveRoom,
18
+ { provides: CurrentRoom }
19
+ >()("live-rpc/ResolveRoom") {}
16
20
 
17
21
  // The middleware's implementation. It gets the message's headers, which over a WebSocket are
18
22
  // the upgrade request's merged with the message's own, and runs the rest of the call with the
19
23
  // room they name, or the default room when they name none. It refuses nothing: whether the
20
24
  // caller may be in that room is `authorize`'s rule.
21
25
  export const resolveRoom = Layer.succeed(ResolveRoom, (call, { headers }) =>
22
- Effect.provideService(call, CurrentRoom, headers[roomHeader] ?? defaultRoom),
26
+ Effect.provideService(call, CurrentRoom, headers[roomHeader] ?? defaultRoom)
23
27
  );
@@ -1,5 +1,7 @@
1
1
  import { Context, Effect, Layer } from "effect";
2
- import { type Message, MessageNotFound, NotAuthor } from "./Chat.js";
2
+
3
+ import { MessageNotFound, NotAuthor } from "./Chat.js";
4
+ import type { Message } from "./Chat.js";
3
5
 
4
6
  /**
5
7
  * Where messages live, by room. That only its author deletes a message is checked here, in the
@@ -8,34 +10,62 @@ import { type Message, MessageNotFound, NotAuthor } from "./Chat.js";
8
10
  export class MessageStore extends Context.Service<
9
11
  MessageStore,
10
12
  {
11
- readonly rooms: Effect.Effect<ReadonlyArray<{ name: string; messages: number }>>;
12
- readonly read: (room: string, after: number) => Effect.Effect<ReadonlyArray<Message>>;
13
- readonly post: (room: string, author: string, text: string) => Effect.Effect<Message>;
13
+ /** The store's epoch: a message's id is unique within it, and a restarted memory store starts another. */
14
+ readonly epoch: string;
15
+ readonly rooms: Effect.Effect<
16
+ ReadonlyArray<{ name: string; messages: number }>
17
+ >;
18
+ readonly read: (
19
+ room: string,
20
+ after: number
21
+ ) => Effect.Effect<readonly Message[]>;
22
+ /** The id of the room's last message posted, kept or deleted: 0 for an empty room. */
23
+ readonly last: (room: string) => Effect.Effect<number>;
24
+ readonly post: (
25
+ room: string,
26
+ author: string,
27
+ text: string
28
+ ) => Effect.Effect<Message>;
14
29
  readonly remove: (
15
30
  room: string,
16
31
  author: string,
17
- id: number,
32
+ id: number
18
33
  ) => Effect.Effect<void, MessageNotFound | NotAuthor>;
19
34
  }
20
35
  >()("live-rpc/MessageStore") {
21
- // DEMO ONLY: memory, lost when the process stops. A real store is another Layer of this
22
- // service, chosen by the entrypoint.
36
+ // DEMO ONLY: memory, lost when the process stops, its ids then counting from 1 again, so each
37
+ // start is another epoch. A real store is another Layer of this service, chosen by the
38
+ // entrypoint, whose ids never repeat.
23
39
  static readonly layerMemory = Layer.sync(MessageStore, () => {
24
- const rooms = new Map<string, { last: number; messages: ReadonlyArray<Message> }>();
40
+ const epoch = crypto.randomUUID();
41
+
42
+ const rooms = new Map<
43
+ string,
44
+ { last: number; messages: readonly Message[] }
45
+ >();
25
46
 
26
47
  const room = (name: string) => rooms.get(name) ?? { last: 0, messages: [] };
27
48
 
28
49
  return MessageStore.of({
50
+ epoch,
29
51
  rooms: Effect.sync(() =>
30
- Array.from(rooms, ([name, { messages }]) => ({ name, messages: messages.length })),
52
+ Array.from(rooms, ([name, { messages }]) => ({
53
+ name,
54
+ messages: messages.length,
55
+ }))
31
56
  ),
32
- read: (name, after) => Effect.sync(() => room(name).messages.filter(({ id }) => id > after)),
57
+ read: (name, after) =>
58
+ Effect.sync(() => room(name).messages.filter(({ id }) => id > after)),
59
+ last: (name) => Effect.sync(() => room(name).last),
33
60
  post: (name, author, text) =>
34
61
  Effect.sync(() => {
35
62
  const { last, messages } = room(name);
36
63
  const message = { id: last + 1, author, text };
37
64
 
38
- rooms.set(name, { last: message.id, messages: [...messages, message] });
65
+ rooms.set(name, {
66
+ last: message.id,
67
+ messages: [...messages, message],
68
+ });
39
69
 
40
70
  return message;
41
71
  }),
@@ -43,11 +73,18 @@ export class MessageStore extends Context.Service<
43
73
  const { last, messages } = room(name);
44
74
  const message = messages.find((candidate) => candidate.id === id);
45
75
 
46
- if (message === undefined) return yield* new MessageNotFound({ id });
76
+ if (message === undefined) {
77
+ return yield* new MessageNotFound({ id });
78
+ }
47
79
 
48
- if (message.author !== author) return yield* new NotAuthor({ id });
80
+ if (message.author !== author) {
81
+ return yield* new NotAuthor({ id });
82
+ }
49
83
 
50
- rooms.set(name, { last, messages: messages.filter((kept) => kept !== message) });
84
+ rooms.set(name, {
85
+ last,
86
+ messages: messages.filter((kept) => kept !== message),
87
+ });
51
88
  }),
52
89
  });
53
90
  });
@@ -1,5 +1,6 @@
1
1
  import { NodeRuntime, NodeSocket } from "@effect/platform-node";
2
- import { Config, Console, Effect, Layer } from "effect";
2
+ import { Config, Console, Effect, Layer, Stream } from "effect";
3
+
3
4
  import { connection, openChat } from "./browser.js";
4
5
 
5
6
  // Entrypoint: `node --import tsx examples/live-rpc/src/client.ts`, with the server running.
@@ -17,19 +18,35 @@ const visit = Effect.gen(function* () {
17
18
 
18
19
  yield* Console.log("ada posts:", posted);
19
20
  yield* Console.log("grace reads:", yield* grace.read(posted.id - 1));
21
+ // A watch from the last id grace held before the post: it sends the post, then would send
22
+ // each one after it, until taken.
23
+ yield* Console.log(
24
+ "grace watches:",
25
+ yield* grace.messages(posted.id - 1).pipe(Stream.take(1), Stream.runCollect)
26
+ );
20
27
  yield* Console.log("grace removes it:", yield* grace.remove(posted.id));
21
28
  yield* Console.log("ada removes it:", yield* ada.remove(posted.id));
22
- yield* Console.log("grace reads ops:", yield* chat.room("ops", "grace").read(0));
23
- yield* Console.log("an expired token reads:", yield* chat.room("lobby", "expired").read(0));
29
+ yield* Console.log(
30
+ "grace reads ops:",
31
+ yield* chat.room("ops", "grace").read(0)
32
+ );
33
+ yield* Console.log(
34
+ "an expired token reads:",
35
+ yield* chat.room("lobby", "expired").read(0)
36
+ );
24
37
  });
25
38
 
26
39
  const main = Effect.gen(function* () {
27
- const url = yield* Config.String("CHAT_URL").pipe(Config.withDefault("ws://127.0.0.1:3000/rpc"));
40
+ const url = yield* Config.String("CHAT_URL").pipe(
41
+ Config.withDefault("ws://127.0.0.1:3000/rpc")
42
+ );
28
43
 
29
44
  // One socket for the whole visit, so both members' calls share it.
30
45
  yield* visit.pipe(
31
46
  Effect.scoped,
32
- Effect.provide(connection(url).pipe(Layer.provide(NodeSocket.layerWebSocketConstructor))),
47
+ Effect.provide(
48
+ connection(url).pipe(Layer.provide(NodeSocket.layerWebSocketConstructor))
49
+ )
33
50
  );
34
51
  });
35
52
 
@@ -1,9 +1,9 @@
1
- import { Context } from "effect";
2
1
  import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
2
+ import { Context } from "effect";
3
3
 
4
4
  export interface Member {
5
5
  readonly id: string;
6
- readonly rooms: ReadonlyArray<string>;
6
+ readonly rooms: readonly string[];
7
7
  }
8
8
 
9
9
  /**
@@ -11,7 +11,7 @@ export interface Member {
11
11
  * provides it for each message, and a test calling in process provides it itself.
12
12
  */
13
13
  export class CurrentMember extends Context.Service<CurrentMember, Member>()(
14
- "live-rpc/CurrentMember",
14
+ "live-rpc/CurrentMember"
15
15
  ) {}
16
16
 
17
17
  // How a remote caller proves it is CurrentMember: a bearer token, the default scheme. Clients
@@ -1,20 +1,24 @@
1
- import { Effect, Redacted } from "effect";
2
1
  import * as Action from "@gjermundgaraba/effect-actions/Action";
3
2
  import * as Authentication from "@gjermundgaraba/effect-actions/Authentication";
3
+ import { Effect, Redacted } from "effect";
4
+
4
5
  import { Login } from "./Identity.js";
5
6
  import { members } from "./members.js";
6
7
 
7
- const isToken = (token: string): token is keyof typeof members => Object.hasOwn(members, token);
8
+ const isToken = (token: string): token is keyof typeof members =>
9
+ Object.hasOwn(members, token);
8
10
 
9
11
  // DEMO ONLY: a token is a member's name. A real verifier checks a signed token with your
10
12
  // authorization server's library, its audience included. An unknown token is the built-in
11
13
  // `Unauthenticated`, which every client decodes.
12
- const verify = (token: Redacted.Redacted<string>) => {
14
+ const verify = (token: Redacted.Redacted) => {
13
15
  const name = Redacted.value(token);
14
16
 
15
17
  return isToken(name)
16
18
  ? Effect.succeed(members[name])
17
- : Effect.fail(new Action.Unauthenticated({ message: "Unknown demo token." }));
19
+ : Effect.fail(
20
+ new Action.Unauthenticated({ message: "Unknown demo token." })
21
+ );
18
22
  };
19
23
 
20
24
  // Server only: `Login`'s provider. Over RPC it runs for every message of a protected rpc, not
@@ -1,34 +1,80 @@
1
- import { Context, Effect, Layer, ManagedRuntime } from "effect";
2
- import { RpcClient } from "effect/rpc";
1
+ import type * as Event from "@gjermundgaraba/effect-actions/Event";
2
+ import { Context, Effect, Exit, Layer, ManagedRuntime, Stream } from "effect";
3
+ import type { RpcClient } from "effect/rpc";
3
4
  import { Socket } from "effect/socket";
5
+
4
6
  import { connection, openChat } from "./browser.js";
7
+ import type { MessagePosted } from "./chat/Chat.js";
5
8
 
6
9
  // The chat for a page that is not one Effect program: the same client, each call a promise.
7
10
  // A `ManagedRuntime` keeps the protocol's layer, and so the socket, between calls. It builds
8
11
  // the layer on the first call, which is when the socket opens, and `close` releases it. A
9
12
  // failure `openChat` does not handle, such as `InvalidInput`, rejects the promise.
10
- class Chat extends Context.Service<Chat, Effect.Success<typeof openChat>>()("live-rpc/Chat") {}
13
+ class Chat extends Context.Service<Chat, Effect.Success<typeof openChat>>()(
14
+ "live-rpc/Chat"
15
+ ) {}
11
16
 
12
17
  export const chatOver = <E>(protocol: Layer.Layer<RpcClient.Protocol, E>) => {
13
- const runtime = ManagedRuntime.make(Layer.effect(Chat, openChat).pipe(Layer.provide(protocol)));
18
+ const runtime = ManagedRuntime.make(
19
+ Layer.effect(Chat, openChat).pipe(Layer.provide(protocol))
20
+ );
14
21
 
15
- const call = <A, E2>(use: (chat: Chat["Service"]) => Effect.Effect<A, E2>) =>
16
- runtime.runPromise(Effect.flatMap(Chat, use));
22
+ const call = async <A, E2>(
23
+ use: (chat: Chat["Service"]) => Effect.Effect<A, E2>
24
+ ) => await runtime.runPromise(Effect.flatMap(Chat, use));
17
25
 
18
26
  return {
19
- rooms: () => call((chat) => chat.rooms),
27
+ rooms: async () => await call((chat) => chat.rooms),
20
28
 
21
29
  room: (name: string, token: string) => ({
22
- read: (after: number) => call((chat) => chat.room(name, token).read(after)),
23
- post: (text: string) => call((chat) => chat.room(name, token).post(text)),
24
- remove: (id: number) => call((chat) => chat.room(name, token).remove(id)),
30
+ read: async (after: number) =>
31
+ await call((chat) => chat.room(name, token).read(after)),
32
+ post: async (text: string) =>
33
+ await call((chat) => chat.room(name, token).post(text)),
34
+ remove: async (id: number) =>
35
+ await call((chat) => chat.room(name, token).remove(id)),
36
+ // Calls `receive` with each message posted after `after`, the id of the last message the
37
+ // page holds, and each gap, as `messages` streams them, until the page calls the function
38
+ // it returns. `ended` is told why it stopped: a refusal, such as a token that expired by
39
+ // the time the watch connected again, or anything else, the page's own call included.
40
+ watch: (
41
+ after: number,
42
+ receive: (received: Event.Received<typeof MessagePosted>) => void,
43
+ ended: (why: "signed-out" | "not-a-member" | "stopped") => void
44
+ ) =>
45
+ runtime.runCallback(
46
+ Effect.flatMap(Chat, (chat) =>
47
+ Stream.runForEach(
48
+ chat.room(name, token).messages(after),
49
+ (received) =>
50
+ Effect.sync(() => {
51
+ receive(received);
52
+ })
53
+ )
54
+ ).pipe(
55
+ Effect.as("stopped" as const),
56
+ Effect.catchTags({
57
+ Unauthenticated: () => Effect.succeed("signed-out" as const),
58
+ Forbidden: () => Effect.succeed("not-a-member" as const),
59
+ })
60
+ ),
61
+ {
62
+ onExit: (exit) => {
63
+ ended(Exit.isSuccess(exit) ? exit.value : "stopped");
64
+ },
65
+ }
66
+ ),
25
67
  }),
26
68
 
27
- close: () => runtime.dispose(),
69
+ close: async () => {
70
+ await runtime.dispose();
71
+ },
28
72
  };
29
73
  };
30
74
 
31
75
  // What a page calls: `const chat = connect("wss://chat.example/rpc")`, on the browser's own
32
76
  // WebSocket, then `await chat.room("lobby", token).post("Hello")`.
33
77
  export const connect = (url: string) =>
34
- chatOver(connection(url).pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)));
78
+ chatOver(
79
+ connection(url).pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
80
+ );
@@ -1,5 +1,7 @@
1
- import { Layer } from "effect";
2
1
  import * as ActionRpc from "@gjermundgaraba/effect-actions/ActionRpc";
2
+ import { Layer } from "effect";
3
+
4
+ import { messageEvents } from "./chat/events.js";
3
5
  import { messages, rooms } from "./chat/handlers.js";
4
6
  import { ResolveRoom, resolveRoom } from "./chat/room.js";
5
7
  import { authenticate } from "./identity/verifier.js";
@@ -8,14 +10,16 @@ import { Rpc } from "./Rpc.js";
8
10
  // The binding's rpcs on Effect's own RpcServer, the protected ones behind `Login`'s verifier;
9
11
  // `listRooms` needs no token.
10
12
  //
11
- // `ResolveRoom`, listed in `middleware`, provides `CurrentRoom`, which the handlers and
12
- // `authorize` read. Only something that runs per message can, since each message names its own
13
- // room: `Layer.succeed(CurrentRoom, "lobby")` provided here would leave the layer still
14
- // requiring it, in its type.
13
+ // `ResolveRoom`, listed in `middleware`, provides `CurrentRoom`, which the handlers, the event's
14
+ // source and `authorize` read; on a watch it runs as the watch starts, and each time it connects
15
+ // again. Only something that runs per message can, since each message names its own room:
16
+ // `Layer.succeed(CurrentRoom, "lobby")` provided here would leave the layer still requiring it,
17
+ // in its type.
15
18
  //
16
- // It still requires three things, each chosen by whoever serves it. The protocol and its
19
+ // It still requires four things, each chosen by whoever serves it. The protocol and its
17
20
  // serialization: server.ts gives it a WebSocket, and a test Effect's HTTP protocol, which
18
- // answers in memory. And `MessageStore`.
19
- export const rpc = ActionRpc.layer(Rpc, [rooms, messages], { middleware: [ResolveRoom] }).pipe(
20
- Layer.provide([resolveRoom, authenticate]),
21
- );
21
+ // answers in memory. `MessageStore`, and `Event.Events`, which `postMessage` notifies and the
22
+ // watches wait on: one of each, provided around the whole server.
23
+ export const rpc = ActionRpc.layer(Rpc, [rooms, messages, messageEvents], {
24
+ middleware: [ResolveRoom],
25
+ }).pipe(Layer.provide([resolveRoom, authenticate]));
@@ -1,8 +1,11 @@
1
1
  import { createServer } from "node:http";
2
+
2
3
  import { NodeHttpServer, NodeRuntime } from "@effect/platform-node";
4
+ import * as Event from "@gjermundgaraba/effect-actions/Event";
3
5
  import { Config, Effect, Layer } from "effect";
4
6
  import { HttpRouter } from "effect/http";
5
7
  import { RpcSerialization, RpcServer } from "effect/rpc";
8
+
6
9
  import { MessageStore } from "./chat/store.js";
7
10
  import { rpc } from "./routes.js";
8
11
 
@@ -15,13 +18,16 @@ import { rpc } from "./routes.js";
15
18
  // serialization.
16
19
  const socket = rpc.pipe(
17
20
  Layer.provide(RpcServer.layerProtocolWebsocket({ path: "/rpc" })),
18
- Layer.provide(RpcSerialization.layerJson),
21
+ Layer.provide(RpcSerialization.layerJson)
19
22
  );
20
23
 
21
24
  const server = Layer.unwrap(
22
25
  Effect.gen(function* () {
23
26
  // The loopback default serves this machine alone; HOST=0.0.0.0 lets others reach it.
24
- const host = yield* Config.String("HOST").pipe(Config.withDefault("127.0.0.1"));
27
+ const host = yield* Config.String("HOST").pipe(
28
+ Config.withDefault("127.0.0.1")
29
+ );
30
+
25
31
  const port = yield* Config.Port("PORT").pipe(Config.withDefault(3000));
26
32
 
27
33
  return HttpRouter.serve(socket).pipe(
@@ -33,13 +39,17 @@ const server = Layer.unwrap(
33
39
  // does not bound it: Node's WebSocket server reads one of up to 100 MiB whole unless
34
40
  // told otherwise. A chat message is short, and a larger one closes the socket (1009).
35
41
  websocket: { maxPayload: 64 * 1024 },
36
- }),
42
+ })
37
43
  ),
38
44
  // DEMO ONLY: messages live in this process's memory; a deployment provides a store that
39
45
  // lasts.
40
46
  Layer.provide(MessageStore.layerMemory),
47
+ // The pages watching a room are this process's sockets, so what wakes them lives in its
48
+ // memory too: one for the whole server. Several processes behind a balancer would each
49
+ // wake only their own pages, the others reading a post at their next read, 5 seconds on.
50
+ Layer.provide(Event.layerMemory)
41
51
  );
42
- }),
52
+ })
43
53
  );
44
54
 
45
- Layer.launch(server).pipe(NodeRuntime.runMain);
55
+ Layer.launch(server).pipe(NodeRuntime.runMain());