@gjermundgaraba/effect-actions 0.18.2 → 0.20.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 (155) hide show
  1. package/README.md +20 -47
  2. package/dist/Action.d.mts +96 -49
  3. package/dist/Action.mjs +12 -16
  4. package/dist/ActionCli.d.mts +82 -44
  5. package/dist/ActionCli.mjs +54 -35
  6. package/dist/ActionHttp.d.mts +84 -45
  7. package/dist/ActionHttp.mjs +3 -4
  8. package/dist/ActionMcp.d.mts +54 -29
  9. package/dist/ActionMcp.mjs +33 -15
  10. package/dist/ActionRpc.d.mts +48 -37
  11. package/dist/ActionRpc.mjs +3 -4
  12. package/dist/ActionToolkit.d.mts +32 -15
  13. package/dist/ActionToolkit.mjs +11 -9
  14. package/dist/Authentication.d.mts +92 -42
  15. package/dist/Authentication.mjs +38 -37
  16. package/dist/Testing.d.mts +23 -27
  17. package/dist/Testing.mjs +1 -2
  18. package/dist/authentication/provider.d.mts +1 -6
  19. package/dist/authentication/provider.mjs +2 -3
  20. package/dist/authentication/refusal.mjs +3 -3
  21. package/dist/cli/commands.d.mts +34 -13
  22. package/dist/cli/commands.mjs +48 -24
  23. package/dist/contract/ast.mjs +1 -2
  24. package/dist/contract/call.d.mts +1 -3
  25. package/dist/contract/call.mjs +1 -2
  26. package/dist/contract/caller.d.mts +1 -3
  27. package/dist/contract/caller.mjs +2 -3
  28. package/dist/contract/declared.d.mts +0 -2
  29. package/dist/contract/declared.mjs +28 -16
  30. package/dist/contract/errors.d.mts +42 -19
  31. package/dist/contract/errors.mjs +43 -19
  32. package/dist/contract/hints.mjs +4 -4
  33. package/dist/contract/implementation.d.mts +7 -14
  34. package/dist/contract/implementation.mjs +31 -19
  35. package/dist/contract/media.d.mts +7 -5
  36. package/dist/contract/media.mjs +7 -4
  37. package/dist/contract/names.mjs +15 -14
  38. package/dist/contract/status.mjs +4 -12
  39. package/dist/http/client.d.mts +15 -6
  40. package/dist/http/client.mjs +2 -3
  41. package/dist/http/schema-errors.mjs +1 -2
  42. package/dist/mcp/client.d.mts +21 -10
  43. package/dist/mcp/client.mjs +8 -11
  44. package/dist/mcp/protocol.mjs +1 -2
  45. package/dist/stdio/console.mjs +1 -2
  46. package/dist/testing/memory.d.mts +1 -3
  47. package/dist/testing/memory.mjs +1 -2
  48. package/docs/Action-failures.md +6 -15
  49. package/docs/Action.md +159 -191
  50. package/docs/ActionCli-failures.md +12 -92
  51. package/docs/ActionCli.md +102 -156
  52. package/docs/ActionHttp-failures.md +14 -76
  53. package/docs/ActionHttp.md +150 -176
  54. package/docs/ActionMcp-failures.md +2 -22
  55. package/docs/ActionMcp.md +79 -170
  56. package/docs/ActionRpc-failures.md +3 -12
  57. package/docs/ActionRpc.md +183 -120
  58. package/docs/ActionToolkit-failures.md +3 -10
  59. package/docs/ActionToolkit.md +112 -109
  60. package/docs/Authentication-advanced.md +171 -108
  61. package/docs/Authentication-failures.md +11 -91
  62. package/docs/Authentication.md +114 -135
  63. package/docs/README.md +45 -30
  64. package/docs/Testing-failures.md +2 -6
  65. package/docs/Testing.md +126 -159
  66. package/docs/setup-failures.md +8 -32
  67. package/docs/setup.md +42 -38
  68. package/examples/README.md +6 -10
  69. package/examples/assistant/README.md +20 -38
  70. package/examples/assistant/src/agent.ts +2 -3
  71. package/examples/assistant/src/cli.ts +4 -9
  72. package/examples/assistant/src/commands.ts +6 -4
  73. package/examples/assistant/src/identity/authorize.ts +3 -3
  74. package/examples/assistant/src/tasks/Tasks.ts +1 -2
  75. package/examples/assistant/src/tasks/handlers.ts +3 -3
  76. package/examples/assistant/src/toolkit.ts +6 -8
  77. package/examples/assistant/test/commands.test.ts +16 -11
  78. package/examples/assistant/test/tasks/tasks.test.ts +0 -2
  79. package/examples/assistant/test/toolkit.test.ts +0 -1
  80. package/examples/kitchen-sink/README.md +30 -52
  81. package/examples/kitchen-sink/src/Actions.ts +2 -3
  82. package/examples/kitchen-sink/src/Api.ts +3 -4
  83. package/examples/kitchen-sink/src/admin/Admin.ts +1 -1
  84. package/examples/kitchen-sink/src/admin/authenticate.ts +3 -3
  85. package/examples/kitchen-sink/src/admin/authorize.ts +1 -2
  86. package/examples/kitchen-sink/src/agent.ts +1 -1
  87. package/examples/kitchen-sink/src/assistant/handlers.ts +1 -6
  88. package/examples/kitchen-sink/src/attachments/Attachments.ts +4 -8
  89. package/examples/kitchen-sink/src/attachments/download.ts +5 -8
  90. package/examples/kitchen-sink/src/attachments/handlers.ts +2 -3
  91. package/examples/kitchen-sink/src/browser.ts +3 -4
  92. package/examples/kitchen-sink/src/cli.ts +6 -7
  93. package/examples/kitchen-sink/src/config.ts +2 -0
  94. package/examples/kitchen-sink/src/identity/Identity.ts +7 -7
  95. package/examples/kitchen-sink/src/identity/authenticate.ts +3 -5
  96. package/examples/kitchen-sink/src/identity/authorize.ts +4 -3
  97. package/examples/kitchen-sink/src/identity/quota.ts +1 -1
  98. package/examples/kitchen-sink/src/issues/Issues.ts +2 -2
  99. package/examples/kitchen-sink/src/issues/assistant.ts +3 -3
  100. package/examples/kitchen-sink/src/issues/guide.ts +2 -3
  101. package/examples/kitchen-sink/src/ops.ts +3 -6
  102. package/examples/kitchen-sink/src/request-policy.ts +8 -4
  103. package/examples/kitchen-sink/src/routes.ts +4 -6
  104. package/examples/kitchen-sink/src/seed.ts +0 -1
  105. package/examples/kitchen-sink/src/server.ts +11 -12
  106. package/examples/kitchen-sink/src/stdio.ts +3 -2
  107. package/examples/kitchen-sink/src/workspaces/tenant.ts +3 -1
  108. package/examples/kitchen-sink/test/admin/admin.test.ts +0 -1
  109. package/examples/kitchen-sink/test/assistant/assistant.test.ts +1 -1
  110. package/examples/kitchen-sink/test/attachments/attachments.test.ts +1 -2
  111. package/examples/kitchen-sink/test/identity/identity.test.ts +0 -1
  112. package/examples/kitchen-sink/test/issues/issues.test.ts +0 -2
  113. package/examples/kitchen-sink/test/request-policy.test.ts +39 -3
  114. package/examples/kitchen-sink/test/routes.test.ts +3 -3
  115. package/examples/live-rpc/README.md +8 -49
  116. package/examples/live-rpc/src/browser.ts +8 -41
  117. package/examples/live-rpc/src/chat/Chat.ts +9 -3
  118. package/examples/live-rpc/src/chat/room.ts +1 -1
  119. package/examples/live-rpc/src/client.ts +2 -3
  120. package/examples/live-rpc/src/page.ts +34 -0
  121. package/examples/live-rpc/src/routes.ts +6 -7
  122. package/examples/live-rpc/src/server.ts +6 -4
  123. package/examples/live-rpc/test/browser.test.ts +6 -5
  124. package/examples/live-rpc/test/routes.test.ts +16 -15
  125. package/examples/live-rpc/test/server.test.ts +1 -1
  126. package/examples/mcp-tools/README.md +22 -45
  127. package/examples/mcp-tools/src/printers/Printers.ts +3 -7
  128. package/examples/mcp-tools/src/routes.ts +0 -7
  129. package/examples/mcp-tools/src/server.ts +1 -1
  130. package/examples/mcp-tools/test/printers/printers.test.ts +2 -3
  131. package/examples/mcp-tools/test/routes.test.ts +2 -3
  132. package/examples/notes-service/README.md +9 -43
  133. package/examples/notes-service/src/cli.ts +0 -3
  134. package/examples/notes-service/src/commands.ts +7 -17
  135. package/examples/notes-service/src/identity/authorize.ts +2 -5
  136. package/examples/notes-service/src/identity/verifier.ts +3 -4
  137. package/examples/notes-service/src/notes/Notes.ts +2 -6
  138. package/examples/notes-service/src/notes/store.ts +5 -1
  139. package/examples/notes-service/src/routes.ts +6 -8
  140. package/examples/notes-service/src/server.ts +4 -3
  141. package/examples/notes-service/src/stdio.ts +4 -3
  142. package/examples/notes-service/test/commands.test.ts +1 -1
  143. package/examples/notes-service/test/notes/as.ts +6 -0
  144. package/examples/notes-service/test/notes/idempotency.test.ts +1 -4
  145. package/examples/notes-service/test/notes/in-process.test.ts +50 -0
  146. package/examples/notes-service/test/notes/notes.test.ts +3 -42
  147. package/examples/notes-service/test/routes.test.ts +2 -2
  148. package/examples/quickstart/README.md +1 -1
  149. package/examples/quickstart/src/client.ts +1 -0
  150. package/examples/quickstart/src/server.ts +2 -4
  151. package/examples/quickstart/test/{greeting/greeting.test.ts → routes.test.ts} +3 -3
  152. package/package.json +12 -16
  153. package/docs/Action-in-process.md +0 -93
  154. package/docs/CONTEXT.md +0 -46
  155. package/docs/guarantees.md +0 -44
