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