@gjermundgaraba/effect-actions 0.11.0 → 0.12.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 +101 -40
- package/dist/Action.d.mts +29 -117
- package/dist/Action.mjs +14 -53
- package/dist/ActionCli.d.mts +60 -28
- package/dist/ActionCli.mjs +54 -48
- package/dist/ActionHttp.d.mts +18 -67
- package/dist/ActionHttp.mjs +20 -70
- package/dist/ActionMcp.d.mts +23 -29
- package/dist/ActionMcp.mjs +56 -114
- package/dist/ActionRpc.d.mts +147 -0
- package/dist/ActionRpc.mjs +113 -0
- package/dist/ActionToolkit.d.mts +5 -8
- package/dist/ActionToolkit.mjs +3 -3
- package/dist/Authentication.d.mts +15 -15
- package/dist/Authentication.mjs +42 -96
- package/dist/Testing.d.mts +28 -23
- package/dist/Testing.mjs +27 -44
- package/dist/{internal/authentication.d.mts → authentication/provider.d.mts} +41 -9
- package/dist/authentication/provider.mjs +12 -0
- package/dist/authentication/refusal.mjs +36 -0
- package/dist/{internal/cli.d.mts → cli/commands.d.mts} +16 -12
- package/dist/cli/commands.mjs +302 -0
- package/dist/{internal → contract}/call.d.mts +2 -2
- package/dist/contract/call.mjs +10 -0
- package/dist/{internal → contract}/errors.d.mts +13 -10
- package/dist/{internal → contract}/errors.mjs +9 -14
- package/dist/{internal → contract}/implementation.d.mts +37 -21
- package/dist/{internal → contract}/implementation.mjs +18 -49
- package/dist/contract/media.d.mts +13 -0
- package/dist/contract/media.mjs +18 -0
- package/dist/contract/rules.d.mts +25 -0
- package/dist/{internal/actions.mjs → contract/rules.mjs} +6 -42
- package/dist/{internal → http}/client.d.mts +7 -9
- package/dist/http/client.mjs +17 -0
- package/dist/http/declared.mjs +20 -0
- package/dist/http/schema-errors.mjs +9 -0
- package/dist/mcp/protocol.d.mts +17 -0
- package/dist/{internal/mcp.mjs → mcp/protocol.mjs} +29 -62
- package/dist/stdio/console.mjs +64 -0
- package/dist/{internal → testing}/memory.d.mts +1 -1
- package/dist/{internal → testing}/memory.mjs +5 -17
- package/dist/toolkit/tools.mjs +29 -0
- package/docs/Action-failures.md +47 -0
- package/docs/Action-in-process.md +93 -0
- package/docs/Action.md +206 -426
- package/docs/ActionCli-failures.md +102 -0
- package/docs/ActionCli.md +206 -293
- package/docs/ActionHttp-failures.md +83 -0
- package/docs/ActionHttp.md +202 -282
- package/docs/ActionMcp-failures.md +31 -0
- package/docs/ActionMcp.md +222 -271
- package/docs/ActionRpc-failures.md +21 -0
- package/docs/ActionRpc.md +178 -0
- package/docs/ActionToolkit-failures.md +15 -0
- package/docs/ActionToolkit.md +100 -119
- package/docs/Authentication-advanced.md +139 -0
- package/docs/Authentication-failures.md +99 -0
- package/docs/Authentication.md +142 -392
- package/docs/CONTEXT.md +39 -24
- package/docs/README.md +71 -35
- package/docs/Testing-failures.md +20 -0
- package/docs/Testing.md +186 -151
- package/docs/guarantees.md +35 -96
- package/docs/setup-failures.md +34 -0
- package/docs/setup.md +87 -91
- package/examples/README.md +68 -0
- package/examples/assistant/README.md +92 -0
- package/examples/assistant/src/agent.ts +92 -0
- package/examples/assistant/src/cli.ts +34 -0
- package/examples/assistant/src/commands.ts +25 -0
- package/examples/assistant/src/identity/Identity.ts +33 -0
- package/examples/assistant/src/identity/authorize.ts +24 -0
- package/examples/assistant/src/identity/handlers.ts +10 -0
- package/examples/assistant/src/identity/members.ts +9 -0
- package/examples/assistant/src/identity/permissions.ts +23 -0
- package/examples/assistant/src/scripted.ts +23 -0
- package/examples/assistant/src/tasks/Tasks.ts +63 -0
- package/examples/assistant/src/tasks/handlers.ts +25 -0
- package/examples/assistant/src/tasks/store.ts +96 -0
- package/examples/assistant/src/toolkit.ts +30 -0
- package/examples/assistant/test/commands.test.ts +57 -0
- package/examples/assistant/test/identity/identity.test.ts +43 -0
- package/examples/assistant/test/tasks/store.test.ts +48 -0
- package/examples/assistant/test/tasks/tasks.test.ts +75 -0
- package/examples/assistant/test/toolkit.test.ts +121 -0
- package/examples/kitchen-sink/README.md +317 -0
- package/examples/kitchen-sink/src/Actions.ts +19 -0
- package/examples/kitchen-sink/src/Api.ts +24 -0
- package/examples/kitchen-sink/src/Rpc.ts +12 -0
- package/examples/kitchen-sink/src/admin/Admin.ts +59 -0
- package/examples/kitchen-sink/src/admin/authenticate.ts +18 -0
- package/examples/kitchen-sink/src/admin/authorize.ts +27 -0
- package/examples/kitchen-sink/src/admin/grants.ts +22 -0
- package/examples/kitchen-sink/src/admin/handlers.ts +21 -0
- package/examples/kitchen-sink/src/admin/keys.ts +8 -0
- package/examples/kitchen-sink/src/agent.ts +47 -0
- package/examples/kitchen-sink/src/assistant/Assistant.ts +82 -0
- package/examples/kitchen-sink/src/assistant/conversations.ts +71 -0
- package/examples/kitchen-sink/src/assistant/demo-model.ts +33 -0
- package/examples/kitchen-sink/src/assistant/handlers.ts +146 -0
- package/examples/kitchen-sink/src/attachments/Attachments.ts +48 -0
- package/examples/kitchen-sink/src/attachments/download.ts +49 -0
- package/examples/kitchen-sink/src/attachments/handlers.ts +38 -0
- package/examples/kitchen-sink/src/attachments/store.ts +58 -0
- package/examples/kitchen-sink/src/browser.ts +33 -0
- package/examples/kitchen-sink/src/cli.ts +85 -0
- package/examples/kitchen-sink/src/config.ts +18 -0
- package/examples/kitchen-sink/src/identity/Identity.ts +61 -0
- package/examples/kitchen-sink/src/identity/authenticate.ts +87 -0
- package/examples/kitchen-sink/src/identity/authorize.ts +16 -0
- package/examples/kitchen-sink/src/identity/directory.ts +52 -0
- package/examples/kitchen-sink/src/identity/handlers.ts +9 -0
- package/examples/kitchen-sink/src/identity/members.ts +13 -0
- package/examples/kitchen-sink/src/identity/quota.ts +62 -0
- package/examples/kitchen-sink/src/issues/Issues.ts +109 -0
- package/examples/kitchen-sink/src/issues/assistant.ts +23 -0
- package/examples/kitchen-sink/src/issues/guide.ts +37 -0
- package/examples/kitchen-sink/src/issues/handlers.ts +37 -0
- package/examples/kitchen-sink/src/issues/store.ts +96 -0
- package/examples/kitchen-sink/src/issues/write-limit.ts +41 -0
- package/examples/kitchen-sink/src/memory.ts +30 -0
- package/examples/kitchen-sink/src/ops.ts +42 -0
- package/examples/kitchen-sink/src/request-policy.ts +63 -0
- package/examples/kitchen-sink/src/routes.ts +120 -0
- package/examples/kitchen-sink/src/sdk.ts +46 -0
- package/examples/kitchen-sink/src/seed.ts +65 -0
- package/examples/kitchen-sink/src/server.ts +62 -0
- package/examples/kitchen-sink/src/stdio.ts +34 -0
- package/examples/kitchen-sink/src/workspaces/Workspaces.ts +28 -0
- package/examples/kitchen-sink/src/workspaces/handlers.ts +23 -0
- package/examples/kitchen-sink/src/workspaces/registry.ts +46 -0
- package/examples/kitchen-sink/src/workspaces/tenant.ts +17 -0
- package/examples/kitchen-sink/test/admin/admin.test.ts +67 -0
- package/examples/kitchen-sink/test/assistant/assistant.test.ts +435 -0
- package/examples/kitchen-sink/test/attachments/attachments.test.ts +161 -0
- package/examples/kitchen-sink/test/identity/identity.test.ts +137 -0
- package/examples/kitchen-sink/test/identity/session.test.ts +110 -0
- package/examples/kitchen-sink/test/issues/assistant.test.ts +75 -0
- package/examples/kitchen-sink/test/issues/issues.test.ts +135 -0
- package/examples/kitchen-sink/test/request-policy.test.ts +125 -0
- package/examples/kitchen-sink/test/routes.test.ts +123 -0
- package/examples/kitchen-sink/test/support/scripted.ts +40 -0
- package/examples/live-rpc/README.md +107 -0
- package/examples/live-rpc/src/Rpc.ts +8 -0
- package/examples/live-rpc/src/browser.ts +98 -0
- package/examples/live-rpc/src/chat/Chat.ts +75 -0
- package/examples/live-rpc/src/chat/authorize.ts +16 -0
- package/examples/live-rpc/src/chat/handlers.ts +50 -0
- package/examples/live-rpc/src/chat/room.ts +23 -0
- package/examples/live-rpc/src/chat/store.ts +54 -0
- package/examples/live-rpc/src/client.ts +37 -0
- package/examples/live-rpc/src/identity/Identity.ts +19 -0
- package/examples/live-rpc/src/identity/members.ts +8 -0
- package/examples/live-rpc/src/identity/verifier.ts +23 -0
- package/examples/live-rpc/src/routes.ts +22 -0
- package/examples/live-rpc/src/server.ts +43 -0
- package/examples/live-rpc/test/browser.test.ts +105 -0
- package/examples/live-rpc/test/chat/chat.test.ts +69 -0
- package/examples/live-rpc/test/routes.test.ts +99 -0
- package/examples/live-rpc/test/server.test.ts +75 -0
- package/examples/mcp-tools/README.md +136 -0
- package/examples/mcp-tools/src/instructions.ts +12 -0
- package/examples/mcp-tools/src/printers/Printers.ts +119 -0
- package/examples/mcp-tools/src/printers/fake.ts +115 -0
- package/examples/mcp-tools/src/printers/farm.ts +136 -0
- package/examples/mcp-tools/src/printers/handlers.ts +29 -0
- package/examples/mcp-tools/src/routes.ts +52 -0
- package/examples/mcp-tools/src/server.ts +30 -0
- package/examples/mcp-tools/src/stdio.ts +26 -0
- package/examples/mcp-tools/test/printers/printers.test.ts +189 -0
- package/examples/mcp-tools/test/routes.test.ts +202 -0
- package/examples/notes-service/README.md +108 -0
- package/examples/notes-service/src/Api.ts +13 -0
- package/examples/notes-service/src/browser.ts +41 -0
- package/examples/notes-service/src/cli.ts +22 -0
- package/examples/notes-service/src/commands.ts +89 -0
- package/examples/notes-service/src/identity/Identity.ts +31 -0
- package/examples/notes-service/src/identity/authorize.ts +18 -0
- package/examples/notes-service/src/identity/handlers.ts +10 -0
- package/examples/notes-service/src/identity/users.ts +9 -0
- package/examples/notes-service/src/identity/verifier.ts +36 -0
- package/examples/notes-service/src/notes/Notes.ts +91 -0
- package/examples/notes-service/src/notes/handlers.ts +33 -0
- package/examples/notes-service/src/notes/store.ts +73 -0
- package/examples/notes-service/src/routes.ts +45 -0
- package/examples/notes-service/src/server.ts +26 -0
- package/examples/notes-service/src/status/Status.ts +10 -0
- package/examples/notes-service/src/status/handlers.ts +14 -0
- package/examples/notes-service/src/stdio.ts +25 -0
- package/examples/notes-service/test/commands.test.ts +134 -0
- package/examples/notes-service/test/notes/notes.test.ts +103 -0
- package/examples/notes-service/test/routes.test.ts +84 -0
- package/examples/quickstart/README.md +31 -0
- package/examples/quickstart/src/Api.ts +5 -0
- package/examples/quickstart/src/client.ts +13 -0
- package/examples/quickstart/src/greeting/Greeting.ts +12 -0
- package/examples/quickstart/src/greeting/handlers.ts +6 -0
- package/examples/quickstart/src/routes.ts +11 -0
- package/examples/quickstart/src/server.ts +16 -0
- package/examples/quickstart/test/greeting/greeting.test.ts +18 -0
- package/package.json +23 -5
- package/dist/internal/actions.d.mts +0 -9
- package/dist/internal/authentication.mjs +0 -11
- package/dist/internal/call.mjs +0 -12
- package/dist/internal/cli.mjs +0 -328
- package/dist/internal/client.mjs +0 -25
- package/dist/internal/console.mjs +0 -77
- package/dist/internal/declared.d.mts +0 -13
- package/dist/internal/declared.mjs +0 -34
- package/dist/internal/mcp.d.mts +0 -14
- package/dist/internal/media.d.mts +0 -8
- package/dist/internal/media.mjs +0 -26
- package/dist/internal/refusal.mjs +0 -67
- package/dist/internal/schema-errors.mjs +0 -20
- package/dist/internal/tools.mjs +0 -44
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest";
|
|
2
|
+
import { Array, Effect, Exit, References } from "effect";
|
|
3
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
4
|
+
import { CurrentUser } from "../../src/identity/Identity.js";
|
|
5
|
+
import { users } from "../../src/identity/users.js";
|
|
6
|
+
import { notes } from "../../src/notes/handlers.js";
|
|
7
|
+
import { NoteChanged, NoteNotFound } from "../../src/notes/Notes.js";
|
|
8
|
+
import { NoteStore } from "../../src/notes/store.js";
|
|
9
|
+
|
|
10
|
+
// What the implementation does, with no transport: `Action.client` runs the same decoding,
|
|
11
|
+
// `authorize` and handler a server runs. Each call names its caller, as a request would.
|
|
12
|
+
const as = (user: keyof typeof users) => Effect.provideService(CurrentUser, users[user]);
|
|
13
|
+
|
|
14
|
+
describe("notes, in process", () => {
|
|
15
|
+
it.effect("keeps each user's notes their own", () =>
|
|
16
|
+
Effect.gen(function* () {
|
|
17
|
+
const client = yield* Action.client(notes);
|
|
18
|
+
const note = yield* client.createNote({ title: "Plan", body: "Draft" }).pipe(as("ada"));
|
|
19
|
+
|
|
20
|
+
expect(yield* client.listNotes().pipe(as("ada"))).toEqual({
|
|
21
|
+
notes: [{ id: note.id, title: "Plan" }],
|
|
22
|
+
});
|
|
23
|
+
expect(yield* client.listNotes().pipe(as("grace"))).toEqual({ notes: [] });
|
|
24
|
+
|
|
25
|
+
const missing = new NoteNotFound({ id: note.id });
|
|
26
|
+
const update = { id: note.id, body: "Mine now", revision: note.revision };
|
|
27
|
+
|
|
28
|
+
expect(yield* Effect.flip(client.getNote({ id: note.id }).pipe(as("grace")))).toEqual(
|
|
29
|
+
missing,
|
|
30
|
+
);
|
|
31
|
+
expect(yield* Effect.flip(client.updateNote(update).pipe(as("grace")))).toEqual(missing);
|
|
32
|
+
expect(yield* Effect.flip(client.deleteNote({ id: note.id }).pipe(as("grace")))).toEqual(
|
|
33
|
+
missing,
|
|
34
|
+
);
|
|
35
|
+
expect(yield* client.getNote({ id: note.id }).pipe(as("ada"))).toEqual(note);
|
|
36
|
+
}).pipe(Effect.provide(NoteStore.layerMemory)),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
it.effect("deletes a note for its owner", () =>
|
|
40
|
+
Effect.gen(function* () {
|
|
41
|
+
const client = yield* Action.client(notes);
|
|
42
|
+
const note = yield* client.createNote({ title: "Plan", body: "Draft" }).pipe(as("ada"));
|
|
43
|
+
|
|
44
|
+
yield* client.deleteNote({ id: note.id }).pipe(as("ada"));
|
|
45
|
+
|
|
46
|
+
expect(yield* Effect.flip(client.getNote({ id: note.id }).pipe(as("ada")))).toEqual(
|
|
47
|
+
new NoteNotFound({ id: note.id }),
|
|
48
|
+
);
|
|
49
|
+
}).pipe(Effect.provide(NoteStore.layerMemory)),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
it.effect("refuses an update of a revision that is no longer current", () =>
|
|
53
|
+
Effect.gen(function* () {
|
|
54
|
+
const client = yield* Action.client(notes);
|
|
55
|
+
const note = yield* client.createNote({ title: "Plan", body: "Draft" }).pipe(as("ada"));
|
|
56
|
+
const update = { id: note.id, body: "Final", revision: note.revision };
|
|
57
|
+
|
|
58
|
+
expect(yield* client.updateNote(update).pipe(as("ada"))).toMatchObject({ revision: 2 });
|
|
59
|
+
expect(yield* Effect.flip(client.updateNote(update).pipe(as("ada")))).toEqual(
|
|
60
|
+
new NoteChanged({ id: note.id, revision: 2 }),
|
|
61
|
+
);
|
|
62
|
+
}).pipe(Effect.provide(NoteStore.layerMemory)),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
it.effect("lets one of two concurrent updates of the same revision through", () =>
|
|
66
|
+
Effect.gen(function* () {
|
|
67
|
+
const client = yield* Action.client(notes);
|
|
68
|
+
|
|
69
|
+
// A fiber is set aside after every `steps` steps, and two that run at once then take
|
|
70
|
+
// turns. Each count sets the first update aside at another point of its work; under
|
|
71
|
+
// three, the two do not finish.
|
|
72
|
+
const succeeded = yield* Effect.forEach(Array.range(3, 60), (steps) =>
|
|
73
|
+
Effect.gen(function* () {
|
|
74
|
+
const note = yield* client.createNote({ title: "Plan", body: "Draft" }).pipe(as("ada"));
|
|
75
|
+
|
|
76
|
+
const update = (body: string) =>
|
|
77
|
+
Effect.exit(client.updateNote({ id: note.id, body, revision: 1 }).pipe(as("ada")));
|
|
78
|
+
|
|
79
|
+
const exits = yield* Effect.all([update("One"), update("Two")], {
|
|
80
|
+
concurrency: "unbounded",
|
|
81
|
+
}).pipe(Effect.provideService(References.MaxOpsBeforeYield, steps));
|
|
82
|
+
|
|
83
|
+
return exits.filter(Exit.isSuccess).length;
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
expect(succeeded).toEqual(Array.range(3, 60).map(() => 1));
|
|
88
|
+
}).pipe(Effect.provide(NoteStore.layerMemory)),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
it.effect("refuses a write from a caller holding the read scope alone", () =>
|
|
92
|
+
Effect.gen(function* () {
|
|
93
|
+
const client = yield* Action.client(notes);
|
|
94
|
+
const create = client.createNote({ title: "Plan", body: "Draft" });
|
|
95
|
+
const refusal = yield* Effect.flip(create.pipe(as("reader")));
|
|
96
|
+
|
|
97
|
+
// `toEqual` on an error compares its fields and never its message, so the message is
|
|
98
|
+
// asserted on its own.
|
|
99
|
+
expect(refusal).toBeInstanceOf(Action.Forbidden);
|
|
100
|
+
expect(refusal.message).toBe("Requires notes:write.");
|
|
101
|
+
}).pipe(Effect.provide(NoteStore.layerMemory)),
|
|
102
|
+
);
|
|
103
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest";
|
|
2
|
+
import { Effect, Layer } from "effect";
|
|
3
|
+
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
4
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
5
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
6
|
+
import * as Testing from "@gjermundgaraba/effect-actions/Testing";
|
|
7
|
+
import { Http } from "../src/Api.js";
|
|
8
|
+
import { Notes } from "../src/notes/Notes.js";
|
|
9
|
+
import { NoteStore } from "../src/notes/store.js";
|
|
10
|
+
import { routes } from "../src/routes.js";
|
|
11
|
+
import { GetStatus } from "../src/status/Status.js";
|
|
12
|
+
|
|
13
|
+
// What the wire adds: the real routes, behind their real authentication, answering in memory.
|
|
14
|
+
// No port is opened. The caller is the token each client sends, as in production.
|
|
15
|
+
const served = Testing.layer(routes).pipe(Layer.provide(NoteStore.layerMemory));
|
|
16
|
+
|
|
17
|
+
const as = (token: string) => ({
|
|
18
|
+
transformClient: HttpClient.mapRequest(HttpClientRequest.bearerToken(token)),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("the service, over HTTP and MCP", () => {
|
|
22
|
+
it.effect("answers the public action without a token and refuses the rest", () =>
|
|
23
|
+
Effect.gen(function* () {
|
|
24
|
+
const anyone = yield* ActionHttp.client(Http);
|
|
25
|
+
|
|
26
|
+
expect(yield* anyone.getStatus()).toEqual({ service: "notes-service", notes: 0 });
|
|
27
|
+
expect(yield* Effect.flip(anyone.whoAmI())).toBeInstanceOf(Action.Unauthenticated);
|
|
28
|
+
}).pipe(Effect.provide(served)),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
it.effect("refuses a token no user holds", () =>
|
|
32
|
+
Effect.gen(function* () {
|
|
33
|
+
const stranger = yield* ActionHttp.client(Http, as("mallory"));
|
|
34
|
+
|
|
35
|
+
const refusal = yield* Effect.flip(stranger.whoAmI());
|
|
36
|
+
|
|
37
|
+
expect(refusal).toBeInstanceOf(Action.Unauthenticated);
|
|
38
|
+
expect(refusal.message).toBe("Unknown demo token.");
|
|
39
|
+
}).pipe(Effect.provide(served)),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
it.effect("serves the host's own routes beside the actions'", () =>
|
|
43
|
+
Effect.gen(function* () {
|
|
44
|
+
const health = yield* HttpClient.get("/healthz");
|
|
45
|
+
const openApi = yield* HttpClient.get("/api/openapi.json");
|
|
46
|
+
|
|
47
|
+
expect(yield* health.text).toBe("ok");
|
|
48
|
+
expect(yield* openApi.text).toContain('"/api/createNote"');
|
|
49
|
+
}).pipe(Effect.provide(served)),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
it.effect("names the scope a caller lacks, so an OAuth client can ask for it", () =>
|
|
53
|
+
Effect.gen(function* () {
|
|
54
|
+
const reader = yield* ActionHttp.client(Http, as("reader"));
|
|
55
|
+
|
|
56
|
+
expect(yield* reader.whoAmI()).toEqual({ id: "reader", scopes: ["notes:read"] });
|
|
57
|
+
|
|
58
|
+
const refusal = yield* Effect.flip(reader.createNote({ title: "Plan", body: "" }));
|
|
59
|
+
|
|
60
|
+
expect(refusal).toBeInstanceOf(Action.Forbidden);
|
|
61
|
+
expect(refusal).toHaveProperty("scopes", ["notes:write"]);
|
|
62
|
+
}).pipe(Effect.provide(served)),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
it.effect("serves one store to both surfaces: a note written over HTTP is read as a tool", () =>
|
|
66
|
+
Effect.gen(function* () {
|
|
67
|
+
const http = yield* ActionHttp.client(Http, as("ada"));
|
|
68
|
+
const mcp = yield* Testing.mcpClient([GetStatus, ...Notes], as("ada"));
|
|
69
|
+
const note = yield* http.createNote({ title: "Plan", body: "Draft" });
|
|
70
|
+
|
|
71
|
+
expect(yield* mcp.getNote({ id: note.id })).toEqual(note);
|
|
72
|
+
expect(yield* mcp.getStatus()).toEqual({ service: "notes-service", notes: 1 });
|
|
73
|
+
}).pipe(Effect.provide(served)),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
it.effect("lists every tool to a signed-out MCP client, as one of them is public", () =>
|
|
77
|
+
Effect.gen(function* () {
|
|
78
|
+
const response = yield* HttpClient.execute(Testing.mcpRequest("tools/list"));
|
|
79
|
+
|
|
80
|
+
expect(response.status).toBe(200);
|
|
81
|
+
expect(yield* response.text).toContain('"name":"createNote"');
|
|
82
|
+
}).pipe(Effect.provide(served)),
|
|
83
|
+
);
|
|
84
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# quickstart
|
|
2
|
+
|
|
3
|
+
The smallest complete program: one action, served as a JSON route and as an MCP tool, called
|
|
4
|
+
with its typed client. It is the skeleton of [notes-service](../notes-service/), which adds
|
|
5
|
+
authentication, authorization, declared errors, a store, a CLI and a browser client to the same
|
|
6
|
+
layout.
|
|
7
|
+
|
|
8
|
+
## Run it
|
|
9
|
+
|
|
10
|
+
From the repository root, after `vp install` (from an installed package, see
|
|
11
|
+
[Run one](../README.md#run-one)):
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
node --import tsx examples/quickstart/src/server.ts # 127.0.0.1:3000
|
|
15
|
+
node --import tsx examples/quickstart/src/client.ts # Hello, Ada!
|
|
16
|
+
vp test examples/quickstart
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Layout
|
|
20
|
+
|
|
21
|
+
```text
|
|
22
|
+
src/
|
|
23
|
+
├── greeting/
|
|
24
|
+
│ ├── Greeting.ts the contract
|
|
25
|
+
│ └── handlers.ts its implementation
|
|
26
|
+
├── Api.ts the HTTP binding, shared by server and client
|
|
27
|
+
├── routes.ts the surfaces: POST /api/greet and /mcp
|
|
28
|
+
├── server.ts entrypoint
|
|
29
|
+
└── client.ts entrypoint
|
|
30
|
+
test/greeting/ the routes, called in memory
|
|
31
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { NodeRuntime } from "@effect/platform-node";
|
|
2
|
+
import { Console, Effect } from "effect";
|
|
3
|
+
import { FetchHttpClient } from "effect/http";
|
|
4
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
5
|
+
import { Http } from "./Api.js";
|
|
6
|
+
|
|
7
|
+
// Entrypoint: `node --import tsx examples/quickstart/src/client.ts`, with the server running.
|
|
8
|
+
// One typed method per action of the binding.
|
|
9
|
+
Effect.gen(function* () {
|
|
10
|
+
const client = yield* ActionHttp.client(Http, { baseUrl: "http://127.0.0.1:3000" });
|
|
11
|
+
|
|
12
|
+
yield* Console.log(yield* client.greet({ name: "Ada" }));
|
|
13
|
+
}).pipe(Effect.provide(FetchHttpClient.layer), NodeRuntime.runMain);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
3
|
+
|
|
4
|
+
// A contract: what the action takes and answers, whether it changes anything, and who may
|
|
5
|
+
// call it. It imports no server code, so any client can import it, a browser page included.
|
|
6
|
+
export const Greet = Action.make("greet", {
|
|
7
|
+
description: "Greet someone by name.",
|
|
8
|
+
input: { name: Schema.String },
|
|
9
|
+
success: Schema.String,
|
|
10
|
+
readOnly: true,
|
|
11
|
+
caller: Action.Anyone,
|
|
12
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
3
|
+
import { Greet } from "./Greeting.js";
|
|
4
|
+
|
|
5
|
+
// The implementation: the contract bound to its handler. Every surface takes this one value.
|
|
6
|
+
export const greeting = Action.implement(Greet, ({ name }) => Effect.succeed(`Hello, ${name}!`));
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Layer } from "effect";
|
|
2
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
3
|
+
import * as ActionMcp from "@gjermundgaraba/effect-actions/ActionMcp";
|
|
4
|
+
import { Http } from "./Api.js";
|
|
5
|
+
import { greeting } from "./greeting/handlers.js";
|
|
6
|
+
|
|
7
|
+
// One implementation, two surfaces: a JSON route and an MCP tool at `/mcp`.
|
|
8
|
+
export const routes = Layer.mergeAll(
|
|
9
|
+
ActionHttp.layer(Http, greeting),
|
|
10
|
+
ActionMcp.layerHttp(greeting, { name: "greetings", version: "1.0.0" }),
|
|
11
|
+
);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { NodeHttpServer, NodeRuntime } from "@effect/platform-node";
|
|
3
|
+
import { Layer } from "effect";
|
|
4
|
+
import { HttpRouter } from "effect/http";
|
|
5
|
+
import { routes } from "./routes.js";
|
|
6
|
+
|
|
7
|
+
// Entrypoint: `node --import tsx examples/quickstart/src/server.ts`.
|
|
8
|
+
// A real server reads its port from config, `Config.Port("PORT")`, and limits request bodies:
|
|
9
|
+
// `Layer.provide(Layer.succeed(HttpServerRequest.MaxBodySize, ByteSize.mebibytes(1)))`.
|
|
10
|
+
// `127.0.0.1` serves this machine alone: a server others reach reads its host from config,
|
|
11
|
+
// `Config.String("HOST").pipe(Config.withDefault("127.0.0.1"))`.
|
|
12
|
+
HttpRouter.serve(routes).pipe(
|
|
13
|
+
Layer.provide(NodeHttpServer.layer(createServer, { host: "127.0.0.1", port: 3000 })),
|
|
14
|
+
Layer.launch,
|
|
15
|
+
NodeRuntime.runMain,
|
|
16
|
+
);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { expect, it } from "@effect/vitest";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
4
|
+
import * as Testing from "@gjermundgaraba/effect-actions/Testing";
|
|
5
|
+
import { Http } from "../../src/Api.js";
|
|
6
|
+
import { Greet } from "../../src/greeting/Greeting.js";
|
|
7
|
+
import { routes } from "../../src/routes.js";
|
|
8
|
+
|
|
9
|
+
// The routes answer in memory: no port, and the same code the server runs.
|
|
10
|
+
it.effect("greets over HTTP and as an MCP tool", () =>
|
|
11
|
+
Effect.gen(function* () {
|
|
12
|
+
const http = yield* ActionHttp.client(Http);
|
|
13
|
+
const mcp = yield* Testing.mcpClient([Greet]);
|
|
14
|
+
|
|
15
|
+
expect(yield* http.greet({ name: "Ada" })).toBe("Hello, Ada!");
|
|
16
|
+
expect(yield* mcp.greet({ name: "Ada" })).toBe("Hello, Ada!");
|
|
17
|
+
}).pipe(Effect.provide(Testing.layer(routes))),
|
|
18
|
+
);
|
package/package.json
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjermundgaraba/effect-actions",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Define Effect action contracts once and project them onto HTTP, MCP, native Toolkits, and CLIs.",
|
|
3
|
+
"version": "0.12.0",
|
|
4
|
+
"description": "Define Effect action contracts once and project them onto HTTP, MCP, Effect RPC, native Toolkits, and CLIs.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ai-agents",
|
|
7
|
+
"cli",
|
|
8
|
+
"effect",
|
|
9
|
+
"effect-ts",
|
|
10
|
+
"http-api",
|
|
11
|
+
"mcp",
|
|
12
|
+
"mcp-server",
|
|
13
|
+
"model-context-protocol",
|
|
14
|
+
"rpc",
|
|
15
|
+
"typescript"
|
|
16
|
+
],
|
|
5
17
|
"homepage": "https://github.com/gjermundgaraba/effect-actions#readme",
|
|
6
18
|
"bugs": {
|
|
7
19
|
"url": "https://github.com/gjermundgaraba/effect-actions/issues"
|
|
@@ -11,9 +23,14 @@
|
|
|
11
23
|
"type": "git",
|
|
12
24
|
"url": "git+https://github.com/gjermundgaraba/effect-actions.git"
|
|
13
25
|
},
|
|
26
|
+
"directories": {
|
|
27
|
+
"doc": "docs",
|
|
28
|
+
"example": "examples"
|
|
29
|
+
},
|
|
14
30
|
"files": [
|
|
15
31
|
"dist",
|
|
16
|
-
"docs"
|
|
32
|
+
"docs",
|
|
33
|
+
"examples"
|
|
17
34
|
],
|
|
18
35
|
"type": "module",
|
|
19
36
|
"sideEffects": false,
|
|
@@ -22,6 +39,7 @@
|
|
|
22
39
|
"./ActionCli": "./dist/ActionCli.mjs",
|
|
23
40
|
"./ActionHttp": "./dist/ActionHttp.mjs",
|
|
24
41
|
"./ActionMcp": "./dist/ActionMcp.mjs",
|
|
42
|
+
"./ActionRpc": "./dist/ActionRpc.mjs",
|
|
25
43
|
"./ActionToolkit": "./dist/ActionToolkit.mjs",
|
|
26
44
|
"./Authentication": "./dist/Authentication.mjs",
|
|
27
45
|
"./Testing": "./dist/Testing.mjs",
|
|
@@ -65,8 +83,8 @@
|
|
|
65
83
|
"dev": "vp pack --watch",
|
|
66
84
|
"test": "vp test",
|
|
67
85
|
"check": "vp check",
|
|
68
|
-
"example": "node --import tsx examples/server.ts",
|
|
69
|
-
"test:package": "vp run build && node scripts/test-package.
|
|
86
|
+
"example": "node --import tsx examples/notes-service/src/server.ts",
|
|
87
|
+
"test:package": "vp run build && node --import tsx scripts/test-package.ts",
|
|
70
88
|
"docs:sync": "node --import tsx scripts/sync-skill.ts"
|
|
71
89
|
}
|
|
72
90
|
}
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import "../Action.mjs";
|
|
2
|
-
import { SchemaAST } from "effect";
|
|
3
|
-
//#region src/internal/actions.d.ts
|
|
4
|
-
/**
|
|
5
|
-
* The caller of a public action: anyone, signed in or not. Registered globally, so two copies
|
|
6
|
-
* of the package agree on it, as they agree on a name.
|
|
7
|
-
*/
|
|
8
|
-
export declare const Anyone: unique symbol;
|
|
9
|
-
//#endregion
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import { Unauthenticated } from "./errors.mjs";
|
|
2
|
-
import { Anyone } from "./actions.mjs";
|
|
3
|
-
import { Effect, Option } from "effect";
|
|
4
|
-
//#region src/internal/authentication.ts
|
|
5
|
-
const assertAuthentication = (actions, auth) => {
|
|
6
|
-
for (const action of actions) if (action.caller !== Anyone && (auth === void 0 || action.caller.key !== auth.identity.key)) throw new Error(`Protected action '${action.name}' requires its matching authentication descriptor`);
|
|
7
|
-
};
|
|
8
|
-
/** Remote promotion happens at tool entry, before shared dispatch, never in local calls. */
|
|
9
|
-
const promote = (auth, run) => Effect.flatMap(Effect.serviceOption(auth["~verified"]), (actor) => Option.isSome(actor) ? Effect.provideService(run, auth.identity, actor.value) : Effect.fail(new Unauthenticated()));
|
|
10
|
-
//#endregion
|
|
11
|
-
export { assertAuthentication, promote };
|
package/dist/internal/call.mjs
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { Effect, Schema } from "effect";
|
|
2
|
-
//#region src/internal/call.ts
|
|
3
|
-
/**
|
|
4
|
-
* The input a method of `action` sends for the arguments it was called with: a given
|
|
5
|
-
* argument as given, and none as the input `{}` decodes to, as a server decodes the `{}` it
|
|
6
|
-
* receives. `Call` allows none only when `{}` is a valid encoded input, which is what this
|
|
7
|
-
* decodes; decoding makes it one of the input's own values, such as an instance of an input
|
|
8
|
-
* class, or fills its defaults.
|
|
9
|
-
*/
|
|
10
|
-
const inputOf = (action, args) => args.length === 0 ? Schema.decodeUnknownEffect(Schema.toCodecJson(action.input))({}) : Effect.succeed(args[0]);
|
|
11
|
-
//#endregion
|
|
12
|
-
export { inputOf };
|