@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,282 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { HttpBackend } from "../src/backends/http.js";
3
+
4
+ function createMockFetch(responses: Record<string, { status: number; body: any }>) {
5
+ return async (url: string, init?: RequestInit) => {
6
+ const method = init?.method ?? "GET";
7
+ const key = `${method} ${url}`;
8
+ // Try exact match first, then URL only
9
+ const resp = responses[key] ?? responses[url];
10
+ if (!resp) return new Response(JSON.stringify({ error: "not found" }), { status: 404 });
11
+ return new Response(JSON.stringify(resp.body), { status: resp.status, headers: { "Content-Type": "application/json" } });
12
+ };
13
+ }
14
+
15
+ describe("HttpBackend Passthrough", () => {
16
+ it("should list root resources/endpoints when resources exist", async () => {
17
+ const backend = new HttpBackend({
18
+ baseUrl: "https://api.example.com",
19
+ resources: [{ name: "users" }],
20
+ fetch: createMockFetch({}) as any,
21
+ });
22
+ const entries = await backend.list("/");
23
+ expect(entries).toContain("users/");
24
+ });
25
+
26
+ it("should passthrough list when no resources or endpoints", async () => {
27
+ const backend = new HttpBackend({
28
+ baseUrl: "https://api.example.com",
29
+ resources: [],
30
+ endpoints: [],
31
+ fetch: createMockFetch({
32
+ "GET https://api.example.com/items": { status: 200, body: [{ id: "1" }, { id: "2" }] },
33
+ }) as any,
34
+ });
35
+ const entries = await backend.list("/items");
36
+ expect(entries).toEqual(["1", "2"]);
37
+ });
38
+
39
+ it("should passthrough read when no resources or endpoints", async () => {
40
+ const backend = new HttpBackend({
41
+ baseUrl: "https://api.example.com",
42
+ resources: [],
43
+ endpoints: [],
44
+ fetch: createMockFetch({
45
+ "GET https://api.example.com/items/1": { status: 200, body: { id: "1", name: "Test" } },
46
+ }) as any,
47
+ });
48
+ const result = await backend.read("/items/1");
49
+ expect(result).toEqual({ id: "1", name: "Test" });
50
+ });
51
+
52
+ it("should passthrough write when no resources or endpoints", async () => {
53
+ const backend = new HttpBackend({
54
+ baseUrl: "https://api.example.com",
55
+ resources: [],
56
+ endpoints: [],
57
+ fetch: createMockFetch({
58
+ "PUT https://api.example.com/items/1": { status: 200, body: { id: "1" } },
59
+ }) as any,
60
+ });
61
+ const result = await backend.write("/items/1", { name: "Updated" });
62
+ expect(result.id).toBe("1");
63
+ });
64
+
65
+ it("should passthrough POST to root path for create", async () => {
66
+ const backend = new HttpBackend({
67
+ baseUrl: "https://api.example.com",
68
+ resources: [],
69
+ endpoints: [],
70
+ fetch: createMockFetch({
71
+ "POST https://api.example.com/": { status: 201, body: { id: "new-1" } },
72
+ }) as any,
73
+ });
74
+ const result = await backend.write("/", { name: "New" });
75
+ expect(result.id).toBe("new-1");
76
+ });
77
+
78
+ it("should passthrough remove when no resources or endpoints", async () => {
79
+ const backend = new HttpBackend({
80
+ baseUrl: "https://api.example.com",
81
+ resources: [],
82
+ endpoints: [],
83
+ fetch: createMockFetch({
84
+ "DELETE https://api.example.com/items/1": { status: 200, body: { ok: true } },
85
+ }) as any,
86
+ });
87
+ await expect(backend.remove("/items/1")).resolves.toBeUndefined();
88
+ });
89
+
90
+ it("should passthrough search when no resources or endpoints", async () => {
91
+ const backend = new HttpBackend({
92
+ baseUrl: "https://api.example.com",
93
+ resources: [],
94
+ endpoints: [],
95
+ fetch: createMockFetch({
96
+ "GET https://api.example.com/items?q=test": { status: 200, body: [{ id: "1" }] },
97
+ }) as any,
98
+ });
99
+ const results = await backend.search("/items", "test");
100
+ expect(results).toEqual([{ id: "1" }]);
101
+ });
102
+
103
+ it("should still show root listing in passthrough mode", async () => {
104
+ const backend = new HttpBackend({
105
+ baseUrl: "https://api.example.com",
106
+ resources: [],
107
+ endpoints: [],
108
+ fetch: createMockFetch({}) as any,
109
+ });
110
+ const entries = await backend.list("/");
111
+ expect(entries).toEqual([]);
112
+ });
113
+ });
114
+
115
+ describe("HttpBackend Passthrough Fallback (resources exist but path unmatched)", () => {
116
+ it("should fallback to passthrough for multi-segment paths that don't match resources", async () => {
117
+ // Simulates: repos resource exists, but /repos/facebook/react has 2 segments after resource
118
+ const backend = new HttpBackend({
119
+ baseUrl: "https://api.github.com",
120
+ resources: [{ name: "repos" }],
121
+ endpoints: [],
122
+ fetch: createMockFetch({
123
+ "GET https://api.github.com/orgs/facebook/repos": {
124
+ status: 200,
125
+ body: [{ id: 1, name: "react" }],
126
+ },
127
+ }) as any,
128
+ });
129
+ // "orgs" is NOT a defined resource, so it should passthrough
130
+ const result = await backend.read("/orgs/facebook/repos");
131
+ expect(result).toEqual([{ id: 1, name: "react" }]);
132
+ });
133
+
134
+ it("should still use resource resolution for matching paths", async () => {
135
+ const backend = new HttpBackend({
136
+ baseUrl: "https://api.example.com",
137
+ resources: [{ name: "users" }],
138
+ fetch: createMockFetch({
139
+ "GET https://api.example.com/users": { status: 200, body: [{ id: "1" }] },
140
+ }) as any,
141
+ });
142
+ // "users" matches a resource — should use resource-list, not passthrough
143
+ const entries = await backend.list("/users/");
144
+ expect(entries).toEqual(["1.json"]);
145
+ });
146
+
147
+ it("should passthrough write for unmatched paths even when resources exist", async () => {
148
+ const backend = new HttpBackend({
149
+ baseUrl: "https://api.example.com",
150
+ resources: [{ name: "users" }],
151
+ fetch: createMockFetch({
152
+ "PUT https://api.example.com/teams/5/members": { status: 200, body: { id: "m1" } },
153
+ }) as any,
154
+ });
155
+ // "teams" is NOT a defined resource
156
+ const result = await backend.write("/teams/5/members", { userId: "1" });
157
+ expect(result.id).toBe("m1");
158
+ });
159
+
160
+ it("should passthrough delete for unmatched paths even when resources exist", async () => {
161
+ const backend = new HttpBackend({
162
+ baseUrl: "https://api.example.com",
163
+ resources: [{ name: "users" }],
164
+ fetch: createMockFetch({
165
+ "DELETE https://api.example.com/teams/5/members/m1": { status: 200, body: { ok: true } },
166
+ }) as any,
167
+ });
168
+ await expect(backend.remove("/teams/5/members/m1")).resolves.toBeUndefined();
169
+ });
170
+
171
+ it("should throw NotFoundError when passthrough gets 404 from upstream", async () => {
172
+ const backend = new HttpBackend({
173
+ baseUrl: "https://api.example.com",
174
+ resources: [{ name: "users" }],
175
+ fetch: createMockFetch({}) as any, // no matching response → 404
176
+ });
177
+ await expect(backend.read("/nonexistent/path")).rejects.toThrow();
178
+ });
179
+ });
180
+
181
+ describe("HttpBackend Passthrough — resource matches but unconsumed segments", () => {
182
+ // This is the critical case: first segment matches a resource, but extra
183
+ // segments remain that the resource tree cannot consume. The resource layer
184
+ // must yield to passthrough instead of silently dropping segments.
185
+
186
+ it("should passthrough read when matched resource has unconsumed segments", async () => {
187
+ // "repos" is a defined resource, but /repos/facebook/react has 2 segments
188
+ // after "repos" — resource layer can only consume 1 (the id).
189
+ const backend = new HttpBackend({
190
+ baseUrl: "https://api.github.com",
191
+ resources: [{ name: "repos" }],
192
+ fetch: createMockFetch({
193
+ "GET https://api.github.com/repos/facebook/react": {
194
+ status: 200,
195
+ body: { id: 1, full_name: "facebook/react" },
196
+ },
197
+ }) as any,
198
+ });
199
+ const result = await backend.read("/repos/facebook/react");
200
+ expect(result).toEqual({ id: 1, full_name: "facebook/react" });
201
+ });
202
+
203
+ it("should passthrough deeply nested paths past a matched resource", async () => {
204
+ const backend = new HttpBackend({
205
+ baseUrl: "https://api.github.com",
206
+ resources: [{ name: "repos" }],
207
+ fetch: createMockFetch({
208
+ "GET https://api.github.com/repos/facebook/react/issues": {
209
+ status: 200,
210
+ body: [{ id: 42, title: "Bug" }],
211
+ },
212
+ }) as any,
213
+ });
214
+ const result = await backend.read("/repos/facebook/react/issues");
215
+ expect(result).toEqual([{ id: 42, title: "Bug" }]);
216
+ });
217
+
218
+ it("should passthrough list for unconsumed segments past a resource", async () => {
219
+ const backend = new HttpBackend({
220
+ baseUrl: "https://api.github.com",
221
+ resources: [{ name: "repos" }],
222
+ fetch: createMockFetch({
223
+ "GET https://api.github.com/repos/facebook/react/pulls": {
224
+ status: 200,
225
+ body: [{ id: 10, title: "PR" }, { id: 11, title: "PR2" }],
226
+ },
227
+ }) as any,
228
+ });
229
+ const entries = await backend.list("/repos/facebook/react/pulls");
230
+ expect(entries).toEqual(["10", "11"]);
231
+ });
232
+
233
+ it("should passthrough write for unconsumed segments past a resource", async () => {
234
+ const backend = new HttpBackend({
235
+ baseUrl: "https://api.github.com",
236
+ resources: [{ name: "repos" }],
237
+ fetch: createMockFetch({
238
+ "PUT https://api.github.com/repos/facebook/react/issues/42": {
239
+ status: 200,
240
+ body: { id: 42 },
241
+ },
242
+ }) as any,
243
+ });
244
+ const result = await backend.write("/repos/facebook/react/issues/42", { state: "closed" });
245
+ expect(result.id).toBe("42");
246
+ });
247
+
248
+ it("should still use resource-item for exact 2-segment match", async () => {
249
+ // repos/123 → resource-item (only 1 segment after resource name, fully consumed)
250
+ const backend = new HttpBackend({
251
+ baseUrl: "https://api.example.com",
252
+ resources: [{ name: "repos" }],
253
+ fetch: createMockFetch({
254
+ "GET https://api.example.com/repos/123": {
255
+ status: 200,
256
+ body: { id: 123, name: "my-repo" },
257
+ },
258
+ }) as any,
259
+ });
260
+ const result = await backend.read("/repos/123");
261
+ expect(result).toEqual({ id: 123, name: "my-repo" });
262
+ });
263
+
264
+ it("should use children when defined, not passthrough", async () => {
265
+ // zones/{id}/dns_records → children match, should NOT passthrough
266
+ const backend = new HttpBackend({
267
+ baseUrl: "https://api.cloudflare.com/client/v4",
268
+ resources: [{
269
+ name: "zones",
270
+ children: [{ name: "dns_records" }],
271
+ }],
272
+ fetch: createMockFetch({
273
+ "GET https://api.cloudflare.com/client/v4/zones/z1/dns_records": {
274
+ status: 200,
275
+ body: [{ id: "r1", type: "A" }],
276
+ },
277
+ }) as any,
278
+ });
279
+ const entries = await backend.list("/zones/z1/dns_records/");
280
+ expect(entries).toEqual(["r1.json"]);
281
+ });
282
+ });
@@ -0,0 +1,132 @@
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 429 retry with backoff", () => {
6
+ let server: Server;
7
+ let port: number;
8
+ let requestCount: number;
9
+
10
+ beforeAll(async () => {
11
+ await new Promise<void>((resolve) => {
12
+ server = createServer((req: IncomingMessage, res: ServerResponse) => {
13
+ res.setHeader("Content-Type", "application/json");
14
+ requestCount++;
15
+
16
+ // /rate-limited → 429 twice, then 200
17
+ if (req.url === "/rate-limited") {
18
+ if (requestCount <= 2) {
19
+ res.writeHead(429, { "Retry-After": "0" }); // 0 seconds for fast tests
20
+ res.end(JSON.stringify({ error: "rate limited" }));
21
+ } else {
22
+ res.writeHead(200);
23
+ res.end(JSON.stringify({ id: "ok", data: "success" }));
24
+ }
25
+ return;
26
+ }
27
+
28
+ // /always-429 → always 429
29
+ if (req.url === "/always-429") {
30
+ res.writeHead(429, { "Retry-After": "0" });
31
+ res.end(JSON.stringify({ error: "rate limited" }));
32
+ return;
33
+ }
34
+
35
+ // /server-error → 500 twice, then 200
36
+ if (req.url === "/server-error") {
37
+ if (requestCount <= 2) {
38
+ res.writeHead(500);
39
+ res.end(JSON.stringify({ error: "internal error" }));
40
+ } else {
41
+ res.writeHead(200);
42
+ res.end(JSON.stringify([{ id: "1" }]));
43
+ }
44
+ return;
45
+ }
46
+
47
+ // /client-error → 400 (should not retry)
48
+ if (req.url === "/client-error") {
49
+ res.writeHead(400);
50
+ res.end(JSON.stringify({ error: "bad request" }));
51
+ return;
52
+ }
53
+
54
+ res.writeHead(200);
55
+ res.end(JSON.stringify({ ok: true }));
56
+ });
57
+ server.listen(0, () => {
58
+ port = (server.address() as any).port;
59
+ resolve();
60
+ });
61
+ });
62
+ });
63
+
64
+ afterAll(() => server?.close());
65
+
66
+ beforeAll(() => {
67
+ requestCount = 0;
68
+ });
69
+
70
+ it("should retry on 429 and eventually succeed", async () => {
71
+ requestCount = 0;
72
+ const backend = new HttpBackend({
73
+ baseUrl: `http://localhost:${port}`,
74
+ retry: { maxRetries: 3, baseDelayMs: 10 },
75
+ });
76
+
77
+ const result = await backend.read("/rate-limited");
78
+ expect(result).toEqual({ id: "ok", data: "success" });
79
+ expect(requestCount).toBe(3); // 2 x 429 + 1 x 200
80
+ });
81
+
82
+ it("should retry on 5xx and eventually succeed", async () => {
83
+ requestCount = 0;
84
+ const backend = new HttpBackend({
85
+ baseUrl: `http://localhost:${port}`,
86
+ retry: { maxRetries: 3, baseDelayMs: 10 },
87
+ });
88
+
89
+ const items = await backend.list("/server-error");
90
+ // Since it's passthrough mode, list returns parsed response
91
+ expect(requestCount).toBe(3);
92
+ });
93
+
94
+ it("should NOT retry on 400 client error", async () => {
95
+ requestCount = 0;
96
+ const backend = new HttpBackend({
97
+ baseUrl: `http://localhost:${port}`,
98
+ retry: { maxRetries: 3, baseDelayMs: 10 },
99
+ });
100
+
101
+ // 400 should not be retried — passthrough list returns empty
102
+ await backend.list("/client-error");
103
+ expect(requestCount).toBe(1); // Only 1 request, no retry
104
+ });
105
+
106
+ it("should give up after maxRetries exhausted", async () => {
107
+ requestCount = 0;
108
+ const backend = new HttpBackend({
109
+ baseUrl: `http://localhost:${port}`,
110
+ retry: { maxRetries: 2, baseDelayMs: 10 },
111
+ });
112
+
113
+ // always-429 will never succeed
114
+ await backend.list("/always-429");
115
+ expect(requestCount).toBe(3); // 1 initial + 2 retries
116
+ });
117
+
118
+ it("should respect Retry-After header", async () => {
119
+ requestCount = 0;
120
+ const start = Date.now();
121
+ const backend = new HttpBackend({
122
+ baseUrl: `http://localhost:${port}`,
123
+ retry: { maxRetries: 3, baseDelayMs: 5000 }, // high base delay
124
+ });
125
+
126
+ // Retry-After: 0 should override the high base delay
127
+ await backend.read("/rate-limited");
128
+ const elapsed = Date.now() - start;
129
+ // With Retry-After: 0, should complete quickly despite high baseDelayMs
130
+ expect(elapsed).toBeLessThan(1000);
131
+ });
132
+ });