@nifrajs/runner 0.1.0-alpha.1
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/LICENSE +21 -0
- package/README.md +29 -0
- package/dist/index.d.ts +77 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +128 -0
- package/dist/index.js.map +1 -0
- package/package.json +46 -0
- package/src/index.ts +192 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nifra Authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# @nifrajs/runner
|
|
2
|
+
|
|
3
|
+
Run requests through a nifra app and capture **structured results** — the shared engine behind the
|
|
4
|
+
website playground (humans) and the agent run/verify tool (an AI writes code → runs it → sees the
|
|
5
|
+
failure → fixes it). It only touches `app.fetch(Request) → Response`, which is Web-standard, so it runs
|
|
6
|
+
unchanged in the browser, Bun, Node, Deno, and on the edge — and depends on nothing.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { runApp } from "@nifrajs/runner"
|
|
10
|
+
import { app } from "./app"
|
|
11
|
+
|
|
12
|
+
const results = await runApp(app, [
|
|
13
|
+
{ path: "/users/1" },
|
|
14
|
+
{ method: "POST", path: "/users", body: { name: "Ada" } },
|
|
15
|
+
])
|
|
16
|
+
// → [{ method, path, ok, status, headers, body, durationMs }, …]
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
- **`runApp(app, requests, options?)`** — run a batch in order; one `RunResult` per request. Never
|
|
20
|
+
throws: a crash on any request becomes that result's `error` and the batch continues.
|
|
21
|
+
- **`runRequest(app, spec, options?)`** — a single request.
|
|
22
|
+
|
|
23
|
+
A plain object/array `body` is JSON-encoded (with a JSON content-type unless you set one); a
|
|
24
|
+
string/`Uint8Array` is sent as-is. Relative paths resolve against `options.origin`
|
|
25
|
+
(default `http://nifra.local`). Body text is captured up to `options.maxBodyChars` (default 64 KB).
|
|
26
|
+
|
|
27
|
+
It's a **runner, not a security sandbox**: it calls the app you hand it in-process, with no isolation.
|
|
28
|
+
Isolation, when you need it, comes from the host — the browser tab for the playground, your own
|
|
29
|
+
process/CI for the agent runner. Don't feed it code you wouldn't already run.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run requests through a nifra app and capture **structured results** — the shared engine behind the
|
|
3
|
+
* website playground (humans) and the agent run/verify tool (AI writes code → runs it → sees the
|
|
4
|
+
* failure → fixes). It only touches `app.fetch(Request) → Response`, which is Web-standard, so it runs
|
|
5
|
+
* unchanged in the browser, Bun, Node, Deno, and on the edge — and depends on nothing.
|
|
6
|
+
*
|
|
7
|
+
* import { runApp } from "@nifrajs/runner"
|
|
8
|
+
* const results = await runApp(app, [
|
|
9
|
+
* { path: "/users/1" },
|
|
10
|
+
* { method: "POST", path: "/users", body: { name: "Ada" } },
|
|
11
|
+
* ])
|
|
12
|
+
* // → [{ status: 200, ok: true, body: {...}, durationMs }, …]
|
|
13
|
+
*
|
|
14
|
+
* It is a *runner*, not a security sandbox: it calls the app you hand it in-process, with no isolation.
|
|
15
|
+
* Isolation, when you need it, comes from the host — the browser tab for the playground, your own
|
|
16
|
+
* process/CI for the agent runner. Don't feed it code you wouldn't already run.
|
|
17
|
+
*/
|
|
18
|
+
/** Anything with a Web-standard fetch handler — a nifra app, or any `(Request) => Response`. Declared
|
|
19
|
+
* structurally so this package has zero dependency on `@nifrajs/core`. */
|
|
20
|
+
export interface AppLike {
|
|
21
|
+
fetch(request: Request): Response | Promise<Response>;
|
|
22
|
+
}
|
|
23
|
+
/** One request to drive through the app. */
|
|
24
|
+
export interface RequestSpec {
|
|
25
|
+
/** HTTP method. Default `"GET"`. */
|
|
26
|
+
readonly method?: string;
|
|
27
|
+
/** Path (`"/users/1"`) or an absolute URL. Relative paths resolve against a local origin. */
|
|
28
|
+
readonly path: string;
|
|
29
|
+
/** Request headers. */
|
|
30
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
31
|
+
/** Body. A plain object/array is JSON-encoded (with a JSON content-type unless you set one); a
|
|
32
|
+
* string/`Uint8Array` is sent as-is. Ignored for GET/HEAD (those can't carry a body). */
|
|
33
|
+
readonly body?: unknown;
|
|
34
|
+
/** Optional label echoed back on the result (handy in the playground UI). */
|
|
35
|
+
readonly label?: string;
|
|
36
|
+
}
|
|
37
|
+
/** The captured outcome of one request. */
|
|
38
|
+
export interface RunResult {
|
|
39
|
+
readonly label?: string;
|
|
40
|
+
readonly method: string;
|
|
41
|
+
readonly path: string;
|
|
42
|
+
/** `true` when the app returned a 2xx response. `false` for non-2xx, or when `app.fetch` threw. */
|
|
43
|
+
readonly ok: boolean;
|
|
44
|
+
/** HTTP status — present when the app returned a response (absent when it threw). */
|
|
45
|
+
readonly status?: number;
|
|
46
|
+
readonly statusText?: string;
|
|
47
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
48
|
+
/** Parsed JSON when the response is JSON; otherwise the (possibly truncated) text. */
|
|
49
|
+
readonly body?: unknown;
|
|
50
|
+
/** The response body as text, truncated to `maxBodyChars`. */
|
|
51
|
+
readonly bodyText?: string;
|
|
52
|
+
/** `true` when `bodyText` was cut to the cap. */
|
|
53
|
+
readonly truncated?: boolean;
|
|
54
|
+
/** Wall-clock time for this request, milliseconds. */
|
|
55
|
+
readonly durationMs: number;
|
|
56
|
+
/** Set when `app.fetch` threw (the app crashed) rather than returning a response. */
|
|
57
|
+
readonly error?: {
|
|
58
|
+
readonly name: string;
|
|
59
|
+
readonly message: string;
|
|
60
|
+
readonly stack?: string;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export interface RunOptions {
|
|
64
|
+
/** Origin used to resolve relative paths. Default `"http://nifra.local"`. */
|
|
65
|
+
readonly origin?: string;
|
|
66
|
+
/** Cap on captured body text, in characters. Default 65536. */
|
|
67
|
+
readonly maxBodyChars?: number;
|
|
68
|
+
}
|
|
69
|
+
/** Drive a single request through the app, capturing the outcome (never throws — a thrown app error
|
|
70
|
+
* becomes `result.error`). */
|
|
71
|
+
export declare function runRequest(app: AppLike, spec: RequestSpec, options?: RunOptions): Promise<RunResult>;
|
|
72
|
+
/**
|
|
73
|
+
* Run a batch of requests through the app, in order, and return a result per request. Never throws:
|
|
74
|
+
* an app crash on any request is captured as that result's `error` and the run continues.
|
|
75
|
+
*/
|
|
76
|
+
export declare function runApp(app: AppLike, requests: readonly RequestSpec[], options?: RunOptions): Promise<RunResult[]>;
|
|
77
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH;0EAC0E;AAC1E,MAAM,WAAW,OAAO;IACtB,KAAK,CAAC,OAAO,EAAE,OAAO,GAAG,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;CACtD;AAED,4CAA4C;AAC5C,MAAM,WAAW,WAAW;IAC1B,oCAAoC;IACpC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,6FAA6F;IAC7F,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,uBAAuB;IACvB,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;IACnD;6FACyF;IACzF,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAA;IACvB,6EAA6E;IAC7E,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CACxB;AAED,2CAA2C;AAC3C,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,mGAAmG;IACnG,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAA;IACpB,qFAAqF;IACrF,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;IAC5B,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;IACnD,sFAAsF;IACtF,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAA;IACvB,8DAA8D;IAC9D,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;IAC1B,iDAAiD;IACjD,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAA;IAC5B,sDAAsD;IACtD,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,qFAAqF;IACrF,QAAQ,CAAC,KAAK,CAAC,EAAE;QAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAC9F;AAED,MAAM,WAAW,UAAU;IACzB,6EAA6E;IAC7E,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,+DAA+D;IAC/D,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAC/B;AAgDD;8BAC8B;AAC9B,wBAAsB,UAAU,CAC9B,GAAG,EAAE,OAAO,EACZ,IAAI,EAAE,WAAW,EACjB,OAAO,GAAE,UAAe,GACvB,OAAO,CAAC,SAAS,CAAC,CA+CpB;AASD;;;GAGG;AACH,wBAAgB,MAAM,CACpB,GAAG,EAAE,OAAO,EACZ,QAAQ,EAAE,SAAS,WAAW,EAAE,EAChC,OAAO,GAAE,UAAe,GACvB,OAAO,CAAC,SAAS,EAAE,CAAC,CAMtB"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run requests through a nifra app and capture **structured results** — the shared engine behind the
|
|
3
|
+
* website playground (humans) and the agent run/verify tool (AI writes code → runs it → sees the
|
|
4
|
+
* failure → fixes). It only touches `app.fetch(Request) → Response`, which is Web-standard, so it runs
|
|
5
|
+
* unchanged in the browser, Bun, Node, Deno, and on the edge — and depends on nothing.
|
|
6
|
+
*
|
|
7
|
+
* import { runApp } from "@nifrajs/runner"
|
|
8
|
+
* const results = await runApp(app, [
|
|
9
|
+
* { path: "/users/1" },
|
|
10
|
+
* { method: "POST", path: "/users", body: { name: "Ada" } },
|
|
11
|
+
* ])
|
|
12
|
+
* // → [{ status: 200, ok: true, body: {...}, durationMs }, …]
|
|
13
|
+
*
|
|
14
|
+
* It is a *runner*, not a security sandbox: it calls the app you hand it in-process, with no isolation.
|
|
15
|
+
* Isolation, when you need it, comes from the host — the browser tab for the playground, your own
|
|
16
|
+
* process/CI for the agent runner. Don't feed it code you wouldn't already run.
|
|
17
|
+
*/
|
|
18
|
+
const DEFAULT_ORIGIN = "http://nifra.local";
|
|
19
|
+
const DEFAULT_MAX_BODY = 64 * 1024;
|
|
20
|
+
const now = () => typeof performance !== "undefined" && typeof performance.now === "function"
|
|
21
|
+
? performance.now()
|
|
22
|
+
: Date.now();
|
|
23
|
+
const METHODS_WITHOUT_BODY = new Set(["GET", "HEAD"]);
|
|
24
|
+
/** A plain JSON-ish object/array (encode it as JSON) — vs a string/binary body we pass through. */
|
|
25
|
+
function isJsonBody(body) {
|
|
26
|
+
if (body === null || typeof body !== "object")
|
|
27
|
+
return false;
|
|
28
|
+
if (body instanceof Uint8Array || body instanceof ArrayBuffer || body instanceof Blob)
|
|
29
|
+
return false;
|
|
30
|
+
if (typeof FormData !== "undefined" && body instanceof FormData)
|
|
31
|
+
return false;
|
|
32
|
+
if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream)
|
|
33
|
+
return false;
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
function buildRequest(spec, origin) {
|
|
37
|
+
const method = (spec.method ?? "GET").toUpperCase();
|
|
38
|
+
const url = /^https?:\/\//i.test(spec.path) ? spec.path : new URL(spec.path, origin).toString();
|
|
39
|
+
const headers = new Headers(spec.headers);
|
|
40
|
+
const init = { method, headers };
|
|
41
|
+
if (spec.body !== undefined && !METHODS_WITHOUT_BODY.has(method)) {
|
|
42
|
+
if (isJsonBody(spec.body)) {
|
|
43
|
+
if (!headers.has("content-type"))
|
|
44
|
+
headers.set("content-type", "application/json");
|
|
45
|
+
init.body = JSON.stringify(spec.body);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
init.body = spec.body;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return new Request(url, init);
|
|
52
|
+
}
|
|
53
|
+
function headersToObject(headers) {
|
|
54
|
+
const out = {};
|
|
55
|
+
headers.forEach((value, key) => {
|
|
56
|
+
out[key] = value;
|
|
57
|
+
});
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
/** Drive a single request through the app, capturing the outcome (never throws — a thrown app error
|
|
61
|
+
* becomes `result.error`). */
|
|
62
|
+
export async function runRequest(app, spec, options = {}) {
|
|
63
|
+
const origin = options.origin ?? DEFAULT_ORIGIN;
|
|
64
|
+
const maxBody = options.maxBodyChars ?? DEFAULT_MAX_BODY;
|
|
65
|
+
const method = (spec.method ?? "GET").toUpperCase();
|
|
66
|
+
const base = {
|
|
67
|
+
...(spec.label === undefined ? {} : { label: spec.label }),
|
|
68
|
+
method,
|
|
69
|
+
path: spec.path,
|
|
70
|
+
};
|
|
71
|
+
const start = now();
|
|
72
|
+
let request;
|
|
73
|
+
try {
|
|
74
|
+
request = buildRequest(spec, origin);
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
// A malformed spec (bad URL, GET-with-body via a custom method, …) — report it, don't throw.
|
|
78
|
+
return { ...base, ok: false, durationMs: now() - start, error: toError(err) };
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const res = await app.fetch(request);
|
|
82
|
+
const text = await res.text();
|
|
83
|
+
const truncated = text.length > maxBody;
|
|
84
|
+
const bodyText = truncated ? text.slice(0, maxBody) : text;
|
|
85
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
86
|
+
let body = bodyText;
|
|
87
|
+
if (contentType.includes("application/json") && text.length > 0) {
|
|
88
|
+
try {
|
|
89
|
+
body = JSON.parse(text);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// Content-Type lied, or the body is partial — keep the text form.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
...base,
|
|
97
|
+
ok: res.ok,
|
|
98
|
+
status: res.status,
|
|
99
|
+
statusText: res.statusText,
|
|
100
|
+
headers: headersToObject(res.headers),
|
|
101
|
+
body,
|
|
102
|
+
bodyText,
|
|
103
|
+
...(truncated ? { truncated } : {}),
|
|
104
|
+
durationMs: now() - start,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
return { ...base, ok: false, durationMs: now() - start, error: toError(err) };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function toError(err) {
|
|
112
|
+
if (err instanceof Error) {
|
|
113
|
+
return { name: err.name, message: err.message, ...(err.stack ? { stack: err.stack } : {}) };
|
|
114
|
+
}
|
|
115
|
+
return { name: "Error", message: String(err) };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Run a batch of requests through the app, in order, and return a result per request. Never throws:
|
|
119
|
+
* an app crash on any request is captured as that result's `error` and the run continues.
|
|
120
|
+
*/
|
|
121
|
+
export function runApp(app, requests, options = {}) {
|
|
122
|
+
return requests.reduce(async (acc, spec) => {
|
|
123
|
+
const results = await acc;
|
|
124
|
+
results.push(await runRequest(app, spec, options));
|
|
125
|
+
return results;
|
|
126
|
+
}, Promise.resolve([]));
|
|
127
|
+
}
|
|
128
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nifrajs/runner",
|
|
3
|
+
"version": "0.1.0-alpha.1",
|
|
4
|
+
"description": "Run requests through a nifra app's fetch and capture structured results — the engine behind the playground and the agent run/verify tool. Dependency-free; runs in the browser, Bun, Node, Deno, and on the edge.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"bun",
|
|
7
|
+
"typescript",
|
|
8
|
+
"nifra",
|
|
9
|
+
"web-framework",
|
|
10
|
+
"full-stack",
|
|
11
|
+
"edge",
|
|
12
|
+
"ssr"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "./dist/index.js",
|
|
16
|
+
"module": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"bun": "./src/index.ts",
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"default": "./dist/index.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"src"
|
|
28
|
+
],
|
|
29
|
+
"sideEffects": false,
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsc -p tsconfig.build.json && bun run ../../scripts/fix-dts.ts dist"
|
|
32
|
+
},
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/nifrajs/nifra.git",
|
|
40
|
+
"directory": "packages/runner"
|
|
41
|
+
},
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/nifrajs/nifra/issues"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/nifrajs/nifra#readme"
|
|
46
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run requests through a nifra app and capture **structured results** — the shared engine behind the
|
|
3
|
+
* website playground (humans) and the agent run/verify tool (AI writes code → runs it → sees the
|
|
4
|
+
* failure → fixes). It only touches `app.fetch(Request) → Response`, which is Web-standard, so it runs
|
|
5
|
+
* unchanged in the browser, Bun, Node, Deno, and on the edge — and depends on nothing.
|
|
6
|
+
*
|
|
7
|
+
* import { runApp } from "@nifrajs/runner"
|
|
8
|
+
* const results = await runApp(app, [
|
|
9
|
+
* { path: "/users/1" },
|
|
10
|
+
* { method: "POST", path: "/users", body: { name: "Ada" } },
|
|
11
|
+
* ])
|
|
12
|
+
* // → [{ status: 200, ok: true, body: {...}, durationMs }, …]
|
|
13
|
+
*
|
|
14
|
+
* It is a *runner*, not a security sandbox: it calls the app you hand it in-process, with no isolation.
|
|
15
|
+
* Isolation, when you need it, comes from the host — the browser tab for the playground, your own
|
|
16
|
+
* process/CI for the agent runner. Don't feed it code you wouldn't already run.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Anything with a Web-standard fetch handler — a nifra app, or any `(Request) => Response`. Declared
|
|
20
|
+
* structurally so this package has zero dependency on `@nifrajs/core`. */
|
|
21
|
+
export interface AppLike {
|
|
22
|
+
fetch(request: Request): Response | Promise<Response>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One request to drive through the app. */
|
|
26
|
+
export interface RequestSpec {
|
|
27
|
+
/** HTTP method. Default `"GET"`. */
|
|
28
|
+
readonly method?: string
|
|
29
|
+
/** Path (`"/users/1"`) or an absolute URL. Relative paths resolve against a local origin. */
|
|
30
|
+
readonly path: string
|
|
31
|
+
/** Request headers. */
|
|
32
|
+
readonly headers?: Readonly<Record<string, string>>
|
|
33
|
+
/** Body. A plain object/array is JSON-encoded (with a JSON content-type unless you set one); a
|
|
34
|
+
* string/`Uint8Array` is sent as-is. Ignored for GET/HEAD (those can't carry a body). */
|
|
35
|
+
readonly body?: unknown
|
|
36
|
+
/** Optional label echoed back on the result (handy in the playground UI). */
|
|
37
|
+
readonly label?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The captured outcome of one request. */
|
|
41
|
+
export interface RunResult {
|
|
42
|
+
readonly label?: string
|
|
43
|
+
readonly method: string
|
|
44
|
+
readonly path: string
|
|
45
|
+
/** `true` when the app returned a 2xx response. `false` for non-2xx, or when `app.fetch` threw. */
|
|
46
|
+
readonly ok: boolean
|
|
47
|
+
/** HTTP status — present when the app returned a response (absent when it threw). */
|
|
48
|
+
readonly status?: number
|
|
49
|
+
readonly statusText?: string
|
|
50
|
+
readonly headers?: Readonly<Record<string, string>>
|
|
51
|
+
/** Parsed JSON when the response is JSON; otherwise the (possibly truncated) text. */
|
|
52
|
+
readonly body?: unknown
|
|
53
|
+
/** The response body as text, truncated to `maxBodyChars`. */
|
|
54
|
+
readonly bodyText?: string
|
|
55
|
+
/** `true` when `bodyText` was cut to the cap. */
|
|
56
|
+
readonly truncated?: boolean
|
|
57
|
+
/** Wall-clock time for this request, milliseconds. */
|
|
58
|
+
readonly durationMs: number
|
|
59
|
+
/** Set when `app.fetch` threw (the app crashed) rather than returning a response. */
|
|
60
|
+
readonly error?: { readonly name: string; readonly message: string; readonly stack?: string }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface RunOptions {
|
|
64
|
+
/** Origin used to resolve relative paths. Default `"http://nifra.local"`. */
|
|
65
|
+
readonly origin?: string
|
|
66
|
+
/** Cap on captured body text, in characters. Default 65536. */
|
|
67
|
+
readonly maxBodyChars?: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const DEFAULT_ORIGIN = "http://nifra.local"
|
|
71
|
+
const DEFAULT_MAX_BODY = 64 * 1024
|
|
72
|
+
|
|
73
|
+
const now = (): number =>
|
|
74
|
+
typeof performance !== "undefined" && typeof performance.now === "function"
|
|
75
|
+
? performance.now()
|
|
76
|
+
: Date.now()
|
|
77
|
+
|
|
78
|
+
const METHODS_WITHOUT_BODY: ReadonlySet<string> = new Set(["GET", "HEAD"])
|
|
79
|
+
|
|
80
|
+
/** A plain JSON-ish object/array (encode it as JSON) — vs a string/binary body we pass through. */
|
|
81
|
+
function isJsonBody(body: unknown): body is Record<string, unknown> | unknown[] {
|
|
82
|
+
if (body === null || typeof body !== "object") return false
|
|
83
|
+
if (body instanceof Uint8Array || body instanceof ArrayBuffer || body instanceof Blob)
|
|
84
|
+
return false
|
|
85
|
+
if (typeof FormData !== "undefined" && body instanceof FormData) return false
|
|
86
|
+
if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) return false
|
|
87
|
+
return true
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function buildRequest(spec: RequestSpec, origin: string): Request {
|
|
91
|
+
const method = (spec.method ?? "GET").toUpperCase()
|
|
92
|
+
const url = /^https?:\/\//i.test(spec.path) ? spec.path : new URL(spec.path, origin).toString()
|
|
93
|
+
|
|
94
|
+
const headers = new Headers(spec.headers as Record<string, string> | undefined)
|
|
95
|
+
const init: RequestInit = { method, headers }
|
|
96
|
+
|
|
97
|
+
if (spec.body !== undefined && !METHODS_WITHOUT_BODY.has(method)) {
|
|
98
|
+
if (isJsonBody(spec.body)) {
|
|
99
|
+
if (!headers.has("content-type")) headers.set("content-type", "application/json")
|
|
100
|
+
init.body = JSON.stringify(spec.body)
|
|
101
|
+
} else {
|
|
102
|
+
init.body = spec.body as RequestInit["body"]
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return new Request(url, init)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function headersToObject(headers: Headers): Record<string, string> {
|
|
109
|
+
const out: Record<string, string> = {}
|
|
110
|
+
headers.forEach((value, key) => {
|
|
111
|
+
out[key] = value
|
|
112
|
+
})
|
|
113
|
+
return out
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Drive a single request through the app, capturing the outcome (never throws — a thrown app error
|
|
117
|
+
* becomes `result.error`). */
|
|
118
|
+
export async function runRequest(
|
|
119
|
+
app: AppLike,
|
|
120
|
+
spec: RequestSpec,
|
|
121
|
+
options: RunOptions = {},
|
|
122
|
+
): Promise<RunResult> {
|
|
123
|
+
const origin = options.origin ?? DEFAULT_ORIGIN
|
|
124
|
+
const maxBody = options.maxBodyChars ?? DEFAULT_MAX_BODY
|
|
125
|
+
const method = (spec.method ?? "GET").toUpperCase()
|
|
126
|
+
const base = {
|
|
127
|
+
...(spec.label === undefined ? {} : { label: spec.label }),
|
|
128
|
+
method,
|
|
129
|
+
path: spec.path,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const start = now()
|
|
133
|
+
let request: Request
|
|
134
|
+
try {
|
|
135
|
+
request = buildRequest(spec, origin)
|
|
136
|
+
} catch (err) {
|
|
137
|
+
// A malformed spec (bad URL, GET-with-body via a custom method, …) — report it, don't throw.
|
|
138
|
+
return { ...base, ok: false, durationMs: now() - start, error: toError(err) }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const res = await app.fetch(request)
|
|
143
|
+
const text = await res.text()
|
|
144
|
+
const truncated = text.length > maxBody
|
|
145
|
+
const bodyText = truncated ? text.slice(0, maxBody) : text
|
|
146
|
+
const contentType = res.headers.get("content-type") ?? ""
|
|
147
|
+
let body: unknown = bodyText
|
|
148
|
+
if (contentType.includes("application/json") && text.length > 0) {
|
|
149
|
+
try {
|
|
150
|
+
body = JSON.parse(text)
|
|
151
|
+
} catch {
|
|
152
|
+
// Content-Type lied, or the body is partial — keep the text form.
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
...base,
|
|
157
|
+
ok: res.ok,
|
|
158
|
+
status: res.status,
|
|
159
|
+
statusText: res.statusText,
|
|
160
|
+
headers: headersToObject(res.headers),
|
|
161
|
+
body,
|
|
162
|
+
bodyText,
|
|
163
|
+
...(truncated ? { truncated } : {}),
|
|
164
|
+
durationMs: now() - start,
|
|
165
|
+
}
|
|
166
|
+
} catch (err) {
|
|
167
|
+
return { ...base, ok: false, durationMs: now() - start, error: toError(err) }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function toError(err: unknown): { name: string; message: string; stack?: string } {
|
|
172
|
+
if (err instanceof Error) {
|
|
173
|
+
return { name: err.name, message: err.message, ...(err.stack ? { stack: err.stack } : {}) }
|
|
174
|
+
}
|
|
175
|
+
return { name: "Error", message: String(err) }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Run a batch of requests through the app, in order, and return a result per request. Never throws:
|
|
180
|
+
* an app crash on any request is captured as that result's `error` and the run continues.
|
|
181
|
+
*/
|
|
182
|
+
export function runApp(
|
|
183
|
+
app: AppLike,
|
|
184
|
+
requests: readonly RequestSpec[],
|
|
185
|
+
options: RunOptions = {},
|
|
186
|
+
): Promise<RunResult[]> {
|
|
187
|
+
return requests.reduce<Promise<RunResult[]>>(async (acc, spec) => {
|
|
188
|
+
const results = await acc
|
|
189
|
+
results.push(await runRequest(app, spec, options))
|
|
190
|
+
return results
|
|
191
|
+
}, Promise.resolve([]))
|
|
192
|
+
}
|