@nkmc/agent-fs 0.1.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 (42) hide show
  1. package/dist/chunk-7LIZT7L3.js +966 -0
  2. package/dist/index.cjs +1278 -0
  3. package/dist/index.d.cts +96 -0
  4. package/dist/index.d.ts +96 -0
  5. package/dist/index.js +419 -0
  6. package/dist/rpc-D1IHpjF_.d.cts +330 -0
  7. package/dist/rpc-D1IHpjF_.d.ts +330 -0
  8. package/dist/testing.cjs +842 -0
  9. package/dist/testing.d.cts +29 -0
  10. package/dist/testing.d.ts +29 -0
  11. package/dist/testing.js +10 -0
  12. package/package.json +25 -0
  13. package/src/agent-fs.ts +151 -0
  14. package/src/backends/http.ts +835 -0
  15. package/src/backends/memory.ts +183 -0
  16. package/src/backends/rpc.ts +456 -0
  17. package/src/index.ts +36 -0
  18. package/src/mount.ts +84 -0
  19. package/src/parser.ts +162 -0
  20. package/src/server.ts +158 -0
  21. package/src/testing.ts +3 -0
  22. package/src/types.ts +52 -0
  23. package/test/agent-fs.test.ts +325 -0
  24. package/test/http-204.test.ts +102 -0
  25. package/test/http-auth-prefix.test.ts +79 -0
  26. package/test/http-cloudflare.test.ts +533 -0
  27. package/test/http-form-encoding.test.ts +119 -0
  28. package/test/http-github.test.ts +580 -0
  29. package/test/http-listkey.test.ts +128 -0
  30. package/test/http-oauth2.test.ts +174 -0
  31. package/test/http-pagination.test.ts +200 -0
  32. package/test/http-param-styles.test.ts +98 -0
  33. package/test/http-passthrough.test.ts +282 -0
  34. package/test/http-retry.test.ts +132 -0
  35. package/test/http.test.ts +360 -0
  36. package/test/memory.test.ts +120 -0
  37. package/test/mount.test.ts +94 -0
  38. package/test/parser.test.ts +100 -0
  39. package/test/rpc-crud.test.ts +627 -0
  40. package/test/rpc-evm.test.ts +390 -0
  41. package/tsconfig.json +8 -0
  42. package/tsup.config.ts +8 -0