@@ -1,4 +1,4 @@
1
- import { Context, Effect, Layer, ManagedRuntime } from "effect";
1
+ import { Effect, Layer } from "effect";
2
2
  import { RpcClient, RpcSerialization } from "effect/rpc";
3
3
  import { Socket } from "effect/socket";
4
4
  import * as ActionRpc from "@gjermundgaraba/effect-actions/ActionRpc";
@@ -9,18 +9,16 @@ import { Rpc } from "./Rpc.js";
9
9
  // bundler gets the contracts and Effect's RPC client, no handler and nothing of Node.
10
10
 
11
11
  // Effect's own client protocol: one WebSocket, speaking the server's JSON. This layer owns
12
- // the socket: it opens when the layer is built and closes when the layer is released, so
13
- // provide it once, around the page's whole program, never around one call. Which WebSocket is
14
- // left to the host: a page provides `Socket.layerWebSocketConstructorGlobal`, the browser's
15
- // own, and client.ts provides Node's.
12
+ // the socket, so a page provides it once, around its whole program, and every call shares
13
+ // that one socket. Which WebSocket is left to the host: a page provides
14
+ // `Socket.layerWebSocketConstructorGlobal`, the browser's own, and client.ts provides Node's.
16
15
  export const connection = (url: string) =>
17
16
  RpcClient.layerProtocolSocket().pipe(
18
17
  Layer.provide(Socket.layerWebSocket(url)),
19
18
  Layer.provide(RpcSerialization.layerJson),
20
19
  );
