@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.
- package/dist/chunk-7LIZT7L3.js +966 -0
- package/dist/index.cjs +1278 -0
- package/dist/index.d.cts +96 -0
- package/dist/index.d.ts +96 -0
- package/dist/index.js +419 -0
- package/dist/rpc-D1IHpjF_.d.cts +330 -0
- package/dist/rpc-D1IHpjF_.d.ts +330 -0
- package/dist/testing.cjs +842 -0
- package/dist/testing.d.cts +29 -0
- package/dist/testing.d.ts +29 -0
- package/dist/testing.js +10 -0
- package/package.json +25 -0
- package/src/agent-fs.ts +151 -0
- package/src/backends/http.ts +835 -0
- package/src/backends/memory.ts +183 -0
- package/src/backends/rpc.ts +456 -0
- package/src/index.ts +36 -0
- package/src/mount.ts +84 -0
- package/src/parser.ts +162 -0
- package/src/server.ts +158 -0
- package/src/testing.ts +3 -0
- package/src/types.ts +52 -0
- package/test/agent-fs.test.ts +325 -0
- package/test/http-204.test.ts +102 -0
- package/test/http-auth-prefix.test.ts +79 -0
- package/test/http-cloudflare.test.ts +533 -0
- package/test/http-form-encoding.test.ts +119 -0
- package/test/http-github.test.ts +580 -0
- package/test/http-listkey.test.ts +128 -0
- package/test/http-oauth2.test.ts +174 -0
- package/test/http-pagination.test.ts +200 -0
- package/test/http-param-styles.test.ts +98 -0
- package/test/http-passthrough.test.ts +282 -0
- package/test/http-retry.test.ts +132 -0
- package/test/http.test.ts +360 -0
- package/test/memory.test.ts +120 -0
- package/test/mount.test.ts +94 -0
- package/test/parser.test.ts +100 -0
- package/test/rpc-crud.test.ts +627 -0
- package/test/rpc-evm.test.ts +390 -0
- package/tsconfig.json +8 -0
- package/tsup.config.ts +8 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1278 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
AgentFs: () => AgentFs,
|
|
24
|
+
HttpBackend: () => HttpBackend,
|
|
25
|
+
JsonRpcTransport: () => JsonRpcTransport,
|
|
26
|
+
MountResolver: () => MountResolver,
|
|
27
|
+
RpcBackend: () => RpcBackend,
|
|
28
|
+
RpcError: () => RpcError,
|
|
29
|
+
VERSION: () => VERSION,
|
|
30
|
+
createAgentFsServer: () => createAgentFsServer,
|
|
31
|
+
parseCommand: () => parseCommand
|
|
32
|
+
});
|
|
33
|
+
module.exports = __toCommonJS(index_exports);
|
|
34
|
+
|
|
35
|
+
// src/parser.ts
|
|
36
|
+
var VALID_OPS = /* @__PURE__ */ new Set(["ls", "cat", "write", "rm", "grep"]);
|
|
37
|
+
function parseCommand(input) {
|
|
38
|
+
const trimmed = input.trim();
|
|
39
|
+
const normalized = trimmed.startsWith("nk ") ? trimmed.slice(3).trim() : trimmed;
|
|
40
|
+
const spaceIdx = normalized.indexOf(" ");
|
|
41
|
+
if (spaceIdx === -1) {
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
error: {
|
|
45
|
+
code: "PARSE_ERROR",
|
|
46
|
+
message: `Missing path: "${input}"`
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const op = normalized.slice(0, spaceIdx);
|
|
51
|
+
if (!VALID_OPS.has(op)) {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
error: {
|
|
55
|
+
code: "PARSE_ERROR",
|
|
56
|
+
message: `Unknown operation: "${op}". Valid: ls, cat, write, rm, grep`
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const rest = normalized.slice(spaceIdx + 1).trim();
|
|
61
|
+
if (op === "grep") {
|
|
62
|
+
return parseGrep(rest, input);
|
|
63
|
+
}
|
|
64
|
+
if (op === "write") {
|
|
65
|
+
return parseWrite(rest, input);
|
|
66
|
+
}
|
|
67
|
+
const path = normalizePath(rest);
|
|
68
|
+
if (!path) {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
error: { code: "PARSE_ERROR", message: `Invalid path: "${rest}"` }
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return { ok: true, data: { op, path } };
|
|
75
|
+
}
|
|
76
|
+
function parseGrep(rest, raw) {
|
|
77
|
+
let pattern;
|
|
78
|
+
let pathPart;
|
|
79
|
+
if (rest.startsWith('"') || rest.startsWith("'")) {
|
|
80
|
+
const quote = rest[0];
|
|
81
|
+
const endQuote = rest.indexOf(quote, 1);
|
|
82
|
+
if (endQuote === -1) {
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
error: { code: "PARSE_ERROR", message: `Unterminated quote in: "${raw}"` }
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
pattern = rest.slice(1, endQuote);
|
|
89
|
+
pathPart = rest.slice(endQuote + 1).trim();
|
|
90
|
+
} else {
|
|
91
|
+
const spaceIdx = rest.indexOf(" ");
|
|
92
|
+
if (spaceIdx === -1) {
|
|
93
|
+
return {
|
|
94
|
+
ok: false,
|
|
95
|
+
error: { code: "PARSE_ERROR", message: `grep requires pattern and path: "${raw}"` }
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
pattern = rest.slice(0, spaceIdx);
|
|
99
|
+
pathPart = rest.slice(spaceIdx + 1).trim();
|
|
100
|
+
}
|
|
101
|
+
const path = normalizePath(pathPart);
|
|
102
|
+
if (!path) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
error: { code: "PARSE_ERROR", message: `Invalid path: "${pathPart}"` }
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return { ok: true, data: { op: "grep", path, pattern } };
|
|
109
|
+
}
|
|
110
|
+
function parseWrite(rest, raw) {
|
|
111
|
+
const pathMatch = rest.match(/^(\/\S+)\s+(.+)$/s);
|
|
112
|
+
if (!pathMatch) {
|
|
113
|
+
return {
|
|
114
|
+
ok: false,
|
|
115
|
+
error: { code: "PARSE_ERROR", message: `write requires path and data: "${raw}"` }
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const path = normalizePath(pathMatch[1]);
|
|
119
|
+
if (!path) {
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
error: { code: "PARSE_ERROR", message: `Invalid path: "${pathMatch[1]}"` }
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
let dataStr = pathMatch[2].trim();
|
|
126
|
+
if (dataStr.startsWith("'") && dataStr.endsWith("'") || dataStr.startsWith('"') && dataStr.endsWith('"')) {
|
|
127
|
+
dataStr = dataStr.slice(1, -1);
|
|
128
|
+
}
|
|
129
|
+
let data;
|
|
130
|
+
try {
|
|
131
|
+
data = JSON.parse(dataStr);
|
|
132
|
+
} catch {
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
error: { code: "PARSE_ERROR", message: `Invalid JSON data: ${dataStr}` }
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return { ok: true, data: { op: "write", path, data } };
|
|
139
|
+
}
|
|
140
|
+
function normalizePath(raw) {
|
|
141
|
+
if (!raw.startsWith("/")) return null;
|
|
142
|
+
if (raw.includes("..")) return null;
|
|
143
|
+
const cleaned = raw.replace(/\/+/g, "/");
|
|
144
|
+
return cleaned;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// src/mount.ts
|
|
148
|
+
var MountResolver = class {
|
|
149
|
+
mounts = [];
|
|
150
|
+
/** Optional async callback invoked when resolve() finds no matching mount.
|
|
151
|
+
* Should return true if a new mount was added. */
|
|
152
|
+
onMiss;
|
|
153
|
+
/** Register a mount point */
|
|
154
|
+
add(mount) {
|
|
155
|
+
const normalized = mount.path.replace(/\/+$/, "") || "/";
|
|
156
|
+
this.mounts.push({ ...mount, path: normalized });
|
|
157
|
+
this.mounts.sort((a, b) => b.path.length - a.path.length);
|
|
158
|
+
}
|
|
159
|
+
/** Resolve a virtual path to a mount and its relative path */
|
|
160
|
+
resolve(virtualPath) {
|
|
161
|
+
for (const mount of this.mounts) {
|
|
162
|
+
if (virtualPath === mount.path || virtualPath.startsWith(mount.path + "/")) {
|
|
163
|
+
const relativePath = virtualPath.slice(mount.path.length) || "/";
|
|
164
|
+
return { mount, relativePath };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
/** Async resolve: tries sync resolve first, then calls onMiss if set. */
|
|
170
|
+
async resolveAsync(virtualPath, agent) {
|
|
171
|
+
const result = this.resolve(virtualPath);
|
|
172
|
+
if (result) return result;
|
|
173
|
+
if (this.onMiss) {
|
|
174
|
+
const added = await this.onMiss(virtualPath, agent);
|
|
175
|
+
if (added) {
|
|
176
|
+
return this.resolve(virtualPath);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
/** Check if a role has permission for an operation on a mount */
|
|
182
|
+
checkPermission(mount, op, roles) {
|
|
183
|
+
const allowed = mount.permissions?.[op];
|
|
184
|
+
if (!allowed) return null;
|
|
185
|
+
const hasRole = roles.some((r) => allowed.includes(r));
|
|
186
|
+
if (!hasRole) {
|
|
187
|
+
return {
|
|
188
|
+
code: "PERMISSION_DENIED",
|
|
189
|
+
message: `Requires one of [${allowed.join(", ")}] for ${op} on ${mount.path}`
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
/** List all registered mount paths (used by "ls /") */
|
|
195
|
+
listMounts() {
|
|
196
|
+
return this.mounts.map((m) => m.path);
|
|
197
|
+
}
|
|
198
|
+
/** Get the backend for a mount path */
|
|
199
|
+
getBackend(mountPath) {
|
|
200
|
+
const mount = this.mounts.find((m) => m.path === mountPath);
|
|
201
|
+
return mount?.backend;
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// src/backends/memory.ts
|
|
206
|
+
var NotFoundError = class extends Error {
|
|
207
|
+
constructor(path) {
|
|
208
|
+
super(`Not found: ${path}`);
|
|
209
|
+
this.name = "NotFoundError";
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// src/agent-fs.ts
|
|
214
|
+
var AgentFs = class {
|
|
215
|
+
resolver;
|
|
216
|
+
_listDomains;
|
|
217
|
+
_searchDomains;
|
|
218
|
+
_searchEndpoints;
|
|
219
|
+
constructor(options) {
|
|
220
|
+
this.resolver = new MountResolver();
|
|
221
|
+
for (const mount of options.mounts) {
|
|
222
|
+
this.resolver.add(mount);
|
|
223
|
+
}
|
|
224
|
+
if (options.onMiss) {
|
|
225
|
+
const addMount = (mount) => this.resolver.add(mount);
|
|
226
|
+
this.resolver.onMiss = (path, agent) => options.onMiss(path, addMount, agent);
|
|
227
|
+
}
|
|
228
|
+
this._listDomains = options.listDomains;
|
|
229
|
+
this._searchDomains = options.searchDomains;
|
|
230
|
+
this._searchEndpoints = options.searchEndpoints;
|
|
231
|
+
}
|
|
232
|
+
/** Execute a raw command string like "ls /db/users/" */
|
|
233
|
+
async execute(input, roles = ["agent"], agent) {
|
|
234
|
+
const parsed = parseCommand(input);
|
|
235
|
+
if (!parsed.ok) return parsed;
|
|
236
|
+
const cmd = parsed.data;
|
|
237
|
+
return this.executeCommand(cmd, roles, agent);
|
|
238
|
+
}
|
|
239
|
+
/** Execute a pre-parsed FsCommand */
|
|
240
|
+
async executeCommand(cmd, roles = ["agent"], agent) {
|
|
241
|
+
if (cmd.path === "/" && cmd.op === "ls") {
|
|
242
|
+
const staticEntries = this.resolver.listMounts().map((p) => p.slice(1) + "/");
|
|
243
|
+
if (this._listDomains) {
|
|
244
|
+
const dynamicDomains = await this._listDomains();
|
|
245
|
+
const dynamicEntries = dynamicDomains.map((d) => d + "/");
|
|
246
|
+
const merged = [.../* @__PURE__ */ new Set([...staticEntries, ...dynamicEntries])];
|
|
247
|
+
return { ok: true, data: merged };
|
|
248
|
+
}
|
|
249
|
+
return { ok: true, data: staticEntries };
|
|
250
|
+
}
|
|
251
|
+
if (cmd.path === "/" && cmd.op === "grep" && this._searchDomains) {
|
|
252
|
+
const results = await this._searchDomains(cmd.pattern);
|
|
253
|
+
return { ok: true, data: results };
|
|
254
|
+
}
|
|
255
|
+
if (cmd.op === "grep" && this._searchEndpoints) {
|
|
256
|
+
const segments = cmd.path.split("/").filter(Boolean);
|
|
257
|
+
if (segments.length === 1) {
|
|
258
|
+
const domain = segments[0];
|
|
259
|
+
const results = await this._searchEndpoints(domain, cmd.pattern);
|
|
260
|
+
return { ok: true, data: results };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const resolved = await this.resolver.resolveAsync(cmd.path, agent);
|
|
264
|
+
if (!resolved) {
|
|
265
|
+
return {
|
|
266
|
+
ok: false,
|
|
267
|
+
error: { code: "NO_MOUNT", message: `No mount for path: ${cmd.path}` }
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
const permType = cmd.op === "write" || cmd.op === "rm" ? "write" : "read";
|
|
271
|
+
const permError = this.resolver.checkPermission(
|
|
272
|
+
resolved.mount,
|
|
273
|
+
permType,
|
|
274
|
+
roles
|
|
275
|
+
);
|
|
276
|
+
if (permError) {
|
|
277
|
+
return { ok: false, error: permError };
|
|
278
|
+
}
|
|
279
|
+
const backend = resolved.mount.backend;
|
|
280
|
+
const relPath = resolved.relativePath;
|
|
281
|
+
try {
|
|
282
|
+
switch (cmd.op) {
|
|
283
|
+
case "ls": {
|
|
284
|
+
const entries = await backend.list(relPath);
|
|
285
|
+
return { ok: true, data: entries };
|
|
286
|
+
}
|
|
287
|
+
case "cat": {
|
|
288
|
+
const data = await backend.read(relPath);
|
|
289
|
+
return { ok: true, data };
|
|
290
|
+
}
|
|
291
|
+
case "write": {
|
|
292
|
+
const result = await backend.write(relPath, cmd.data);
|
|
293
|
+
return { ok: true, data: result };
|
|
294
|
+
}
|
|
295
|
+
case "rm": {
|
|
296
|
+
await backend.remove(relPath);
|
|
297
|
+
return { ok: true, data: { deleted: cmd.path } };
|
|
298
|
+
}
|
|
299
|
+
case "grep": {
|
|
300
|
+
const results = await backend.search(relPath, cmd.pattern);
|
|
301
|
+
return { ok: true, data: results };
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
} catch (err) {
|
|
305
|
+
if (err instanceof NotFoundError) {
|
|
306
|
+
return {
|
|
307
|
+
ok: false,
|
|
308
|
+
error: { code: "NOT_FOUND", message: err.message }
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
ok: false,
|
|
313
|
+
error: {
|
|
314
|
+
code: "BACKEND_ERROR",
|
|
315
|
+
message: err instanceof Error ? err.message : String(err)
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
// src/server.ts
|
|
323
|
+
var import_node_http = require("http");
|
|
324
|
+
function createAgentFsServer(options) {
|
|
325
|
+
const { agentFs, port = 3071 } = options;
|
|
326
|
+
const server = (0, import_node_http.createServer)(async (req, res) => {
|
|
327
|
+
try {
|
|
328
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
329
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
|
330
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
331
|
+
if (req.method === "OPTIONS") {
|
|
332
|
+
res.writeHead(204);
|
|
333
|
+
res.end();
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
337
|
+
if (url.pathname === "/execute" && req.method === "POST") {
|
|
338
|
+
const body = await readBody(req);
|
|
339
|
+
const { command, roles } = body;
|
|
340
|
+
if (!command || typeof command !== "string") {
|
|
341
|
+
sendJson(res, 400, { error: "Missing 'command' field" });
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const result = await agentFs.execute(command, roles);
|
|
345
|
+
const status = result.ok ? 200 : errorToStatus(result.error.code);
|
|
346
|
+
sendJson(res, status, result);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (url.pathname.startsWith("/fs")) {
|
|
350
|
+
const virtualPath = url.pathname.slice(3) || "/";
|
|
351
|
+
const query = url.searchParams.get("q");
|
|
352
|
+
let op;
|
|
353
|
+
let data;
|
|
354
|
+
let pattern;
|
|
355
|
+
switch (req.method) {
|
|
356
|
+
case "GET":
|
|
357
|
+
if (query) {
|
|
358
|
+
op = "grep";
|
|
359
|
+
pattern = query;
|
|
360
|
+
} else if (virtualPath.endsWith("/")) {
|
|
361
|
+
op = "ls";
|
|
362
|
+
} else {
|
|
363
|
+
op = "cat";
|
|
364
|
+
}
|
|
365
|
+
break;
|
|
366
|
+
case "POST":
|
|
367
|
+
case "PUT":
|
|
368
|
+
op = "write";
|
|
369
|
+
data = await readBody(req);
|
|
370
|
+
break;
|
|
371
|
+
case "DELETE":
|
|
372
|
+
op = "rm";
|
|
373
|
+
break;
|
|
374
|
+
default:
|
|
375
|
+
sendJson(res, 405, { error: "Method not allowed" });
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
const result = await agentFs.executeCommand(
|
|
379
|
+
{ op, path: virtualPath, data, pattern }
|
|
380
|
+
);
|
|
381
|
+
const status = result.ok ? 200 : errorToStatus(result.error.code);
|
|
382
|
+
sendJson(res, status, result);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
sendJson(res, 404, { error: "Not found. Use /fs/* or /execute" });
|
|
386
|
+
} catch (err) {
|
|
387
|
+
sendJson(res, 500, {
|
|
388
|
+
error: err instanceof Error ? err.message : "Internal server error"
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
return {
|
|
393
|
+
server,
|
|
394
|
+
listen: () => new Promise((resolve) => {
|
|
395
|
+
server.listen(port, () => resolve());
|
|
396
|
+
}),
|
|
397
|
+
close: () => new Promise((resolve, reject) => {
|
|
398
|
+
server.close((err) => err ? reject(err) : resolve());
|
|
399
|
+
}),
|
|
400
|
+
port
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
async function readBody(req) {
|
|
404
|
+
return new Promise((resolve, reject) => {
|
|
405
|
+
const chunks = [];
|
|
406
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
407
|
+
req.on("end", () => {
|
|
408
|
+
const raw = Buffer.concat(chunks).toString("utf-8");
|
|
409
|
+
if (!raw) {
|
|
410
|
+
resolve({});
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
try {
|
|
414
|
+
resolve(JSON.parse(raw));
|
|
415
|
+
} catch {
|
|
416
|
+
reject(new Error("Invalid JSON body"));
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
req.on("error", reject);
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
function sendJson(res, status, data) {
|
|
423
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
424
|
+
res.end(JSON.stringify(data));
|
|
425
|
+
}
|
|
426
|
+
function errorToStatus(code) {
|
|
427
|
+
switch (code) {
|
|
428
|
+
case "PARSE_ERROR":
|
|
429
|
+
case "INVALID_PATH":
|
|
430
|
+
return 400;
|
|
431
|
+
case "PERMISSION_DENIED":
|
|
432
|
+
return 403;
|
|
433
|
+
case "NOT_FOUND":
|
|
434
|
+
case "NO_MOUNT":
|
|
435
|
+
return 404;
|
|
436
|
+
default:
|
|
437
|
+
return 500;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// src/backends/http.ts
|
|
442
|
+
var HttpBackend = class {
|
|
443
|
+
baseUrl;
|
|
444
|
+
auth;
|
|
445
|
+
resourceList;
|
|
446
|
+
endpoints;
|
|
447
|
+
params;
|
|
448
|
+
_fetch;
|
|
449
|
+
bodyEncoding;
|
|
450
|
+
pagination;
|
|
451
|
+
retryConfig;
|
|
452
|
+
/** Cached OAuth2 access token */
|
|
453
|
+
_oauth2Token;
|
|
454
|
+
constructor(config) {
|
|
455
|
+
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
|
|
456
|
+
this.auth = config.auth;
|
|
457
|
+
this.resourceList = config.resources ?? [];
|
|
458
|
+
this.endpoints = new Map(
|
|
459
|
+
(config.endpoints ?? []).map((e) => [e.name, e])
|
|
460
|
+
);
|
|
461
|
+
this.params = config.params ?? {};
|
|
462
|
+
this._fetch = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
463
|
+
this.bodyEncoding = config.bodyEncoding ?? "json";
|
|
464
|
+
this.retryConfig = {
|
|
465
|
+
maxRetries: config.retry?.maxRetries ?? 3,
|
|
466
|
+
baseDelayMs: config.retry?.baseDelayMs ?? 1e3
|
|
467
|
+
};
|
|
468
|
+
if (config.pagination) {
|
|
469
|
+
this.pagination = {
|
|
470
|
+
...config.pagination,
|
|
471
|
+
maxPages: config.pagination.maxPages ?? 10
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
async list(path) {
|
|
476
|
+
const parsed = this.parsePath(path);
|
|
477
|
+
if (parsed.type === "root") {
|
|
478
|
+
const entries = [];
|
|
479
|
+
for (const r of this.resourceList) {
|
|
480
|
+
entries.push(r.name + "/");
|
|
481
|
+
}
|
|
482
|
+
if (this.endpoints.size > 0) {
|
|
483
|
+
entries.push("_api/");
|
|
484
|
+
}
|
|
485
|
+
return entries;
|
|
486
|
+
}
|
|
487
|
+
if (parsed.type === "api-list") {
|
|
488
|
+
return Array.from(this.endpoints.values()).map(
|
|
489
|
+
(e) => `${e.name} [${e.method}]`
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
if (parsed.type === "resource-list") {
|
|
493
|
+
const { resource, resolvedApiPath } = parsed;
|
|
494
|
+
const items = await this.fetchAllItems(resource, resolvedApiPath);
|
|
495
|
+
if (!Array.isArray(items)) return [];
|
|
496
|
+
return this.formatListItems(resource, items);
|
|
497
|
+
}
|
|
498
|
+
if (parsed.type === "passthrough") {
|
|
499
|
+
const resp = await this.request("GET", parsed.apiPath);
|
|
500
|
+
if (!resp.ok) return [];
|
|
501
|
+
const data = await safeJson(resp);
|
|
502
|
+
if (data === null) return [];
|
|
503
|
+
if (Array.isArray(data)) return data.map((item) => String(item.id ?? item.name ?? JSON.stringify(item)));
|
|
504
|
+
return Object.keys(data);
|
|
505
|
+
}
|
|
506
|
+
if (parsed.type === "resource-item") {
|
|
507
|
+
const { resource, resolvedApiPath, id } = parsed;
|
|
508
|
+
if (resource.pathMode === "tree") {
|
|
509
|
+
const resp = await this.request("GET", `${resolvedApiPath}/${id}`);
|
|
510
|
+
if (!resp.ok) return [];
|
|
511
|
+
const data = await safeJson(resp);
|
|
512
|
+
if (data === null) return [];
|
|
513
|
+
if (Array.isArray(data)) {
|
|
514
|
+
return this.formatListItems(resource, data);
|
|
515
|
+
}
|
|
516
|
+
return [];
|
|
517
|
+
}
|
|
518
|
+
if (resource.children && resource.children.length > 0) {
|
|
519
|
+
return resource.children.map((c) => c.name + "/");
|
|
520
|
+
}
|
|
521
|
+
return [];
|
|
522
|
+
}
|
|
523
|
+
return [];
|
|
524
|
+
}
|
|
525
|
+
async read(path) {
|
|
526
|
+
const parsed = this.parsePath(path);
|
|
527
|
+
if (parsed.type === "resource-item") {
|
|
528
|
+
const { resource, resolvedApiPath, id } = parsed;
|
|
529
|
+
if (id === "_schema") {
|
|
530
|
+
return {
|
|
531
|
+
resource: resource.name,
|
|
532
|
+
fields: resource.fields ?? []
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
if (id === "_count") {
|
|
536
|
+
const resp2 = await this.request("GET", resolvedApiPath);
|
|
537
|
+
const data = await safeJson(resp2) ?? {};
|
|
538
|
+
const items = extractList(data, resource.listKey);
|
|
539
|
+
return { count: Array.isArray(items) ? items.length : 0 };
|
|
540
|
+
}
|
|
541
|
+
const resp = await this.request("GET", `${resolvedApiPath}/${id}`);
|
|
542
|
+
if (!resp.ok) throw new NotFoundError(path);
|
|
543
|
+
let result = await safeJson(resp);
|
|
544
|
+
if (resource.transform?.read) result = resource.transform.read(result);
|
|
545
|
+
return result;
|
|
546
|
+
}
|
|
547
|
+
if (parsed.type === "resource-list") {
|
|
548
|
+
const { resource, resolvedApiPath } = parsed;
|
|
549
|
+
const resp = await this.request("GET", resolvedApiPath);
|
|
550
|
+
const data = await safeJson(resp);
|
|
551
|
+
return data === null ? [] : extractList(data, resource.listKey) ?? data;
|
|
552
|
+
}
|
|
553
|
+
if (parsed.type === "api-call") {
|
|
554
|
+
const endpoint = this.getEndpoint(parsed.endpoint);
|
|
555
|
+
const resp = await this.request(endpoint.method, endpoint.apiPath);
|
|
556
|
+
return safeJson(resp);
|
|
557
|
+
}
|
|
558
|
+
if (parsed.type === "passthrough") {
|
|
559
|
+
const resp = await this.request("GET", parsed.apiPath);
|
|
560
|
+
if (!resp.ok) throw new NotFoundError(path);
|
|
561
|
+
return safeJson(resp);
|
|
562
|
+
}
|
|
563
|
+
throw new NotFoundError(path);
|
|
564
|
+
}
|
|
565
|
+
async write(path, data) {
|
|
566
|
+
const parsed = this.parsePath(path);
|
|
567
|
+
if (parsed.type === "root" && this.resourceList.length === 0 && this.endpoints.size === 0) {
|
|
568
|
+
const resp = await this.request("POST", "/", data);
|
|
569
|
+
const result = await safeJson(resp) ?? {};
|
|
570
|
+
return { id: String(result.id ?? "ok") };
|
|
571
|
+
}
|
|
572
|
+
if (parsed.type === "resource-item" && parsed.id) {
|
|
573
|
+
const { resource, resolvedApiPath, id } = parsed;
|
|
574
|
+
let writeData = data;
|
|
575
|
+
if (resource.readBeforeWrite) {
|
|
576
|
+
try {
|
|
577
|
+
const readResp = await this.request(
|
|
578
|
+
"GET",
|
|
579
|
+
`${resolvedApiPath}/${id}`
|
|
580
|
+
);
|
|
581
|
+
if (readResp.ok) {
|
|
582
|
+
const readResult = await safeJson(readResp);
|
|
583
|
+
if (readResult) writeData = resource.readBeforeWrite.inject(readResult, writeData);
|
|
584
|
+
}
|
|
585
|
+
} catch {
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
if (resource.transform?.write) writeData = resource.transform.write(writeData);
|
|
589
|
+
const method = resource.updateMethod ?? "PUT";
|
|
590
|
+
const resp = await this.request(
|
|
591
|
+
method,
|
|
592
|
+
`${resolvedApiPath}/${id}`,
|
|
593
|
+
writeData
|
|
594
|
+
);
|
|
595
|
+
if (!resp.ok) throw new NotFoundError(path);
|
|
596
|
+
const result = await safeJson(resp) ?? {};
|
|
597
|
+
const idField = resource.idField ?? "id";
|
|
598
|
+
return { id: String(result[idField] ?? id) };
|
|
599
|
+
}
|
|
600
|
+
if (parsed.type === "resource-list") {
|
|
601
|
+
const { resource, resolvedApiPath } = parsed;
|
|
602
|
+
let writeData = data;
|
|
603
|
+
if (resource.transform?.write) writeData = resource.transform.write(writeData);
|
|
604
|
+
const resp = await this.request("POST", resolvedApiPath, writeData);
|
|
605
|
+
const result = await safeJson(resp) ?? {};
|
|
606
|
+
const idField = resource.idField ?? "id";
|
|
607
|
+
return { id: String(result[idField] ?? "unknown") };
|
|
608
|
+
}
|
|
609
|
+
if (parsed.type === "api-call") {
|
|
610
|
+
const endpoint = this.getEndpoint(parsed.endpoint);
|
|
611
|
+
const resp = await this.request(endpoint.method, endpoint.apiPath, data);
|
|
612
|
+
const result = await safeJson(resp) ?? {};
|
|
613
|
+
return { id: result.id ?? "ok" };
|
|
614
|
+
}
|
|
615
|
+
if (parsed.type === "passthrough") {
|
|
616
|
+
const method = parsed.apiPath === "/" ? "POST" : "PUT";
|
|
617
|
+
const resp = await this.request(method, parsed.apiPath, data);
|
|
618
|
+
const result = await safeJson(resp) ?? {};
|
|
619
|
+
return { id: String(result.id ?? "ok") };
|
|
620
|
+
}
|
|
621
|
+
throw new Error(`Cannot write to path: ${path}`);
|
|
622
|
+
}
|
|
623
|
+
async remove(path) {
|
|
624
|
+
const parsed = this.parsePath(path);
|
|
625
|
+
if (parsed.type === "resource-item" && parsed.id) {
|
|
626
|
+
const { resource, resolvedApiPath, id } = parsed;
|
|
627
|
+
let deleteBody;
|
|
628
|
+
if (resource.readBeforeWrite) {
|
|
629
|
+
const readResp = await this.request(
|
|
630
|
+
"GET",
|
|
631
|
+
`${resolvedApiPath}/${id}`
|
|
632
|
+
);
|
|
633
|
+
if (!readResp.ok) throw new NotFoundError(path);
|
|
634
|
+
const readResult = await safeJson(readResp);
|
|
635
|
+
if (resource.transform?.remove) {
|
|
636
|
+
deleteBody = resource.transform.remove(readResult);
|
|
637
|
+
}
|
|
638
|
+
} else if (resource.transform?.remove) {
|
|
639
|
+
deleteBody = resource.transform.remove(null);
|
|
640
|
+
}
|
|
641
|
+
const resp = await this.request(
|
|
642
|
+
"DELETE",
|
|
643
|
+
`${resolvedApiPath}/${id}`,
|
|
644
|
+
deleteBody
|
|
645
|
+
);
|
|
646
|
+
if (!resp.ok) throw new NotFoundError(path);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
if (parsed.type === "passthrough") {
|
|
650
|
+
const resp = await this.request("DELETE", parsed.apiPath);
|
|
651
|
+
if (!resp.ok) throw new NotFoundError(path);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
throw new Error(`Cannot remove path: ${path}`);
|
|
655
|
+
}
|
|
656
|
+
async search(path, pattern) {
|
|
657
|
+
const parsed = this.parsePath(path);
|
|
658
|
+
if (parsed.type === "resource-list" || parsed.type === "resource-item") {
|
|
659
|
+
const { resource, resolvedApiPath } = parsed;
|
|
660
|
+
const resp = await this.request(
|
|
661
|
+
"GET",
|
|
662
|
+
`${resolvedApiPath}?q=${encodeURIComponent(pattern)}`
|
|
663
|
+
);
|
|
664
|
+
const data = await safeJson(resp) ?? {};
|
|
665
|
+
const items = extractList(data, resource.listKey);
|
|
666
|
+
if (Array.isArray(items)) return items;
|
|
667
|
+
const allResp = await this.request("GET", resolvedApiPath);
|
|
668
|
+
const allData = await safeJson(allResp) ?? {};
|
|
669
|
+
const allItems = extractList(allData, resource.listKey);
|
|
670
|
+
if (!Array.isArray(allItems)) return [];
|
|
671
|
+
return allItems.filter(
|
|
672
|
+
(item) => JSON.stringify(item).includes(pattern)
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
if (parsed.type === "passthrough") {
|
|
676
|
+
const resp = await this.request("GET", `${parsed.apiPath}?q=${encodeURIComponent(pattern)}`);
|
|
677
|
+
const data = await safeJson(resp);
|
|
678
|
+
return Array.isArray(data) ? data : [];
|
|
679
|
+
}
|
|
680
|
+
return [];
|
|
681
|
+
}
|
|
682
|
+
// --- Internal helpers ---
|
|
683
|
+
async request(method, apiPath, body, absoluteUrl) {
|
|
684
|
+
const url = absoluteUrl ? apiPath : `${this.baseUrl}${apiPath}`;
|
|
685
|
+
const useForm = this.bodyEncoding === "form";
|
|
686
|
+
const headers = {
|
|
687
|
+
"Content-Type": useForm ? "application/x-www-form-urlencoded" : "application/json",
|
|
688
|
+
Accept: "application/json",
|
|
689
|
+
"User-Agent": "nkmc-gateway/1.0"
|
|
690
|
+
};
|
|
691
|
+
if (this.auth) {
|
|
692
|
+
switch (this.auth.type) {
|
|
693
|
+
case "bearer": {
|
|
694
|
+
const prefix = this.auth.prefix ?? "Bearer";
|
|
695
|
+
headers["Authorization"] = `${prefix} ${this.auth.token}`;
|
|
696
|
+
break;
|
|
697
|
+
}
|
|
698
|
+
case "api-key":
|
|
699
|
+
headers[this.auth.header] = this.auth.key;
|
|
700
|
+
break;
|
|
701
|
+
case "basic":
|
|
702
|
+
headers["Authorization"] = `Basic ${btoa(`${this.auth.username}:${this.auth.password}`)}`;
|
|
703
|
+
break;
|
|
704
|
+
case "oauth2": {
|
|
705
|
+
const token = await this.getOAuth2Token();
|
|
706
|
+
headers["Authorization"] = `Bearer ${token}`;
|
|
707
|
+
break;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
let encodedBody;
|
|
712
|
+
if (body !== void 0) {
|
|
713
|
+
encodedBody = useForm ? encodeFormBody(body) : JSON.stringify(body);
|
|
714
|
+
}
|
|
715
|
+
let lastResp;
|
|
716
|
+
for (let attempt = 0; attempt <= this.retryConfig.maxRetries; attempt++) {
|
|
717
|
+
lastResp = await this._fetch(url, {
|
|
718
|
+
method,
|
|
719
|
+
headers,
|
|
720
|
+
body: encodedBody
|
|
721
|
+
});
|
|
722
|
+
if (lastResp.ok || lastResp.status >= 400 && lastResp.status < 500 && lastResp.status !== 429) {
|
|
723
|
+
return lastResp;
|
|
724
|
+
}
|
|
725
|
+
if (attempt === this.retryConfig.maxRetries) break;
|
|
726
|
+
let delayMs;
|
|
727
|
+
const retryAfter = lastResp.headers.get("retry-after");
|
|
728
|
+
if (retryAfter) {
|
|
729
|
+
const seconds = Number(retryAfter);
|
|
730
|
+
delayMs = isNaN(seconds) ? this.retryConfig.baseDelayMs : seconds * 1e3;
|
|
731
|
+
} else {
|
|
732
|
+
delayMs = this.retryConfig.baseDelayMs * Math.pow(2, attempt) * (0.5 + Math.random() * 0.5);
|
|
733
|
+
}
|
|
734
|
+
await sleep(delayMs);
|
|
735
|
+
}
|
|
736
|
+
return lastResp;
|
|
737
|
+
}
|
|
738
|
+
/** Obtain (or refresh) OAuth2 access token via client_credentials grant */
|
|
739
|
+
async getOAuth2Token() {
|
|
740
|
+
if (this.auth?.type !== "oauth2") throw new Error("Not OAuth2 auth");
|
|
741
|
+
if (this._oauth2Token && Date.now() < this._oauth2Token.expiresAt - 3e4) {
|
|
742
|
+
return this._oauth2Token.token;
|
|
743
|
+
}
|
|
744
|
+
const { tokenUrl, clientId, clientSecret, scope } = this.auth;
|
|
745
|
+
const params = new URLSearchParams({ grant_type: "client_credentials" });
|
|
746
|
+
if (scope) params.set("scope", scope);
|
|
747
|
+
const resp = await this._fetch(tokenUrl, {
|
|
748
|
+
method: "POST",
|
|
749
|
+
headers: {
|
|
750
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
751
|
+
Authorization: `Basic ${btoa(`${clientId}:${clientSecret}`)}`
|
|
752
|
+
},
|
|
753
|
+
body: params.toString()
|
|
754
|
+
});
|
|
755
|
+
if (!resp.ok) {
|
|
756
|
+
throw new Error(`OAuth2 token request failed: ${resp.status} ${resp.statusText}`);
|
|
757
|
+
}
|
|
758
|
+
const data = await resp.json();
|
|
759
|
+
const expiresIn = data.expires_in ?? 3600;
|
|
760
|
+
this._oauth2Token = {
|
|
761
|
+
token: data.access_token,
|
|
762
|
+
expiresAt: Date.now() + expiresIn * 1e3
|
|
763
|
+
};
|
|
764
|
+
return this._oauth2Token.token;
|
|
765
|
+
}
|
|
766
|
+
/** Parse Link header to extract rel="next" URL */
|
|
767
|
+
getNextPageUrl(resp) {
|
|
768
|
+
const link = resp.headers.get("link");
|
|
769
|
+
if (!link) return null;
|
|
770
|
+
const match = link.match(/<([^>]+)>;\s*rel="next"/);
|
|
771
|
+
return match ? match[1] : null;
|
|
772
|
+
}
|
|
773
|
+
/** Fetch all items with optional pagination */
|
|
774
|
+
async fetchAllItems(resource, apiPath) {
|
|
775
|
+
const resp = await this.request("GET", apiPath);
|
|
776
|
+
const data = await safeJson(resp);
|
|
777
|
+
if (data === null) return [];
|
|
778
|
+
let items = extractList(data, resource.listKey);
|
|
779
|
+
if (!Array.isArray(items)) return [];
|
|
780
|
+
if (this.pagination) {
|
|
781
|
+
const maxPages = this.pagination.maxPages;
|
|
782
|
+
let pages = 1;
|
|
783
|
+
if (this.pagination.type === "link-header") {
|
|
784
|
+
let nextUrl = this.getNextPageUrl(resp);
|
|
785
|
+
while (nextUrl && pages < maxPages) {
|
|
786
|
+
const nextResp = await this.request("GET", nextUrl, void 0, true);
|
|
787
|
+
const nextData = await safeJson(nextResp);
|
|
788
|
+
if (!nextData) break;
|
|
789
|
+
const nextItems = extractList(nextData, resource.listKey);
|
|
790
|
+
if (!Array.isArray(nextItems) || nextItems.length === 0) break;
|
|
791
|
+
items = items.concat(nextItems);
|
|
792
|
+
nextUrl = this.getNextPageUrl(nextResp);
|
|
793
|
+
pages++;
|
|
794
|
+
}
|
|
795
|
+
} else if (this.pagination.type === "cursor") {
|
|
796
|
+
const { cursorParam, cursorPath } = this.pagination;
|
|
797
|
+
let cursor = getNestedValue(data, cursorPath);
|
|
798
|
+
while (cursor && pages < maxPages) {
|
|
799
|
+
const sep = apiPath.includes("?") ? "&" : "?";
|
|
800
|
+
const nextResp = await this.request("GET", `${apiPath}${sep}${cursorParam}=${encodeURIComponent(String(cursor))}`);
|
|
801
|
+
const nextData = await safeJson(nextResp);
|
|
802
|
+
if (!nextData) break;
|
|
803
|
+
const nextItems = extractList(nextData, resource.listKey);
|
|
804
|
+
if (!Array.isArray(nextItems) || nextItems.length === 0) break;
|
|
805
|
+
items = items.concat(nextItems);
|
|
806
|
+
cursor = getNestedValue(nextData, cursorPath);
|
|
807
|
+
pages++;
|
|
808
|
+
}
|
|
809
|
+
} else if (this.pagination.type === "offset") {
|
|
810
|
+
const { offsetParam = "offset", limitParam = "limit", pageSize = 100 } = this.pagination;
|
|
811
|
+
let offset = items.length;
|
|
812
|
+
while (pages < maxPages) {
|
|
813
|
+
const sep = apiPath.includes("?") ? "&" : "?";
|
|
814
|
+
const nextResp = await this.request("GET", `${apiPath}${sep}${offsetParam}=${offset}&${limitParam}=${pageSize}`);
|
|
815
|
+
const nextData = await safeJson(nextResp);
|
|
816
|
+
if (!nextData) break;
|
|
817
|
+
const nextItems = extractList(nextData, resource.listKey);
|
|
818
|
+
if (!Array.isArray(nextItems) || nextItems.length === 0) break;
|
|
819
|
+
items = items.concat(nextItems);
|
|
820
|
+
offset += nextItems.length;
|
|
821
|
+
pages++;
|
|
822
|
+
}
|
|
823
|
+
} else if (this.pagination.type === "page") {
|
|
824
|
+
const { pageParam = "page" } = this.pagination;
|
|
825
|
+
let page = 2;
|
|
826
|
+
while (pages < maxPages) {
|
|
827
|
+
const sep = apiPath.includes("?") ? "&" : "?";
|
|
828
|
+
const nextResp = await this.request("GET", `${apiPath}${sep}${pageParam}=${page}`);
|
|
829
|
+
const nextData = await safeJson(nextResp);
|
|
830
|
+
if (!nextData) break;
|
|
831
|
+
const nextItems = extractList(nextData, resource.listKey);
|
|
832
|
+
if (!Array.isArray(nextItems) || nextItems.length === 0) break;
|
|
833
|
+
items = items.concat(nextItems);
|
|
834
|
+
page++;
|
|
835
|
+
pages++;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
return items;
|
|
840
|
+
}
|
|
841
|
+
/** Format list items using transform.list or default idField + ".json" */
|
|
842
|
+
formatListItems(resource, items) {
|
|
843
|
+
if (resource.transform?.list) {
|
|
844
|
+
return items.map((item) => resource.transform.list(item));
|
|
845
|
+
}
|
|
846
|
+
const idField = resource.idField ?? "id";
|
|
847
|
+
return items.map(
|
|
848
|
+
(item) => String(item[idField]) + ".json"
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
getEndpoint(name) {
|
|
852
|
+
const endpoint = this.endpoints.get(name);
|
|
853
|
+
if (!endpoint) throw new NotFoundError(`Endpoint not found: ${name}`);
|
|
854
|
+
return endpoint;
|
|
855
|
+
}
|
|
856
|
+
/** Replace :param and {param} placeholders in API paths with values from config.params */
|
|
857
|
+
resolveTemplate(path) {
|
|
858
|
+
return path.replace(/:(\w+)/g, (_, key) => {
|
|
859
|
+
const value = this.params[key];
|
|
860
|
+
if (value === void 0) throw new Error(`Missing param: ${key}`);
|
|
861
|
+
return value;
|
|
862
|
+
}).replace(/\{(\w+)\}/g, (_, key) => {
|
|
863
|
+
const value = this.params[key];
|
|
864
|
+
if (value === void 0) throw new Error(`Missing param: ${key}`);
|
|
865
|
+
return value;
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
/** Recursively resolve a filesystem path against the resource tree */
|
|
869
|
+
resolveResourcePath(parts, pos, resources, parentApiPath) {
|
|
870
|
+
if (pos >= parts.length) return null;
|
|
871
|
+
const resourceName = parts[pos];
|
|
872
|
+
const resource = resources.find((r) => r.name === resourceName);
|
|
873
|
+
if (!resource) return null;
|
|
874
|
+
const rawSegment = resource.apiPath ?? `/${resource.name}`;
|
|
875
|
+
const resolvedSegment = this.resolveTemplate(rawSegment);
|
|
876
|
+
const baseApiPath = parentApiPath + resolvedSegment;
|
|
877
|
+
const remaining = parts.length - pos - 1;
|
|
878
|
+
if (resource.pathMode === "tree") {
|
|
879
|
+
if (remaining === 0) {
|
|
880
|
+
return { type: "resource-list", resource, resolvedApiPath: baseApiPath };
|
|
881
|
+
}
|
|
882
|
+
const id2 = parts.slice(pos + 1).join("/");
|
|
883
|
+
return { type: "resource-item", resource, resolvedApiPath: baseApiPath, id: id2 };
|
|
884
|
+
}
|
|
885
|
+
if (remaining === 0) {
|
|
886
|
+
return { type: "resource-list", resource, resolvedApiPath: baseApiPath };
|
|
887
|
+
}
|
|
888
|
+
const rawId = parts[pos + 1];
|
|
889
|
+
if (remaining >= 2) {
|
|
890
|
+
if (resource.children) {
|
|
891
|
+
const childResult = this.resolveResourcePath(
|
|
892
|
+
parts,
|
|
893
|
+
pos + 2,
|
|
894
|
+
resource.children,
|
|
895
|
+
baseApiPath + "/" + rawId
|
|
896
|
+
);
|
|
897
|
+
if (childResult) return childResult;
|
|
898
|
+
}
|
|
899
|
+
return null;
|
|
900
|
+
}
|
|
901
|
+
let id = rawId;
|
|
902
|
+
if (id.endsWith(".json")) id = id.slice(0, -5);
|
|
903
|
+
return { type: "resource-item", resource, resolvedApiPath: baseApiPath, id };
|
|
904
|
+
}
|
|
905
|
+
parsePath(path) {
|
|
906
|
+
const cleaned = path.replace(/^\/+/, "").replace(/\/+$/, "");
|
|
907
|
+
if (!cleaned) return { type: "root" };
|
|
908
|
+
const parts = cleaned.split("/");
|
|
909
|
+
if (parts[0] === "_api") {
|
|
910
|
+
if (parts.length === 1) return { type: "api-list" };
|
|
911
|
+
return { type: "api-call", endpoint: parts[1] };
|
|
912
|
+
}
|
|
913
|
+
const result = this.resolveResourcePath(
|
|
914
|
+
parts,
|
|
915
|
+
0,
|
|
916
|
+
this.resourceList,
|
|
917
|
+
""
|
|
918
|
+
);
|
|
919
|
+
if (!result) {
|
|
920
|
+
return { type: "passthrough", apiPath: "/" + cleaned };
|
|
921
|
+
}
|
|
922
|
+
return result;
|
|
923
|
+
}
|
|
924
|
+
};
|
|
925
|
+
async function safeJson(resp) {
|
|
926
|
+
if (resp.status === 204) return null;
|
|
927
|
+
const text = await resp.text();
|
|
928
|
+
if (!text || !text.trim()) return null;
|
|
929
|
+
try {
|
|
930
|
+
return JSON.parse(text);
|
|
931
|
+
} catch {
|
|
932
|
+
return null;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
function extractList(data, listKey) {
|
|
936
|
+
if (Array.isArray(data)) return data;
|
|
937
|
+
if (typeof data !== "object" || data === null) return data;
|
|
938
|
+
if (listKey) return data[listKey];
|
|
939
|
+
let firstEmpty = null;
|
|
940
|
+
for (const value of Object.values(data)) {
|
|
941
|
+
if (Array.isArray(value)) {
|
|
942
|
+
if (value.length > 0) return value;
|
|
943
|
+
if (!firstEmpty) firstEmpty = value;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
if (firstEmpty) return firstEmpty;
|
|
947
|
+
return data;
|
|
948
|
+
}
|
|
949
|
+
function getNestedValue(obj, path) {
|
|
950
|
+
let current = obj;
|
|
951
|
+
for (const key of path.split(".")) {
|
|
952
|
+
if (current === null || current === void 0 || typeof current !== "object") return void 0;
|
|
953
|
+
current = current[key];
|
|
954
|
+
}
|
|
955
|
+
return current;
|
|
956
|
+
}
|
|
957
|
+
function sleep(ms) {
|
|
958
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
959
|
+
}
|
|
960
|
+
function encodeFormBody(data, prefix) {
|
|
961
|
+
if (data === null || data === void 0) return "";
|
|
962
|
+
if (typeof data !== "object") {
|
|
963
|
+
return prefix ? `${encodeURIComponent(prefix)}=${encodeURIComponent(String(data))}` : "";
|
|
964
|
+
}
|
|
965
|
+
const parts = [];
|
|
966
|
+
const obj = data;
|
|
967
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
968
|
+
const fullKey = prefix ? `${prefix}[${key}]` : key;
|
|
969
|
+
if (Array.isArray(value)) {
|
|
970
|
+
for (const item of value) {
|
|
971
|
+
parts.push(`${encodeURIComponent(`${fullKey}[]`)}=${encodeURIComponent(String(item))}`);
|
|
972
|
+
}
|
|
973
|
+
} else if (value !== null && typeof value === "object") {
|
|
974
|
+
const nested = encodeFormBody(value, fullKey);
|
|
975
|
+
if (nested) parts.push(nested);
|
|
976
|
+
} else if (value !== void 0) {
|
|
977
|
+
parts.push(`${encodeURIComponent(fullKey)}=${encodeURIComponent(String(value))}`);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
return parts.join("&");
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// src/backends/rpc.ts
|
|
984
|
+
var RpcError = class extends Error {
|
|
985
|
+
code;
|
|
986
|
+
data;
|
|
987
|
+
constructor(code, message, data) {
|
|
988
|
+
super(message);
|
|
989
|
+
this.name = "RpcError";
|
|
990
|
+
this.code = code;
|
|
991
|
+
this.data = data;
|
|
992
|
+
}
|
|
993
|
+
};
|
|
994
|
+
function sleep2(ms) {
|
|
995
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
996
|
+
}
|
|
997
|
+
async function safeRpcJson(resp) {
|
|
998
|
+
const text = await resp.text();
|
|
999
|
+
if (!text || !text.trim()) {
|
|
1000
|
+
throw new RpcError(-32700, "Parse error: empty response body");
|
|
1001
|
+
}
|
|
1002
|
+
try {
|
|
1003
|
+
return JSON.parse(text);
|
|
1004
|
+
} catch {
|
|
1005
|
+
throw new RpcError(-32700, `Parse error: invalid JSON response`);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
var NON_RETRYABLE_RPC_CODES = /* @__PURE__ */ new Set([-32700, -32600, -32601, -32602]);
|
|
1009
|
+
function isRetryableRpcError(code) {
|
|
1010
|
+
if (NON_RETRYABLE_RPC_CODES.has(code)) return false;
|
|
1011
|
+
return code === -32603 || code >= -32099 && code <= -32e3;
|
|
1012
|
+
}
|
|
1013
|
+
function isRetryableHttpStatus(status) {
|
|
1014
|
+
return status === 429 || status >= 500;
|
|
1015
|
+
}
|
|
1016
|
+
var JsonRpcTransport = class {
|
|
1017
|
+
url;
|
|
1018
|
+
headers;
|
|
1019
|
+
_fetch;
|
|
1020
|
+
nextId = 1;
|
|
1021
|
+
retryConfig;
|
|
1022
|
+
constructor(config) {
|
|
1023
|
+
this.url = config.url;
|
|
1024
|
+
this.headers = config.headers ?? {};
|
|
1025
|
+
this._fetch = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
1026
|
+
this.retryConfig = {
|
|
1027
|
+
maxRetries: config.retry?.maxRetries ?? 3,
|
|
1028
|
+
baseDelayMs: config.retry?.baseDelayMs ?? 1e3
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
async call(method, params) {
|
|
1032
|
+
const id = this.nextId++;
|
|
1033
|
+
const body = JSON.stringify({ jsonrpc: "2.0", id, method, params });
|
|
1034
|
+
const headers = { "Content-Type": "application/json", ...this.headers };
|
|
1035
|
+
let lastError;
|
|
1036
|
+
for (let attempt = 0; attempt <= this.retryConfig.maxRetries; attempt++) {
|
|
1037
|
+
let resp;
|
|
1038
|
+
try {
|
|
1039
|
+
resp = await this._fetch(this.url, { method: "POST", headers, body });
|
|
1040
|
+
} catch (err) {
|
|
1041
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
1042
|
+
if (attempt < this.retryConfig.maxRetries) {
|
|
1043
|
+
await this.backoff(attempt);
|
|
1044
|
+
continue;
|
|
1045
|
+
}
|
|
1046
|
+
throw lastError;
|
|
1047
|
+
}
|
|
1048
|
+
if (isRetryableHttpStatus(resp.status)) {
|
|
1049
|
+
lastError = new RpcError(-32e3, `HTTP ${resp.status}`);
|
|
1050
|
+
if (attempt < this.retryConfig.maxRetries) {
|
|
1051
|
+
await this.backoff(attempt, resp);
|
|
1052
|
+
continue;
|
|
1053
|
+
}
|
|
1054
|
+
throw lastError;
|
|
1055
|
+
}
|
|
1056
|
+
const result = await safeRpcJson(resp);
|
|
1057
|
+
if (result.error) {
|
|
1058
|
+
const { code, message, data } = result.error;
|
|
1059
|
+
if (isRetryableRpcError(code) && attempt < this.retryConfig.maxRetries) {
|
|
1060
|
+
lastError = new RpcError(code, message, data);
|
|
1061
|
+
await this.backoff(attempt);
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1064
|
+
throw new RpcError(code, message, data);
|
|
1065
|
+
}
|
|
1066
|
+
return result.result;
|
|
1067
|
+
}
|
|
1068
|
+
throw lastError ?? new RpcError(-32e3, "Max retries exhausted");
|
|
1069
|
+
}
|
|
1070
|
+
async batch(calls) {
|
|
1071
|
+
const requests = calls.map((c) => ({
|
|
1072
|
+
jsonrpc: "2.0",
|
|
1073
|
+
id: this.nextId++,
|
|
1074
|
+
method: c.method,
|
|
1075
|
+
params: c.params
|
|
1076
|
+
}));
|
|
1077
|
+
const body = JSON.stringify(requests);
|
|
1078
|
+
const headers = { "Content-Type": "application/json", ...this.headers };
|
|
1079
|
+
let lastError;
|
|
1080
|
+
for (let attempt = 0; attempt <= this.retryConfig.maxRetries; attempt++) {
|
|
1081
|
+
let resp;
|
|
1082
|
+
try {
|
|
1083
|
+
resp = await this._fetch(this.url, { method: "POST", headers, body });
|
|
1084
|
+
} catch (err) {
|
|
1085
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
1086
|
+
if (attempt < this.retryConfig.maxRetries) {
|
|
1087
|
+
await this.backoff(attempt);
|
|
1088
|
+
continue;
|
|
1089
|
+
}
|
|
1090
|
+
throw lastError;
|
|
1091
|
+
}
|
|
1092
|
+
if (isRetryableHttpStatus(resp.status)) {
|
|
1093
|
+
lastError = new RpcError(-32e3, `HTTP ${resp.status}`);
|
|
1094
|
+
if (attempt < this.retryConfig.maxRetries) {
|
|
1095
|
+
await this.backoff(attempt, resp);
|
|
1096
|
+
continue;
|
|
1097
|
+
}
|
|
1098
|
+
throw lastError;
|
|
1099
|
+
}
|
|
1100
|
+
const results = await safeRpcJson(resp);
|
|
1101
|
+
const sorted = results.sort((a, b) => a.id - b.id);
|
|
1102
|
+
const hasRetryableError = sorted.some(
|
|
1103
|
+
(r) => r.error && isRetryableRpcError(r.error.code)
|
|
1104
|
+
);
|
|
1105
|
+
if (hasRetryableError && attempt < this.retryConfig.maxRetries) {
|
|
1106
|
+
const firstErr = sorted.find((r) => r.error).error;
|
|
1107
|
+
lastError = new RpcError(firstErr.code, firstErr.message, firstErr.data);
|
|
1108
|
+
await this.backoff(attempt);
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1111
|
+
return sorted.map((r) => {
|
|
1112
|
+
if (r.error) {
|
|
1113
|
+
throw new RpcError(r.error.code, r.error.message, r.error.data);
|
|
1114
|
+
}
|
|
1115
|
+
return r.result;
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
throw lastError ?? new RpcError(-32e3, "Max retries exhausted");
|
|
1119
|
+
}
|
|
1120
|
+
/** Exponential backoff with jitter + Retry-After header support */
|
|
1121
|
+
async backoff(attempt, resp) {
|
|
1122
|
+
let delayMs;
|
|
1123
|
+
const retryAfter = resp?.headers.get("retry-after");
|
|
1124
|
+
if (retryAfter) {
|
|
1125
|
+
const seconds = Number(retryAfter);
|
|
1126
|
+
delayMs = isNaN(seconds) ? this.retryConfig.baseDelayMs : seconds * 1e3;
|
|
1127
|
+
} else {
|
|
1128
|
+
delayMs = this.retryConfig.baseDelayMs * Math.pow(2, attempt) * (0.5 + Math.random() * 0.5);
|
|
1129
|
+
}
|
|
1130
|
+
await sleep2(delayMs);
|
|
1131
|
+
}
|
|
1132
|
+
};
|
|
1133
|
+
var RpcBackend = class {
|
|
1134
|
+
transport;
|
|
1135
|
+
resources;
|
|
1136
|
+
constructor(config) {
|
|
1137
|
+
this.transport = config.transport;
|
|
1138
|
+
this.resources = config.resources;
|
|
1139
|
+
}
|
|
1140
|
+
async list(path) {
|
|
1141
|
+
const parsed = this.parsePath(path);
|
|
1142
|
+
if (parsed.type === "root") {
|
|
1143
|
+
return this.resources.filter((r) => r.methods.list || r.methods.read).map((r) => r.name + "/");
|
|
1144
|
+
}
|
|
1145
|
+
if (parsed.type === "resource-list") {
|
|
1146
|
+
const { resource } = parsed;
|
|
1147
|
+
if (!resource.methods.list) return [];
|
|
1148
|
+
const { method, params } = resource.methods.list;
|
|
1149
|
+
const result = await this.transport.call(method, params({}));
|
|
1150
|
+
if (resource.transform?.list) {
|
|
1151
|
+
const transformed = resource.transform.list(result);
|
|
1152
|
+
return Array.isArray(transformed) ? transformed : [transformed];
|
|
1153
|
+
}
|
|
1154
|
+
if (Array.isArray(result)) {
|
|
1155
|
+
const idField = resource.idField ?? "id";
|
|
1156
|
+
return result.map(
|
|
1157
|
+
(item) => String(item[idField]) + ".json"
|
|
1158
|
+
);
|
|
1159
|
+
}
|
|
1160
|
+
return [];
|
|
1161
|
+
}
|
|
1162
|
+
return [];
|
|
1163
|
+
}
|
|
1164
|
+
async read(path) {
|
|
1165
|
+
const parsed = this.parsePath(path);
|
|
1166
|
+
if (parsed.type === "resource-item") {
|
|
1167
|
+
const { resource, id } = parsed;
|
|
1168
|
+
if (!resource.methods.read) {
|
|
1169
|
+
throw new NotFoundError(path);
|
|
1170
|
+
}
|
|
1171
|
+
const { method, params } = resource.methods.read;
|
|
1172
|
+
const result = await this.transport.call(method, params({ id }));
|
|
1173
|
+
if (result === null || result === void 0) {
|
|
1174
|
+
throw new NotFoundError(path);
|
|
1175
|
+
}
|
|
1176
|
+
return resource.transform?.read ? resource.transform.read(result) : result;
|
|
1177
|
+
}
|
|
1178
|
+
if (parsed.type === "resource-list") {
|
|
1179
|
+
const { resource } = parsed;
|
|
1180
|
+
if (!resource.methods.list) {
|
|
1181
|
+
throw new NotFoundError(path);
|
|
1182
|
+
}
|
|
1183
|
+
const { method, params } = resource.methods.list;
|
|
1184
|
+
return this.transport.call(method, params({}));
|
|
1185
|
+
}
|
|
1186
|
+
throw new NotFoundError(path);
|
|
1187
|
+
}
|
|
1188
|
+
async write(path, data) {
|
|
1189
|
+
const parsed = this.parsePath(path);
|
|
1190
|
+
if (parsed.type === "resource-item") {
|
|
1191
|
+
const { resource, id } = parsed;
|
|
1192
|
+
const rpcMethod = resource.methods.write ?? resource.methods.create;
|
|
1193
|
+
if (!rpcMethod) throw new Error(`Cannot write to path: ${path}`);
|
|
1194
|
+
const writeData = resource.transform?.write ? resource.transform.write(data) : data;
|
|
1195
|
+
const { method, params } = rpcMethod;
|
|
1196
|
+
const result = await this.transport.call(method, params({ id, data: writeData }));
|
|
1197
|
+
return { id: String(result ?? id) };
|
|
1198
|
+
}
|
|
1199
|
+
if (parsed.type === "resource-list") {
|
|
1200
|
+
const { resource } = parsed;
|
|
1201
|
+
const rpcMethod = resource.methods.create ?? resource.methods.write;
|
|
1202
|
+
if (!rpcMethod) throw new Error(`Cannot write to path: ${path}`);
|
|
1203
|
+
const writeData = resource.transform?.write ? resource.transform.write(data) : data;
|
|
1204
|
+
const { method, params } = rpcMethod;
|
|
1205
|
+
const result = await this.transport.call(method, params({ data: writeData }));
|
|
1206
|
+
return { id: String(result ?? "unknown") };
|
|
1207
|
+
}
|
|
1208
|
+
throw new Error(`Cannot write to path: ${path}`);
|
|
1209
|
+
}
|
|
1210
|
+
async remove(path) {
|
|
1211
|
+
const parsed = this.parsePath(path);
|
|
1212
|
+
if (parsed.type === "resource-item") {
|
|
1213
|
+
const { resource, id } = parsed;
|
|
1214
|
+
if (!resource.methods.remove) {
|
|
1215
|
+
throw new Error(`Cannot remove path: ${path}`);
|
|
1216
|
+
}
|
|
1217
|
+
const { method, params } = resource.methods.remove;
|
|
1218
|
+
await this.transport.call(method, params({ id }));
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
throw new Error(`Cannot remove path: ${path}`);
|
|
1222
|
+
}
|
|
1223
|
+
async search(path, pattern) {
|
|
1224
|
+
const parsed = this.parsePath(path);
|
|
1225
|
+
if (parsed.type === "resource-list" || parsed.type === "resource-item") {
|
|
1226
|
+
const { resource } = parsed;
|
|
1227
|
+
if (resource.methods.search) {
|
|
1228
|
+
const { method, params } = resource.methods.search;
|
|
1229
|
+
const result = await this.transport.call(method, params({ pattern }));
|
|
1230
|
+
if (Array.isArray(result)) return result;
|
|
1231
|
+
return [result];
|
|
1232
|
+
}
|
|
1233
|
+
if (resource.methods.list) {
|
|
1234
|
+
const { method, params } = resource.methods.list;
|
|
1235
|
+
const result = await this.transport.call(method, params({}));
|
|
1236
|
+
if (Array.isArray(result)) {
|
|
1237
|
+
return result.filter(
|
|
1238
|
+
(item) => JSON.stringify(item).includes(pattern)
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1241
|
+
if (JSON.stringify(result).includes(pattern)) {
|
|
1242
|
+
return [result];
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
return [];
|
|
1247
|
+
}
|
|
1248
|
+
// --- Internal ---
|
|
1249
|
+
parsePath(path) {
|
|
1250
|
+
const cleaned = path.replace(/^\/+/, "").replace(/\/+$/, "");
|
|
1251
|
+
if (!cleaned) return { type: "root" };
|
|
1252
|
+
const parts = cleaned.split("/");
|
|
1253
|
+
const resourceName = parts[0];
|
|
1254
|
+
const resource = this.resources.find((r) => r.name === resourceName);
|
|
1255
|
+
if (!resource) throw new NotFoundError(`Invalid path: ${path}`);
|
|
1256
|
+
if (parts.length === 1) {
|
|
1257
|
+
return { type: "resource-list", resource };
|
|
1258
|
+
}
|
|
1259
|
+
let id = parts.slice(1).join("/");
|
|
1260
|
+
if (id.endsWith(".json")) id = id.slice(0, -5);
|
|
1261
|
+
return { type: "resource-item", resource, id };
|
|
1262
|
+
}
|
|
1263
|
+
};
|
|
1264
|
+
|
|
1265
|
+
// src/index.ts
|
|
1266
|
+
var VERSION = "0.1.0";
|
|
1267
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1268
|
+
0 && (module.exports = {
|
|
1269
|
+
AgentFs,
|
|
1270
|
+
HttpBackend,
|
|
1271
|
+
JsonRpcTransport,
|
|
1272
|
+
MountResolver,
|
|
1273
|
+
RpcBackend,
|
|
1274
|
+
RpcError,
|
|
1275
|
+
VERSION,
|
|
1276
|
+
createAgentFsServer,
|
|
1277
|
+
parseCommand
|
|
1278
|
+
});
|