@@ -0,0 +1,174 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
+ import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http";
3
+ import { HttpBackend } from "../src/backends/http.js";
4
+
5
+ describe("HttpBackend OAuth2 client_credentials", () => {
6
+ let server: Server;
7
+ let port: number;
8
+ let tokenRequestCount: number;
9
+
10
+ beforeAll(async () => {
11
+ tokenRequestCount = 0;
12
+
13
+ await new Promise<void>((resolve) => {
14
+ server = createServer((req: IncomingMessage, res: ServerResponse) => {
15
+ res.setHeader("Content-Type", "application/json");
16
+
17
+ const chunks: Buffer[] = [];
18
+ req.on("data", (c: Buffer) => chunks.push(c));
19
+ req.on("end", () => {
20
+ const body = chunks.length ? Buffer.concat(chunks).toString() : "";
21
+
22
+ // Token endpoint
23
+ if (req.url === "/oauth/token" && req.method === "POST") {
24
+ tokenRequestCount++;
25
+
26
+ // Verify client credentials in Authorization header
27
+ const auth = req.headers.authorization;
28
+ if (!auth?.startsWith("Basic ")) {
29
+ res.writeHead(401);
30
+ res.end(JSON.stringify({ error: "invalid_client" }));
31
+ return;
32
+ }
33
+
34
+ const decoded = Buffer.from(auth.slice(6), "base64").toString();
35
+ const [clientId, clientSecret] = decoded.split(":");
36
+ if (clientId !== "my-client" || clientSecret !== "my-secret") {
37
+ res.writeHead(401);
38
+ res.end(JSON.stringify({ error: "invalid_client" }));
39
+ return;
40
+ }
41
+
42
+ // Verify grant_type
43
+ const params = new URLSearchParams(body);
44
+ if (params.get("grant_type") !== "client_credentials") {
45
+ res.writeHead(400);
46
+ res.end(JSON.stringify({ error: "unsupported_grant_type" }));
47
+ return;
48
+ }
49
+
50
+ res.writeHead(200);
51
+ res.end(JSON.stringify({
52
+ access_token: `token-${tokenRequestCount}`,
53
+ token_type: "bearer",
54
+ expires_in: 3600,
55
+ }));
56
+ return;
57
+ }
58
+
59
+ // Protected API endpoint
60
+ if (req.url === "/api/data" && req.method === "GET") {
61
+ const auth = req.headers.authorization;
62
+ if (!auth?.startsWith("Bearer token-")) {
63
+ res.writeHead(401);
64
+ res.end(JSON.stringify({ error: "unauthorized" }));
65
+ return;
66
+ }
67
+
68
+ res.writeHead(200);
69
+ res.end(JSON.stringify({ items: [{ id: "1" }], token_used: auth }));
70
+ return;
71
+ }
72
+
73
+ res.writeHead(404);
74
+ res.end("{}");
75
+ });
76
+ });
77
+ server.listen(0, () => {
78
+ port = (server.address() as any).port;
79
+ resolve();
80
+ });
81
+ });
82
+ });
83
+
84
+ afterAll(() => server?.close());
85
+
86
+ it("should automatically fetch OAuth2 token and use it for requests", async () => {
87
+ tokenRequestCount = 0;
88
+ const backend = new HttpBackend({
89
+ baseUrl: `http://localhost:${port}`,
90
+ auth: {
91
+ type: "oauth2",
92
+ tokenUrl: `http://localhost:${port}/oauth/token`,
93
+ clientId: "my-client",
94
+ clientSecret: "my-secret",
95
+ },
96
+ });
97
+
98
+ const result = await backend.read("/api/data") as any;
99
+ expect(result.items).toEqual([{ id: "1" }]);
100
+ expect(result.token_used).toBe("Bearer token-1");
101
+ expect(tokenRequestCount).toBe(1);
102
+ });
103
+
104
+ it("should cache the token across multiple requests", async () => {
105
+ tokenRequestCount = 0;
106
+ const backend = new HttpBackend({
107
+ baseUrl: `http://localhost:${port}`,
108
+ auth: {
109
+ type: "oauth2",
110
+ tokenUrl: `http://localhost:${port}/oauth/token`,
111
+ clientId: "my-client",
112
+ clientSecret: "my-secret",
113
+ },
114
+ });
115
+
116
+ await backend.read("/api/data");
117
+ await backend.read("/api/data");
118
+ await backend.read("/api/data");
119
+
120
+ // Token should only be fetched once (cached)
121
+ expect(tokenRequestCount).toBe(1);
122
+ });
123
+
124
+ it("should pass scope if provided", async () => {
125
+ let tokenBody = "";
126
+ const mockServer = createServer((req: IncomingMessage, res: ServerResponse) => {
127
+ res.setHeader("Content-Type", "application/json");
128
+ const chunks: Buffer[] = [];
129
+ req.on("data", (c: Buffer) => chunks.push(c));
130
+ req.on("end", () => {
131
+ const body = chunks.length ? Buffer.concat(chunks).toString() : "";
132
+ if (req.url === "/token") {
133
+ tokenBody = body; // capture only the token request body
134
+ res.end(JSON.stringify({ access_token: "t", expires_in: 3600 }));
135
+ return;
136
+ }
137
+ res.end(JSON.stringify({ ok: true }));
138
+ });
139
+ });
140
+
141
+ await new Promise<void>((resolve) => mockServer.listen(0, resolve));
142
+ const p = (mockServer.address() as any).port;
143
+
144
+ const backend = new HttpBackend({
145
+ baseUrl: `http://localhost:${p}`,
146
+ auth: {
147
+ type: "oauth2",
148
+ tokenUrl: `http://localhost:${p}/token`,
149
+ clientId: "c",
150
+ clientSecret: "s",
151
+ scope: "read write",
152
+ },
153
+ });
154
+
155
+ await backend.read("/test");
156
+ expect(tokenBody).toContain("scope=read+write");
157
+
158
+ mockServer.close();
159
+ });
160
+
161
+ it("should fail gracefully on invalid credentials", async () => {
162
+ const backend = new HttpBackend({
163
+ baseUrl: `http://localhost:${port}`,
164
+ auth: {
165
+ type: "oauth2",
166
+ tokenUrl: `http://localhost:${port}/oauth/token`,
167
+ clientId: "wrong",
168
+ clientSecret: "wrong",
169
+ },
170
+ });
171
+
172
+ await expect(backend.read("/api/data")).rejects.toThrow("OAuth2 token request failed");
173
+ });
174
+ });
@@ -0,0 +1,200 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
+ import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http";
3
+ import { HttpBackend } from "../src/backends/http.js";
4
+ import { getNestedValue } from "../src/backends/http.js";
5
+
6
+ describe("getNestedValue", () => {
7
+ it("should resolve dot-separated paths", () => {
8
+ expect(getNestedValue({ a: { b: { c: 42 } } }, "a.b.c")).toBe(42);
9
+ });
10
+
11
+ it("should resolve single-level paths", () => {
12
+ expect(getNestedValue({ cursor: "abc" }, "cursor")).toBe("abc");
13
+ });
14
+
15
+ it("should return undefined for missing paths", () => {
16
+ expect(getNestedValue({ a: 1 }, "b")).toBeUndefined();
17
+ expect(getNestedValue(null, "a")).toBeUndefined();
18
+ });
19
+ });
20
+
21
+ describe("Cursor pagination", () => {
22
+ let server: Server;
23
+ let port: number;
24
+
25
+ beforeAll(async () => {
26
+ await new Promise<void>((resolve) => {
27
+ server = createServer((req: IncomingMessage, res: ServerResponse) => {
28
+ res.setHeader("Content-Type", "application/json");
29
+ const url = new URL(req.url ?? "/", "http://localhost");
30
+
31
+ if (url.pathname === "/items") {
32
+ const cursor = url.searchParams.get("starting_after");
33
+ if (!cursor) {
34
+ // Page 1
35
+ res.end(JSON.stringify({
36
+ data: [{ id: "a" }, { id: "b" }],
37
+ has_more: true,
38
+ next_cursor: "b",
39
+ }));
40
+ } else if (cursor === "b") {
41
+ // Page 2
42
+ res.end(JSON.stringify({
43
+ data: [{ id: "c" }, { id: "d" }],
44
+ has_more: true,
45
+ next_cursor: "d",
46
+ }));
47
+ } else {
48
+ // Page 3 (last)
49
+ res.end(JSON.stringify({
50
+ data: [{ id: "e" }],
51
+ has_more: false,
52
+ }));
53
+ }
54
+ return;
55
+ }
56
+
57
+ res.writeHead(404);
58
+ res.end("{}");
59
+ });
60
+ server.listen(0, () => {
61
+ port = (server.address() as any).port;
62
+ resolve();
63
+ });
64
+ });
65
+ });
66
+
67
+ afterAll(() => server?.close());
68
+
69
+ it("should follow cursor pagination across multiple pages", async () => {
70
+ const backend = new HttpBackend({
71
+ baseUrl: `http://localhost:${port}`,
72
+ resources: [{ name: "items", apiPath: "/items", listKey: "data" }],
73
+ pagination: {
74
+ type: "cursor",
75
+ cursorParam: "starting_after",
76
+ cursorPath: "next_cursor",
77
+ maxPages: 10,
78
+ },
79
+ });
80
+
81
+ const items = await backend.list("/items/");
82
+ expect(items).toEqual(["a.json", "b.json", "c.json", "d.json", "e.json"]);
83
+ });
84
+
85
+ it("should respect maxPages limit", async () => {
86
+ const backend = new HttpBackend({
87
+ baseUrl: `http://localhost:${port}`,
88
+ resources: [{ name: "items", apiPath: "/items", listKey: "data" }],
89
+ pagination: {
90
+ type: "cursor",
91
+ cursorParam: "starting_after",
92
+ cursorPath: "next_cursor",
93
+ maxPages: 2, // Only fetch 2 pages total
94
+ },
95
+ });
96
+
97
+ const items = await backend.list("/items/");
98
+ // Page 1: a,b + Page 2: c,d (maxPages=2 means 1 additional page)
99
+ expect(items).toEqual(["a.json", "b.json", "c.json", "d.json"]);
100
+ });
101
+ });
102
+
103
+ describe("Offset pagination", () => {
104
+ let server: Server;
105
+ let port: number;
106
+
107
+ beforeAll(async () => {
108
+ await new Promise<void>((resolve) => {
109
+ server = createServer((req: IncomingMessage, res: ServerResponse) => {
110
+ res.setHeader("Content-Type", "application/json");
111
+ const url = new URL(req.url ?? "/", "http://localhost");
112
+
113
+ if (url.pathname === "/records") {
114
+ const offset = parseInt(url.searchParams.get("offset") ?? "0");
115
+ const limit = parseInt(url.searchParams.get("limit") ?? "2");
116
+ const allItems = [{ id: "1" }, { id: "2" }, { id: "3" }, { id: "4" }, { id: "5" }];
117
+ const page = allItems.slice(offset, offset + limit);
118
+ res.end(JSON.stringify({ items: page, total: allItems.length }));
119
+ return;
120
+ }
121
+
122
+ res.writeHead(404);
123
+ res.end("{}");
124
+ });
125
+ server.listen(0, () => {
126
+ port = (server.address() as any).port;
127
+ resolve();
128
+ });
129
+ });
130
+ });
131
+
132
+ afterAll(() => server?.close());
133
+
134
+ it("should follow offset pagination", async () => {
135
+ const backend = new HttpBackend({
136
+ baseUrl: `http://localhost:${port}`,
137
+ resources: [{ name: "records", apiPath: "/records", listKey: "items" }],
138
+ pagination: {
139
+ type: "offset",
140
+ offsetParam: "offset",
141
+ limitParam: "limit",
142
+ pageSize: 2,
143
+ maxPages: 10,
144
+ },
145
+ });
146
+
147
+ const items = await backend.list("/records/");
148
+ expect(items).toEqual(["1.json", "2.json", "3.json", "4.json", "5.json"]);
149
+ });
150
+ });
151
+
152
+ describe("Page pagination", () => {
153
+ let server: Server;
154
+ let port: number;
155
+
156
+ beforeAll(async () => {
157
+ await new Promise<void>((resolve) => {
158
+ server = createServer((req: IncomingMessage, res: ServerResponse) => {
159
+ res.setHeader("Content-Type", "application/json");
160
+ const url = new URL(req.url ?? "/", "http://localhost");
161
+
162
+ if (url.pathname === "/entries") {
163
+ const page = parseInt(url.searchParams.get("page") ?? "1");
164
+ if (page === 1) {
165
+ res.end(JSON.stringify({ results: [{ id: "x" }, { id: "y" }] }));
166
+ } else if (page === 2) {
167
+ res.end(JSON.stringify({ results: [{ id: "z" }] }));
168
+ } else {
169
+ res.end(JSON.stringify({ results: [] }));
170
+ }
171
+ return;
172
+ }
173
+
174
+ res.writeHead(404);
175
+ res.end("{}");
176
+ });
177
+ server.listen(0, () => {
178
+ port = (server.address() as any).port;
179
+ resolve();
180
+ });
181
+ });
182
+ });
183
+
184
+ afterAll(() => server?.close());
185
+
186
+ it("should follow page pagination", async () => {
187
+ const backend = new HttpBackend({
188
+ baseUrl: `http://localhost:${port}`,
189
+ resources: [{ name: "entries", apiPath: "/entries", listKey: "results" }],
190
+ pagination: {
191
+ type: "page",
192
+ pageParam: "page",
193
+ maxPages: 10,
194
+ },
195
+ });
196
+
197
+ const items = await backend.list("/entries/");
198
+ expect(items).toEqual(["x.json", "y.json", "z.json"]);
199
+ });
200
+ });
@@ -0,0 +1,98 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
+ import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http";
3
+ import { HttpBackend } from "../src/backends/http.js";
4
+
5
+ describe("HttpBackend {param} path parameter support", () => {
6
+ let server: Server;
7
+ let port: number;
8
+
9
+ beforeAll(async () => {
10
+ await new Promise<void>((resolve) => {
11
+ server = createServer((req: IncomingMessage, res: ServerResponse) => {
12
+ res.setHeader("Content-Type", "application/json");
13
+
14
+ // /accounts/acc123/records → list
15
+ const listMatch = req.url?.match(/^\/accounts\/(\w+)\/records$/);
16
+ if (listMatch && req.method === "GET") {
17
+ res.end(JSON.stringify([
18
+ { id: "r1", account: listMatch[1] },
19
+ { id: "r2", account: listMatch[1] },
20
+ ]));
21
+ return;
22
+ }
23
+
24
+ // /accounts/acc123/records/r1 → single item
25
+ const getMatch = req.url?.match(/^\/accounts\/(\w+)\/records\/(\w+)$/);
26
+ if (getMatch && req.method === "GET") {
27
+ res.end(JSON.stringify({ id: getMatch[2], account: getMatch[1], data: "test" }));
28
+ return;
29
+ }
30
+
31
+ res.writeHead(404);
32
+ res.end("{}");
33
+ });
34
+ server.listen(0, () => {
35
+ port = (server.address() as any).port;
36
+ resolve();
37
+ });
38
+ });
39
+ });
40
+
41
+ afterAll(() => server?.close());
42
+
43
+ it("should resolve {accountId} style params in apiPath", async () => {
44
+ const backend = new HttpBackend({
45
+ baseUrl: `http://localhost:${port}`,
46
+ params: { accountId: "acc123" },
47
+ resources: [{
48
+ name: "records",
49
+ apiPath: "/accounts/{accountId}/records",
50
+ }],
51
+ });
52
+
53
+ const items = await backend.list("/records/");
54
+ expect(items).toEqual(["r1.json", "r2.json"]);
55
+ });
56
+
57
+ it("should resolve :accountId style params (existing behavior)", async () => {
58
+ const backend = new HttpBackend({
59
+ baseUrl: `http://localhost:${port}`,
60
+ params: { accountId: "acc123" },
61
+ resources: [{
62
+ name: "records",
63
+ apiPath: "/accounts/:accountId/records",
64
+ }],
65
+ });
66
+
67
+ const items = await backend.list("/records/");
68
+ expect(items).toEqual(["r1.json", "r2.json"]);
69
+ });
70
+
71
+ it("should resolve {param} in cat path", async () => {
72
+ const backend = new HttpBackend({
73
+ baseUrl: `http://localhost:${port}`,
74
+ params: { accountId: "acc123" },
75
+ resources: [{
76
+ name: "records",
77
+ apiPath: "/accounts/{accountId}/records",
78
+ }],
79
+ });
80
+
81
+ const result = await backend.read("/records/r1.json") as Record<string, unknown>;
82
+ expect(result.id).toBe("r1");
83
+ expect(result.account).toBe("acc123");
84
+ });
85
+
86
+ it("should throw for missing {param}", () => {
87
+ const backend = new HttpBackend({
88
+ baseUrl: `http://localhost:${port}`,
89
+ params: {}, // no accountId
90
+ resources: [{
91
+ name: "records",
92
+ apiPath: "/accounts/{accountId}/records",
93
+ }],
94
+ });
95
+
96
+ expect(() => backend.list("/records/")).rejects.toThrow("Missing param: accountId");
97
+ });
98
+ });