21
20
 
22
- // The chat as a page uses it, on the connection provided around it. The scope it needs holds
23
- // only the typed client built on that connection, not the socket.
21
+ // The chat as a page uses it, on the connection provided around it.
24
22
  export const openChat = Effect.gen(function* () {
25
23
  // One typed method per action of the binding, all on the one connection.
26
24
  const client = yield* ActionRpc.client(Rpc);
@@ -30,14 +28,13 @@ export const openChat = Effect.gen(function* () {
30
28
  rooms: Effect.map(client.listRooms(), ({ rooms }) => rooms),
31
29
 
32
30
  room: (name: string, token: string) => {
33
- // A browser WebSocket sets no header when it connects, so the token travels on each
34
- // message, and the room beside it. A refreshed token needs no new socket.
31
+ // A browser WebSocket cannot set an `Authorization` header on its upgrade, so the token
32
+ // travels on each message, and the room beside it. A refreshed token needs no new socket.
35
33
  const here = RpcClient.withHeaders({ authorization: `Bearer ${token}`, [roomHeader]: name });
36
34
 
37
35
  return {
38
36
  // Called again and again while the page is open, with the last id it holds. A token
39
- // may expire under a socket that stays up, so the refusals are handled here. Each is
40
- // a typed failure matched by its tag, and only that call fails: the socket stays open.
37
+ // may expire while the page stays open, so each refusal becomes what the page shows.
41
38
  read: (after: number) =>
42
39
  client.readMessages({ after }).pipe(
43
40
  here,
@@ -66,33 +63,3 @@ export const openChat = Effect.gen(function* () {
66
63
  },
67
64
  };
68
65
  });
69
-
70
- // The chat for a page that is not one Effect program: the same client, each call a promise.
71
- // A `ManagedRuntime` keeps the protocol's layer, and so the socket, between calls. It builds
72
- // the layer on the first call, which is when the socket opens, and `close` releases it. A
73
- // failure `openChat` does not handle, such as `InvalidInput`, rejects the promise.
74
- class Chat extends Context.Service<Chat, Effect.Success<typeof openChat>>()("live-rpc/Chat") {}
75
-
76
- export const chatOver = <E>(protocol: Layer.Layer<RpcClient.Protocol, E>) => {
77
- const runtime = ManagedRuntime.make(Layer.effect(Chat, openChat).pipe(Layer.provide(protocol)));
78
-
79
- const call = <A, E2>(use: (chat: Chat["Service"]) => Effect.Effect<A, E2>) =>
80
- runtime.runPromise(Effect.flatMap(Chat, use));
81
-
82
- return {
83
- rooms: () => call((chat) => chat.rooms),
84
-
85
- room: (name: string, token: string) => ({
86
- read: (after: number) => call((chat) => chat.room(name, token).read(after)),
87
- post: (text: string) => call((chat) => chat.room(name, token).post(text)),
88
- remove: (id: number) => call((chat) => chat.room(name, token).remove(id)),
89
- }),
90
-
91
- close: () => runtime.dispose(),
92
- };
93
- };
94
-
95
- // What a page calls: `const chat = connect("wss://chat.example/rpc")`, on the browser's own
96
- // WebSocket, then `await chat.room("lobby", token).post("Hello")`.
97
- export const connect = (url: string) =>
98
- chatOver(connection(url).pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)));
@@ -30,13 +30,19 @@ export class NotAuthor extends Schema.TaggedError<NotAuthor>()(
30
30
  ) {}
31
31
 
32
32
  // Which room a call is about is not an input of any action: it travels beside the call, in
33
- // this header, as the token does. The client sets it; on the server, room.ts reads it. A call
34
- // without the header is about the default room, so `listRooms`, which is about none, sends none.
33
+ // this header, as the token does, because `authorize` sees the action and not its input, and
34
+ // the membership rule needs the room. With the room in the input, that check belongs in the
35
+ // store, as `MessageStore.remove` takes the author and refuses with `NotAuthor`, never in a
36
+ // helper each handler must remember to call. The client sets the header; on the server,
37
+ // room.ts reads it. A call without it is about the default room, so `listRooms`, which is
38
+ // about none, sends none; yours may refuse such a call instead.
35
39
  export const roomHeader = "x-room";
36
40
 
37
41
  export const defaultRoom = "lobby";
38
42
 
39
- // Public: `caller: Action.Anyone`. A page shows the rooms before anyone has signed in.
43
+ // Public: `caller: Action.Anyone`, so a page shows the rooms before anyone has signed in. It
44
+ // tells a signed-out caller every room's name and how many messages it holds, a room appearing
45
+ // once someone posts: decide whether yours may.
40
46
  export const ListRooms = Action.make("listRooms", {
41
47
  description: "List the rooms someone has posted in, and how many messages each holds.",
42
48
  success: {
@@ -9,7 +9,7 @@ import { defaultRoom, roomHeader } from "./Chat.js";
9
9
  export class CurrentRoom extends Context.Service<CurrentRoom, string>()("live-rpc/CurrentRoom") {}
10
10
 
11
11
  // Native Effect RPC middleware, declared as providing `CurrentRoom`. Listed in
12
- // `ActionRpc.layer`'s `middleware`, it runs around every rpc, once per message.
12
+ // `ActionRpc.layer`'s `middleware`, it runs once per message.
13
13
  export class ResolveRoom extends RpcMiddleware.Service<ResolveRoom, { provides: CurrentRoom }>()(
14
14
  "live-rpc/ResolveRoom",
15
15
  ) {}
@@ -7,7 +7,7 @@ import { connection, openChat } from "./browser.js";
7
7
  const visit = Effect.gen(function* () {
8
8
  const chat = yield* openChat;
9
9
 
10
- // Two members on the one socket: each call is whoever its token says.
10
+ // Two members on the one socket.
11
11
  const ada = chat.room("lobby", "ada");
12
12
  const grace = chat.room("lobby", "grace");
13
13
 
@@ -26,8 +26,7 @@ const visit = Effect.gen(function* () {
26
26
  const main = Effect.gen(function* () {
27
27
  const url = yield* Config.String("CHAT_URL").pipe(Config.withDefault("ws://127.0.0.1:3000/rpc"));
28
28
 
29
- // The socket belongs to `connection(url)`: provided once around the whole visit, it opens
30
- // for it and closes when this `Effect.provide` ends. `Effect.scoped` holds the client alone.
29
+ // One socket for the whole visit, so both members' calls share it.
31
30
  yield* visit.pipe(
32
31
  Effect.scoped,
33
32
  Effect.provide(connection(url).pipe(Layer.provide(NodeSocket.layerWebSocketConstructor))),
@@ -0,0 +1,34 @@
1
+ import { Context, Effect, Layer, ManagedRuntime } from "effect";
2
+ import { RpcClient } from "effect/rpc";
3
+ import { Socket } from "effect/socket";
4
+ import { connection, openChat } from "./browser.js";
5
+
6
+ // The chat for a page that is not one Effect program: the same client, each call a promise.
7
+ // A `ManagedRuntime` keeps the protocol's layer, and so the socket, between calls. It builds
8
+ // the layer on the first call, which is when the socket opens, and `close` releases it. A
9
+ // 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") {}
11
+
12
+ export const chatOver = <E>(protocol: Layer.Layer<RpcClient.Protocol, E>) => {
13
+ const runtime = ManagedRuntime.make(Layer.effect(Chat, openChat).pipe(Layer.provide(protocol)));
14
+
15
+ const call = <A, E2>(use: (chat: Chat["Service"]) => Effect.Effect<A, E2>) =>
16
+ runtime.runPromise(Effect.flatMap(Chat, use));
17
+
18
+ return {
19
+ rooms: () => call((chat) => chat.rooms),
20
+
21
+ 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)),
25
+ }),
26
+
27
+ close: () => runtime.dispose(),
28
+ };
29
+ };
30
+
31
+ // What a page calls: `const chat = connect("wss://chat.example/rpc")`, on the browser's own
32
+ // WebSocket, then `await chat.room("lobby", token).post("Hello")`.
33
+ export const connect = (url: string) =>
34
+ chatOver(connection(url).pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)));
@@ -5,14 +5,13 @@ import { ResolveRoom, resolveRoom } from "./chat/room.js";
5
5
  import { authenticate } from "./identity/verifier.js";
6
6
  import { Rpc } from "./Rpc.js";
7
7
 
8
- // The binding's rpcs on Effect's own RpcServer. A protected rpc authenticates every message
9
- // with `Login`'s verifier, so one connection may carry several callers; `listRooms` needs no
10
- // token.
8
+ // The binding's rpcs on Effect's own RpcServer, the protected ones behind `Login`'s verifier;
9
+ // `listRooms` needs no token.
11
10
  //
12
- // `middleware` runs around every rpc, inside authentication and before the input is decoded.
13
- // `ResolveRoom` provides `CurrentRoom`, which the handlers and `authorize` read. Only something
14
- // that runs per message can: `Layer.succeed(CurrentRoom, "lobby")` provided here would leave
15
- // the layer still requiring it, in its type.
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.
16
15
  //
17
16
  // It still requires three things, each chosen by whoever serves it. The protocol and its
18
17
  // serialization: server.ts gives it a WebSocket, and a test Effect's HTTP protocol, which
@@ -7,12 +7,12 @@ import { MessageStore } from "./chat/store.js";
7
7
  import { rpc } from "./routes.js";
8
8
 
9
9
  // Entrypoint: `node --import tsx examples/live-rpc/src/server.ts`.
10
- // The composition root of the server. It chooses the protocol, the store and the port, and
11
- // starts Node's HTTP server. It provides no caller and no room: those arrive with each message.
10
+ // It chooses the protocol, the store and the port, and starts Node's HTTP server. It provides
11
+ // no caller and no room: those arrive with each message.
12
12
 
13
13
  // The rpcs over a WebSocket at `/rpc`, the router's one route: Node's HTTP server answers the
14
- // upgrade. `ActionRpc.layer` serves a protocol speaking JSON, and the client must speak the
15
- // same one.
14
+ // upgrade. JSON, because a page's client speaks it: browser.ts gives its socket the same
15
+ // serialization.
16
16
  const socket = rpc.pipe(
17
17
  Layer.provide(RpcServer.layerProtocolWebsocket({ path: "/rpc" })),
18
18
  Layer.provide(RpcSerialization.layerJson),
@@ -35,6 +35,8 @@ const server = Layer.unwrap(
35
35
  websocket: { maxPayload: 64 * 1024 },
36
36
  }),
37
37
  ),
38
+ // DEMO ONLY: messages live in this process's memory; a deployment provides a store that
39
+ // lasts.
38
40
  Layer.provide(MessageStore.layerMemory),
39
41
  );
40
42
  }),
@@ -3,13 +3,14 @@ import { Effect, Layer } from "effect";
3
3
  import { RpcClient, RpcSerialization, RpcServer } from "effect/rpc";
4
4
  import * as Action from "@gjermundgaraba/effect-actions/Action";
5
5
  import * as Testing from "@gjermundgaraba/effect-actions/Testing";
6
- import { chatOver, openChat } from "../src/browser.js";
6
+ import { openChat } from "../src/browser.js";
7
+ import { chatOver } from "../src/page.js";
7
8
  import { MessageStore } from "../src/chat/store.js";
8
9
  import { rpc } from "../src/routes.js";
9
10
 
10
- // The page's client against the real rpcs, in memory. `openChat` needs a client protocol and
11
- // does not care which: a page gives it `connection(url)`, a WebSocket, and this test Effect's
12
- // HTTP protocol, answered by `Testing.layer`, which cannot upgrade a WebSocket.
11
+ // The page's client against the real rpcs, in memory, served as in routes.test.ts. `openChat`
12
+ // needs a client protocol and does not care which: a page gives it `connection(url)`, a
13
+ // WebSocket, and this test Effect's HTTP protocol.
13
14
  const served = RpcClient.layerProtocolHttp({ url: "/rpc" }).pipe(
14
15
  Layer.provide(RpcSerialization.layerJson),
15
16
  Layer.provide(
@@ -78,7 +79,7 @@ describe("the page's client", () => {
78
79
 
79
80
  // `connect(url)` is this with a WebSocket. Each test's runtime builds its own layer, and so
80
81
  // its own store.
81
- it("answers a page that is not an Effect program with promises, on one connection", async () => {
82
+ it("answers a page that is not an Effect program with promises, until it closes", async () => {
82
83
  const chat = chatOver(served);
83
84
 
84
85
  const posted = await chat.room("lobby", "ada").post("Deploying at noon.");
@@ -10,9 +10,8 @@ import { rpc } from "../src/routes.js";
10
10
  import { Rpc } from "../src/Rpc.js";
11
11
 
12
12
  // What the wire adds: the real rpcs, behind their real authentication and middleware,
13
- // answering in memory. The server gives them a WebSocket, which `Testing.layer` cannot
14
- // upgrade: it answers HTTP requests alone. So the test gives the same layer Effect's HTTP
15
- // protocol, and its client posts to it. No port is opened.
13
+ // answering in memory. The test gives them Effect's HTTP protocol, not the server's WebSocket,
14
+ // because `Testing.layer` answers HTTP requests; no port is opened.
16
15
  const server = rpc.pipe(
17
16
  Layer.provide(RpcServer.layerProtocolHttp({ path: "/rpc" })),
18
17
  Layer.provide(RpcSerialization.layerJson),
@@ -30,18 +29,20 @@ const as = (token: string) => RpcClient.withHeaders({ authorization: `Bearer ${t
30
29
  const inRoom = (room: string) => RpcClient.withHeaders({ [roomHeader]: room });
31
30
 
32
31
  describe("the chat, over RPC", () => {
33
- it.effect("answers the public rpc without a token and refuses the rest", () =>
34
- Effect.gen(function* () {
35
- const client = yield* ActionRpc.client(Rpc);
36
- const signedOut = yield* Effect.flip(client.readMessages({ after: 0 }));
37
- const stranger = yield* Effect.flip(client.readMessages({ after: 0 }).pipe(as("mallory")));
38
-
39
- expect(yield* client.listRooms()).toEqual({ rooms: [] });
40
- expect(signedOut).toBeInstanceOf(Action.Unauthenticated);
41
- expect(signedOut.message).toBe("A bearer token is required.");
42
- expect(stranger).toBeInstanceOf(Action.Unauthenticated);
43
- expect(stranger.message).toBe("Unknown demo token.");
44
- }).pipe(Effect.provide(served)),
32
+ it.effect(
33
+ "answers the public rpc without a token and refuses a protected rpc without a known token",
34
+ () =>
35
+ Effect.gen(function* () {
36
+ const client = yield* ActionRpc.client(Rpc);
37
+ const signedOut = yield* Effect.flip(client.readMessages({ after: 0 }));
38
+ const stranger = yield* Effect.flip(client.readMessages({ after: 0 }).pipe(as("mallory")));
39
+
40
+ expect(yield* client.listRooms()).toEqual({ rooms: [] });
41
+ expect(signedOut).toBeInstanceOf(Action.Unauthenticated);
42
+ expect(signedOut.message).toBe("A bearer token is required.");
43
+ expect(stranger).toBeInstanceOf(Action.Unauthenticated);
44
+ expect(stranger.message).toBe("Unknown demo token.");
45
+ }).pipe(Effect.provide(served)),
45
46
  );
46
47
 
47
48
  it.effect("authenticates each call by its own token: two callers on one client", () =>
@@ -4,7 +4,7 @@ import { describe, expect, it } from "@effect/vitest";
4
4
  import { Effect, Layer } from "effect";
5
5
  import { HttpRouter, HttpServer } from "effect/http";
6
6
  import { RpcSerialization, RpcServer } from "effect/rpc";
7
- import { connect } from "../src/browser.js";
7
+ import { connect } from "../src/page.js";
8
8
  import { MessageStore } from "../src/chat/store.js";
9
9
  import { rpc } from "../src/routes.js";
10
10
 
@@ -5,11 +5,6 @@ authentication of its own. It signs in to the farm once, with its own account, a
5
5
  reach it may call every tool, so it runs as a local subprocess of an agent or on a network only
6
6
  trusted clients are on.
7
7
 
8
- Start here if you are putting an existing API in front of a model, then read
9
- [ActionMcp.md](../../docs/ActionMcp.md). For tools that need a token, its "Protect tools" shows
10
- the verifier, and [notes-service](../notes-service/) is a server whose tools belong to the
11
- signed-in user.
12
-
13
8
  ## Run it
14
9
 
15
10
  From the repository root, after `vp install` (from an installed package, see
@@ -28,8 +23,7 @@ echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":
28
23
  vp test examples/mcp-tools
29
24
  ```
30
25
 
31
- The farm is a fake, answered in memory, so any URL, user and password work: replace it with a
32
- real HTTP client before deploying anything. `ALLOWED_ORIGINS` lists the browser origins the HTTP
26
+ The farm is a fake, answered in memory, so any URL, user and password work. `ALLOWED_ORIGINS` lists the browser origins the HTTP
33
27
  endpoint accepts and answers with CORS headers, comma-separated; `HOST` is the interface it
34
28
  listens on.
35
29
 
@@ -80,9 +74,9 @@ curl http://127.0.0.1:3000/mcp \
80
74
  ```
81
75
 
82
76
  It answers `{"jsonrpc":"2.0","id":1,"result":{…,"structuredContent":{"printers":[…]},"isError":false}}`.
83
- Every header is needed: without `Accept` naming both types the answer is an empty 406, without
84
- `Content-Type` a 415, and without an `MCP-` header, or with one that differs from the body, a
85
- 400 saying which. `tools/list` takes the same request with that method, no `MCP-Name` header,
77
+ Every header is needed, as `ActionMcp.request` builds it, and
78
+ [ActionMcp-failures.md](../../docs/ActionMcp-failures.md) lists what a missing one answers.
79
+ `tools/list` takes the same request with that method, no `MCP-Name` header,
86
80
  and no `name` or `arguments`.
87
81
 
88
82
  ## Layout
@@ -105,40 +99,23 @@ test/
105
99
  └── routes.test.ts over MCP in memory, with Testing.layer
106
100
  ```
107
101
 
108
- - **The upstream is a service, built once.** `farm.ts` declares what the handlers need of the
109
- farm, and `platform/print-farm.ts` is its Layer over the farm's HTTP API: it reads its URL and
110
- credentials from `Config`, takes whichever `HttpClient` the entrypoint provides, and signs in
111
- while it is built. The handlers get it already signed in and only delegate: `handlers.ts`
112
- maps each tool onto a method of `PrintFarm`, which knows nothing of tools or HTTP, so a second
113
- caller of the farm, a job or another surface, takes the service alone.
114
- - **One declared error, mapped in one place.** A refusal, an unreachable farm, an answer of
115
- another shape and one that never comes each leave `platform/print-farm.ts` as a `PrintFarmError` with a
116
- message a model can act on. No handler catches anything.
117
- - **What a model sends is checked by the contract.** A printer id becomes part of the farm's
118
- URL, so its schema admits only what is safe there, and anything else is refused before a
119
- handler runs.
120
- - **One implementation, two transports.** `server.ts` and `stdio.ts` serve the same `printers`
121
- with the same `serverInfo`; they differ in what they provide around it.
122
- - **The contract is the tool.** A tool's name, description, input schema, hints and title all
123
- come from `Printers.ts`.
124
-
125
102
  ## Where to look
126
103
 
127
- | To see | Read |
128
- | --------------------------------------------------------------- | -------------------------------- |
129
- | Public tools described for a model, with field descriptions | `src/printers/Printers.ts` |
130
- | `readOnly`, `title`, `destructiveHint` and `idempotentHint` | `src/printers/Printers.ts` |
131
- | One declared error shared by every tool | `src/printers/Printers.ts` |
132
- | An input a model cannot use to reach another URL | `src/printers/Printers.ts` |
133
- | A tool answering an image beside other fields | `src/printers/Printers.ts` |
134
- | An upstream client reading `Config`, signing in once | `src/platform/print-farm.ts` |
135
- | An upstream's failures mapped to the declared error | `src/platform/print-farm.ts` |
136
- | A builder yielding a service, and handlers that only delegate | `src/printers/handlers.ts` |
137
- | Server `instructions`, shared by both transports | `src/instructions.ts` |
138
- | Allowed origins from `Config`, their CORS, and `/healthz` | `src/routes.ts` |
139
- | A request body limit, and where an unauthenticated server binds | `src/server.ts` |
140
- | A stdio server whose logs go to stderr | `src/stdio.ts` |
141
- | A fake upstream provided as a Layer, to a test or an entrypoint | `src/platform/fake-farm.ts` |
142
- | Testing tools and each upstream failure's message in process | `test/printers/printers.test.ts` |
143
- | Testing listed hints, an image and the origin check in memory | `test/routes.test.ts` |
144
- | Testing CORS for one configured origin and for several | `test/routes.test.ts` |
104
+ | To see | Read |
105
+ | ----------------------------------------------------------------------- | -------------------------------- |
106
+ | Public tools described for a model, with field descriptions | `src/printers/Printers.ts` |
107
+ | `readOnly`, `title`, `idempotentHint`, and destructive as MCP's default | `src/printers/Printers.ts` |
108
+ | One declared error shared by every tool | `src/printers/Printers.ts` |
109
+ | An input a model cannot use to reach another URL | `src/printers/Printers.ts` |
110
+ | A tool answering an image beside other fields | `src/printers/Printers.ts` |
111
+ | An upstream client reading `Config`, signing in once | `src/platform/print-farm.ts` |
112
+ | An upstream's failures mapped to the declared error | `src/platform/print-farm.ts` |
113
+ | A builder yielding a service, and handlers that only delegate | `src/printers/handlers.ts` |
114
+ | Server `instructions`, shared by both transports | `src/instructions.ts` |
115
+ | Allowed origins from `Config`, their CORS, and `/healthz` | `src/routes.ts` |
116
+ | A request body limit, and where an unauthenticated server binds | `src/server.ts` |
117
+ | A stdio server whose logs go to stderr | `src/stdio.ts` |
118
+ | A fake upstream provided as a Layer, to a test or an entrypoint | `src/platform/fake-farm.ts` |
119
+ | Testing tools and the upstream's failure messages in process | `test/printers/printers.test.ts` |
120
+ | Testing listed hints, an image and the origin check in memory | `test/routes.test.ts` |
121
+ | Testing CORS for one configured origin and for several | `test/routes.test.ts` |
@@ -40,8 +40,7 @@ const tool = { caller: Action.Anyone, error: PrintFarmError } as const;
40
40
  // commands names them in camelCase, as notes-service does.
41
41
  //
42
42
  // A description is written for the model that picks the tool: what it does, when to call it,
43
- // and what it answers. `readOnly` is sent as the tool's read-only hint; `mcp` holds the other
44
- // hints and the title a client shows its user.
43
+ // and what it answers.
45
44
  export const ListPrinters = Action.make("list_printers", {
46
45
  ...tool,
47
46
  description:
@@ -53,8 +52,7 @@ export const ListPrinters = Action.make("list_printers", {
53
52
  mcp: { title: "List printers" },
54
53
  });
55
54
 
56
- // `image` is an `Action.Image` beside an ordinary field. Over MCP it is sent as an image block
57
- // the model can look at, after the JSON of `{ printer }`.
55
+ // `image` is an `Action.Image`, so a model sees the picture rather than reading its base64.
58
56
  export const GetCameraSnapshot = Action.make("get_camera_snapshot", {
59
57
  ...tool,
60
58
  description:
@@ -95,12 +93,10 @@ export const SetPaused = Action.make("set_paused", {
95
93
  },
96
94
  success: Printer,
97
95
  readOnly: false,
98
- // Destructive, MCP's default for a write: a pause interrupts a running print. Only a write
99
- // that only adds, such as creating a record, says `destructiveHint: false`.
96
+ // Destructive, MCP's default for a write: a pause interrupts a running print.
100
97
  mcp: { title: "Pause or resume a print", idempotentHint: true },
101
98
  });
102
99
 
103
- // Destructive, which is MCP's default for a write, so no `destructiveHint` is stated.
104
100
  export const CancelPrint = Action.make("cancel_print", {
105
101
  ...tool,
106
102
  description:
@@ -20,13 +20,6 @@ export const routes = Layer.unwrap(
20
20
  // no origin, allows every one, so it is added only where an origin is configured. The
21
21
  // methods are the endpoint's, the headers those an MCP client sends and must be able to
22
22
  // read; an endpoint with authentication adds `Authorization` and exposes `WWW-Authenticate`.
23
- //
24
- // Given several origins it names the request's own in `Access-Control-Allow-Origin` when
25
- // it is listed, and sends no such header otherwise. Given exactly one it compares nothing:
26
- // every answer names that origin, a preflight from elsewhere included. Both refuse a page
27
- // elsewhere, whose browser allows a call only where the header names the page's own origin,
28
- // and the Origin check above has answered its request 403 either way. So a test of one
29
- // configured origin asserts which origin is named, never that the header is absent.
30
23
  const cors =
31
24
  allowedOrigins.length === 0
32
25
  ? Layer.empty
@@ -10,7 +10,7 @@ import { routes } from "./routes.js";
10
10
  // and FARM_PASSWORD set. MCP over HTTP, for clients that connect to a URL.
11
11
  // These tools are public: no route asks for a token, so where the server listens is its access
12
12
  // control, the loopback interface by default or a network only trusted clients are on. Tools
13
- // that need a caller take a verifier, as docs/ActionMcp.md's "Protect tools" shows.
13
+ // that need a caller are protected as docs/ActionMcp.md's "Protect tools" shows.
14
14
  const server = Layer.unwrap(
15
15
  Effect.gen(function* () {
16
16
  const host = yield* Config.String("HOST").pipe(Config.withDefault("127.0.0.1"));
@@ -63,7 +63,7 @@ const farmThat = (
63
63
  const door = { id: "mk4-1", name: "MK4 by the door" };
64
64
 
65
65
  describe("printers, in process", () => {
66
- it.effect("pauses, resumes and cancels a job, each call safe to repeat", () =>
66
+ it.effect("pauses, resumes and cancels a job, pause and cancel safe to repeat", () =>
67
67
  Effect.gen(function* () {
68
68
  const client = yield* Action.client(printers);
69
69
  const pause = client.set_paused({ printer_id: "mk4-1", paused: true });
@@ -115,8 +115,7 @@ describe("printers, in process", () => {
115
115
  }).pipe(Effect.provide(farmOver(noting(sent))));
116
116
  });
117
117
 
118
- // The message is all a model gets of a failure, so each test asserts it. `toEqual` with an
119
- // expected error would not: an error's `message` is not among the properties it compares.
118
+ // The message is all a model gets of a failure, so each test asserts it.
120
119
  it.effect("fails with the declared error, carrying the farm's own reason", () =>
121
120
  Effect.gen(function* () {
122
121
  const client = yield* Action.client(printers);
@@ -14,8 +14,7 @@ import { routes } from "../src/routes.js";
14
14
  // What the entrypoint reads from the environment, given to the test as a Layer.
15
15
  const farm = { FARM_URL: "https://farm.example.test", FARM_USER: "agent", FARM_PASSWORD: "secret" };
16
16
 
17
- // `Testing.layer` gives the test an `HttpClient` of its own, which reaches the routes. The
18
- // fake farm is another `HttpClient`, provided to `printFarm` alone.
17
+ // The fake farm goes to `printFarm` alone, so the test's own requests still reach the routes.
19
18
  const servedWith = (environment: Record<string, string>) =>
20
19
  Testing.layer(routes).pipe(
21
20
  Layer.provide(printFarm.pipe(Layer.provide(fakeFarm))),
@@ -57,7 +56,7 @@ const Listed = Schema.Struct({
57
56
  });
58
57
 
59
58
  describe("the tools, over MCP", () => {
60
- it.effect("lists every tool with the hints its contract states", () =>
59
+ it.effect("lists every tool with its title and hints as sent", () =>
61
60
  Effect.gen(function* () {
62
61
  // A raw request, for what no typed client shows: here, the listing itself.
63
62
  const response = yield* HttpClient.execute(ActionMcp.request("tools/list"));
@@ -5,11 +5,6 @@ implementation, served as a JSON API and as MCP tools, with a CLI and a browser
5
5
  same binding. Notes belong to the signed-in user; reading needs the `notes:read` scope and
6
6
  writing `notes:write`.
7
7
 
8
- Start here if you are building a service. The other examples follow the same layout. Then read
9
- [Action.md](../../docs/Action.md) and the page of each surface you serve, which
10
- [docs/README.md](../../docs/README.md) routes, one page per tool call. An error's message is
11
- quoted in a `docs/*-failures.md`.
12
-
13
8
  ## Run it
14
9
 
15
10
  From the repository root, after `vp install` (from an installed package, see
@@ -44,11 +39,10 @@ codex mcp add notes --url http://127.0.0.1:3000/mcp --bearer-token-env-var NOTES
44
39
  Cloudflare Workers ([ActionMcp.md](../../docs/ActionMcp.md#serve-tools-over-http)).
45
40
 
46
41
  The server reads `HOST` (default `127.0.0.1`), `PORT` (default 3000) and `PUBLIC_URL`, the
47
- address it tells OAuth clients it is at; the CLI reads `NOTES_URL` and `NOTES_TOKEN`. Change the port and set all three to match.
48
- Every command asks for `NOTES_TOKEN`, the public `get-status` included: without it a command
49
- prints one message on stderr saying to set it, and exits 1. `--help` needs none.
50
-
51
- The tokens are fixed names and the store is memory: replace both before deploying anything.
42
+ address it tells OAuth clients it is at; the CLI reads `NOTES_URL` and `NOTES_TOKEN`. Change the
43
+ port and set `PORT`, `PUBLIC_URL` and `NOTES_URL` to match. Every command asks for
44
+ `NOTES_TOKEN`, the public `get-status` included: without it a command prints one message on
45
+ stderr saying to set it, and exits 1. `--help` needs none.
52
46
 
53
47
  ## Retry a creation
54
48
 
@@ -62,24 +56,8 @@ command:
62
56
  echo "First draft" | node --import tsx examples/notes-service/src/cli.ts create-note --request-id plan-1 --title Plan
63
57
  ```
64
58
 
65
- The key belongs to the signed-in caller and the `createNote` operation. Another caller can use
66
- it independently. The same caller repeating the key and content gets the original result;
67
- different content fails with `RequestConflict`. A new key creates a new note, even when its
68
- content is identical. Every attempt still runs authorization.
69
-
70
- The replay result is a snapshot of the creation. Updating or deleting the note leaves that
71
- snapshot intact: replay neither changes the note back nor recreates it. `importNotes` has no
72
- request key; each import creates new notes. Calls without a key are not idempotent, so the
73
- creation tool does not advertise `idempotentHint: true`.
74
-
75
- [src/notes/store.ts](src/notes/store.ts) owns creation and replay recording.
76
-
77
- **DEMO ONLY:** notes and replay records live in this process's memory, without eviction. They
78
- do not survive a restart or coordinate separate server instances. A deployed store must commit
79
- the mutation and replay record in one transaction, enforce uniqueness of caller, operation and
80
- key, and define a retention window. An expired key no longer protects a retry. External side
81
- effects need the upstream system's cooperation: a local record cannot make a separate network
82
- call atomic. The library supplies neither replay storage nor automatic retries.
59
+ What a key promises, per caller, for changed content and after the note changes, is what
60
+ [test/notes/idempotency.test.ts](test/notes/idempotency.test.ts) asserts.
83
61
 
84
62
  ## Layout
85
63
 
@@ -113,19 +91,6 @@ test/
113
91
  └── commands.test.ts the commands and their connection, against the same routes in memory
114
92
  ```
115
93
 
116
- - **A folder per domain, not per mechanism.** Everything about notes is in `notes/`; there is no
117
- `contracts/` or `handlers/` folder.
118
- - **The PascalCase file is the contract.** `Notes.ts`, `Identity.ts`, `Status.ts` and `Api.ts`
119
- import no server code, so a browser or a CLI imports them. The lowercase files beside them run
120
- on the server, each named for its role, never the domain: `notes.ts` beside `Notes.ts` is one
121
- file on a disk that ignores case (TS1149).
122
- - **One entrypoint per way of running it.** Each chooses its own implementations: `server.ts`
123
- picks the store and the HTTP server, `worker.ts` the routes for a serverless host, `stdio.ts`
124
- names the local user, a test provides its own store. Nothing below an entrypoint knows which
125
- it got.
126
- - **In a monorepo** the contract files and `Api.ts` are the package the server, the CLI and the
127
- website all depend on; the lowercase files and `routes.ts` are the server.
128
-
129
94
  ## Where to look
130
95
 
131
96
  | To see | Read |
@@ -146,8 +111,9 @@ test/
146
111
  | A bulk import as one action, its command reading a file | `src/notes/`, `src/commands.ts` |
147
112
  | The CLI's connection, read from config when a command runs | `src/commands.ts` |
148
113
  | A missing token as one message, not a `ConfigError` report | `src/commands.ts` |
149
- | A promise client handling each declared error by its tag | `src/browser.ts` |
150
- | Testing handlers and authorization with no transport | `test/notes/notes.test.ts` |
114
+ | A promise client handling a declared error by its tag | `src/browser.ts` |
115
+ | Testing handlers and authorization with no transport | `test/notes/in-process.test.ts` |
116
+ | Testing a revision race, two fibers taking turns | `test/notes/notes.test.ts` |
151
117
  | Testing routes, tools and refusals in memory | `test/routes.test.ts` |
152
118
  | Testing commands against the routes in memory | `test/commands.test.ts` |
153
119
  | Testing `--help` and the connection with config as a Layer | `test/commands.test.ts` |
@@ -6,9 +6,6 @@ import { commands } from "./commands.js";
6
6
 
7
7
  // Entrypoint: `node --import tsx examples/notes-service/src/cli.ts list-notes`.
8
8
  // The commands of commands.ts, which read where the server is and the token when one runs.
9
-
10
- // A result is printed on stdout. A failure goes to stderr, under an `ERROR` heading, as the JSON
11
- // HTTP sends, such as `{"_tag":"NoteNotFound","id":"9"}`, and the command exits 1.
12
9
  Command.run(commands, { version: "1.0.0" }).pipe(
13
10
  Effect.provide([NodeHttpClient.layerUndici, NodeServices.layer]),
14
11
  ActionCli.logToStderr,