@venn-lang/http 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.
@@ -0,0 +1,63 @@
1
+ //#region src/server/http-server.types.d.ts
2
+ /** One request a server received, as the language sees it. */
3
+ interface ServerRequest {
4
+ method: string;
5
+ /** The path with its query string, exactly as it arrived. */
6
+ url: string;
7
+ headers: Record<string, string>;
8
+ body: string;
9
+ }
10
+ /** What a handler answers with. Every field has a sensible default. */
11
+ interface ServerReply {
12
+ status?: number;
13
+ headers?: Record<string, string>;
14
+ /** A string is sent as-is; anything else is sent as JSON. */
15
+ body?: unknown;
16
+ }
17
+ /** Called once per request. Returning nothing sends `204 No Content`. */
18
+ type RequestHandler = (request: ServerRequest) => ServerReply | undefined | Promise<ServerReply | undefined>;
19
+ /** A server that is listening, and how to stop it. */
20
+ interface RunningServer {
21
+ /** The port it actually bound to. Asking for 0 gets one chosen for you. */
22
+ readonly port: number;
23
+ close(): Promise<void>;
24
+ }
25
+ /**
26
+ * Accepting requests over HTTP.
27
+ *
28
+ * Two implementations: `createNodeServer` binds a real socket, and
29
+ * `createMemoryServer` keeps the handler in memory so a test can deliver a
30
+ * request without a network. The conformance suite is what says they agree.
31
+ */
32
+ interface HttpServer {
33
+ listen(args: {
34
+ port: number;
35
+ host?: string;
36
+ handle: RequestHandler;
37
+ }): Promise<RunningServer>;
38
+ }
39
+ //#endregion
40
+ //#region src/server/node-server.d.ts
41
+ /** A node HttpServer, plus the way to hang up everything it still has open. */
42
+ interface NodeHttpServer extends HttpServer {
43
+ /**
44
+ * Close every server started here that is still listening.
45
+ *
46
+ * A process owns its sockets, so whoever owns the process (the CLI) needs one
47
+ * call to give them all back on the way out.
48
+ */
49
+ closeAll(): Promise<void>;
50
+ }
51
+ /**
52
+ * The real implementation: a bound socket, on the port the OS gave it.
53
+ *
54
+ * Only this file reaches for `node:http`, which is why it sits behind the
55
+ * `@venn-lang/http/node` subpath. The rest of the package stays platform-neutral and
56
+ * runs wherever the language runs, the editor's worker included.
57
+ *
58
+ * @throws VN7020 if the port is taken, VN7021 if the socket refuses to bind.
59
+ */
60
+ declare function createNodeServer(): NodeHttpServer;
61
+ //#endregion
62
+ export { type NodeHttpServer, createNodeServer };
63
+ //# sourceMappingURL=node.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"node.d.mts","names":[],"sources":["../src/server/http-server.types.ts","../src/server/node-server.ts"],"mappings":";;UACiB;EACf;;EAEA;EACA,SAAS;EACT;;;UAIe;EACf;EACA,UAAU;;EAEV;;;KAIU,kBACV,SAAS,kBACN,0BAA0B,QAAQ;;UAGtB;;WAEN;EACT,SAAS;;;;;;;;;UAUM;EACf,OAAO;IAAQ;IAAc;IAAe,QAAQ;MAAmB,QAAQ;;;;;UC1BhE,uBAAuB;;;;;;;EAOtC,YAAY;;;;;;;;;;;iBAYE,oBAAoB"}
package/dist/node.mjs ADDED
@@ -0,0 +1,146 @@
1
+ import { createServer } from "node:http";
2
+ import { VennError } from "@venn-lang/contracts";
3
+ //#region src/server/http-server.errors.ts
4
+ /** VN7020: the address the flow asked for is already taken by something else. */
5
+ function portInUse(args) {
6
+ return new VennError({
7
+ code: "VN7020",
8
+ message: `Port ${args.port} is already in use — stop whatever is listening on ${args.host}:${args.port}, or ask for "port: 0" to take any free one.`,
9
+ detail: {
10
+ port: args.port,
11
+ host: args.host,
12
+ cause: "EADDRINUSE"
13
+ }
14
+ });
15
+ }
16
+ /** VN7021: the socket refused to bind for any other reason. */
17
+ function listenFailed(args) {
18
+ return new VennError({
19
+ code: "VN7021",
20
+ message: `Could not listen on ${args.host}:${args.port} — ${args.cause}.`,
21
+ detail: {
22
+ port: args.port,
23
+ host: args.host,
24
+ cause: args.cause
25
+ }
26
+ });
27
+ }
28
+ /**
29
+ * Whatever the socket threw, as a Venn error: VN7020 for `EADDRINUSE`, VN7021
30
+ * for anything else.
31
+ *
32
+ * The translation lives at the producer so no caller has to read a `node:net`
33
+ * errno to know what went wrong.
34
+ */
35
+ function asListenError(args) {
36
+ const error = args.error;
37
+ if (error?.code === "EADDRINUSE") return portInUse({
38
+ port: args.port,
39
+ host: args.host
40
+ });
41
+ return listenFailed({
42
+ port: args.port,
43
+ host: args.host,
44
+ cause: error?.message ?? String(args.error)
45
+ });
46
+ }
47
+ //#endregion
48
+ //#region src/server/node-server.ts
49
+ /**
50
+ * The real implementation: a bound socket, on the port the OS gave it.
51
+ *
52
+ * Only this file reaches for `node:http`, which is why it sits behind the
53
+ * `@venn-lang/http/node` subpath. The rest of the package stays platform-neutral and
54
+ * runs wherever the language runs, the editor's worker included.
55
+ *
56
+ * @throws VN7020 if the port is taken, VN7021 if the socket refuses to bind.
57
+ */
58
+ function createNodeServer() {
59
+ const open = /* @__PURE__ */ new Set();
60
+ return {
61
+ listen: async (args) => {
62
+ const server = await bind(args);
63
+ open.add(server);
64
+ return {
65
+ port: server.port,
66
+ close: () => forget(open, server)
67
+ };
68
+ },
69
+ closeAll: async () => {
70
+ for (const server of [...open]) await forget(open, server);
71
+ }
72
+ };
73
+ }
74
+ /** Closing is idempotent from the set's point of view: gone is gone. */
75
+ async function forget(open, server) {
76
+ open.delete(server);
77
+ await server.close();
78
+ }
79
+ function bind(args) {
80
+ const { port, host, handle } = args;
81
+ return new Promise((resolve, reject) => {
82
+ const at = host ?? "127.0.0.1";
83
+ const server = createServer((request, response) => {
84
+ answer(request, response, handle);
85
+ });
86
+ server.once("error", (error) => reject(asListenError({
87
+ port,
88
+ host: at,
89
+ error
90
+ })));
91
+ server.listen(port, at, () => resolve(running(server)));
92
+ });
93
+ }
94
+ function running(server) {
95
+ const address = server.address();
96
+ return {
97
+ port: typeof address === "object" && address ? address.port : 0,
98
+ close: () => new Promise((resolve) => {
99
+ server.closeAllConnections?.();
100
+ server.close(() => resolve());
101
+ })
102
+ };
103
+ }
104
+ /** A handler that throws becomes a 500: one bad request must not kill the server. */
105
+ async function answer(request, response, handle) {
106
+ try {
107
+ send(response, await handle(await read(request)) ?? { status: 204 });
108
+ } catch (error) {
109
+ send(response, {
110
+ status: 500,
111
+ body: { error: String(error?.message ?? error) }
112
+ });
113
+ }
114
+ }
115
+ function send(response, reply) {
116
+ const json = typeof reply.body !== "string" && reply.body !== void 0;
117
+ const headers = {
118
+ ...json ? { "content-type": "application/json" } : {},
119
+ ...reply.headers
120
+ };
121
+ response.writeHead(reply.status ?? 200, headers);
122
+ response.end(body(reply.body, json));
123
+ }
124
+ function body(value, json) {
125
+ if (value === void 0) return "";
126
+ return json ? JSON.stringify(value) : String(value);
127
+ }
128
+ async function read(request) {
129
+ const chunks = [];
130
+ for await (const chunk of request) chunks.push(chunk);
131
+ return {
132
+ method: request.method ?? "GET",
133
+ url: request.url ?? "/",
134
+ headers: plain(request.headers),
135
+ body: Buffer.concat(chunks).toString("utf8")
136
+ };
137
+ }
138
+ function plain(headers) {
139
+ const out = {};
140
+ for (const [key, value] of Object.entries(headers)) if (value !== void 0) out[key] = Array.isArray(value) ? value.join(", ") : value;
141
+ return out;
142
+ }
143
+ //#endregion
144
+ export { createNodeServer };
145
+
146
+ //# sourceMappingURL=node.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"node.mjs","names":[],"sources":["../src/server/http-server.errors.ts","../src/server/node-server.ts"],"sourcesContent":["import { VennError } from \"@venn-lang/contracts\";\n\n/** VN7020: the address the flow asked for is already taken by something else. */\nexport function portInUse(args: { port: number; host: string }): VennError {\n return new VennError({\n code: \"VN7020\",\n message: `Port ${args.port} is already in use — stop whatever is listening on ${args.host}:${args.port}, or ask for \"port: 0\" to take any free one.`,\n detail: { port: args.port, host: args.host, cause: \"EADDRINUSE\" },\n });\n}\n\n/** VN7021: the socket refused to bind for any other reason. */\nexport function listenFailed(args: { port: number; host: string; cause: string }): VennError {\n return new VennError({\n code: \"VN7021\",\n message: `Could not listen on ${args.host}:${args.port} — ${args.cause}.`,\n detail: { port: args.port, host: args.host, cause: args.cause },\n });\n}\n\n/**\n * Whatever the socket threw, as a Venn error: VN7020 for `EADDRINUSE`, VN7021\n * for anything else.\n *\n * The translation lives at the producer so no caller has to read a `node:net`\n * errno to know what went wrong.\n */\nexport function asListenError(args: { port: number; host: string; error: unknown }): VennError {\n const error = args.error as { code?: string; message?: string } | undefined;\n if (error?.code === \"EADDRINUSE\") return portInUse({ port: args.port, host: args.host });\n return listenFailed({\n port: args.port,\n host: args.host,\n cause: error?.message ?? String(args.error),\n });\n}\n","import { createServer, type IncomingMessage, type ServerResponse } from \"node:http\";\nimport { asListenError } from \"./http-server.errors.js\";\nimport type {\n HttpServer,\n RequestHandler,\n RunningServer,\n ServerReply,\n ServerRequest,\n} from \"./http-server.types.js\";\n\n/** A node HttpServer, plus the way to hang up everything it still has open. */\nexport interface NodeHttpServer extends HttpServer {\n /**\n * Close every server started here that is still listening.\n *\n * A process owns its sockets, so whoever owns the process (the CLI) needs one\n * call to give them all back on the way out.\n */\n closeAll(): Promise<void>;\n}\n\n/**\n * The real implementation: a bound socket, on the port the OS gave it.\n *\n * Only this file reaches for `node:http`, which is why it sits behind the\n * `@venn-lang/http/node` subpath. The rest of the package stays platform-neutral and\n * runs wherever the language runs, the editor's worker included.\n *\n * @throws VN7020 if the port is taken, VN7021 if the socket refuses to bind.\n */\nexport function createNodeServer(): NodeHttpServer {\n const open = new Set<RunningServer>();\n return {\n listen: async (args) => {\n const server = await bind(args);\n open.add(server);\n return { port: server.port, close: () => forget(open, server) };\n },\n closeAll: async () => {\n for (const server of [...open]) await forget(open, server);\n },\n };\n}\n\n/** Closing is idempotent from the set's point of view: gone is gone. */\nasync function forget(open: Set<RunningServer>, server: RunningServer): Promise<void> {\n open.delete(server);\n await server.close();\n}\n\nfunction bind(args: {\n port: number;\n host?: string;\n handle: RequestHandler;\n}): Promise<RunningServer> {\n const { port, host, handle } = args;\n return new Promise<RunningServer>((resolve, reject) => {\n const at = host ?? \"127.0.0.1\";\n const server = createServer((request, response) => {\n void answer(request, response, handle);\n });\n // A busy port is the flow's problem, not a `node:net` errno: translate here.\n server.once(\"error\", (error) => reject(asListenError({ port, host: at, error })));\n server.listen(port, at, () => resolve(running(server)));\n });\n}\n\nfunction running(server: ReturnType<typeof createServer>): RunningServer {\n const address = server.address();\n return {\n port: typeof address === \"object\" && address ? address.port : 0,\n close: () =>\n new Promise<void>((resolve) => {\n server.closeAllConnections?.();\n server.close(() => resolve());\n }),\n };\n}\n\n/** A handler that throws becomes a 500: one bad request must not kill the server. */\nasync function answer(\n request: IncomingMessage,\n response: ServerResponse,\n handle: RequestHandler,\n): Promise<void> {\n try {\n send(response, (await handle(await read(request))) ?? { status: 204 });\n } catch (error) {\n send(response, { status: 500, body: { error: String((error as Error)?.message ?? error) } });\n }\n}\n\nfunction send(response: ServerResponse, reply: ServerReply): void {\n const json = typeof reply.body !== \"string\" && reply.body !== undefined;\n const headers = { ...(json ? { \"content-type\": \"application/json\" } : {}), ...reply.headers };\n response.writeHead(reply.status ?? 200, headers);\n response.end(body(reply.body, json));\n}\n\nfunction body(value: unknown, json: boolean): string {\n if (value === undefined) return \"\";\n return json ? JSON.stringify(value) : String(value);\n}\n\nasync function read(request: IncomingMessage): Promise<ServerRequest> {\n const chunks: Buffer[] = [];\n for await (const chunk of request) chunks.push(chunk as Buffer);\n return {\n method: request.method ?? \"GET\",\n url: request.url ?? \"/\",\n headers: plain(request.headers),\n body: Buffer.concat(chunks).toString(\"utf8\"),\n };\n}\n\nfunction plain(headers: IncomingMessage[\"headers\"]): Record<string, string> {\n const out: Record<string, string> = {};\n for (const [key, value] of Object.entries(headers)) {\n if (value !== undefined) out[key] = Array.isArray(value) ? value.join(\", \") : value;\n }\n return out;\n}\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@venn-lang/http",
3
+ "version": "0.1.0",
4
+ "description": "The http namespace: seven request verbs, a server, and the two ports they ride.",
5
+ "keywords": [
6
+ "venn",
7
+ "testing",
8
+ "e2e",
9
+ "http"
10
+ ],
11
+ "homepage": "https://github.com/venn-lang/venn/tree/main/packages/std-http#readme",
12
+ "bugs": "https://github.com/venn-lang/venn/issues",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/venn-lang/venn.git",
16
+ "directory": "packages/std-http"
17
+ },
18
+ "license": "MIT",
19
+ "author": "Vinicius Borges",
20
+ "type": "module",
21
+ "sideEffects": false,
22
+ "exports": {
23
+ ".": {
24
+ "development": "./src/index.ts",
25
+ "types": "./dist/index.d.ts",
26
+ "import": "./dist/index.js",
27
+ "default": "./dist/index.js"
28
+ },
29
+ "./node": {
30
+ "development": "./src/node.ts",
31
+ "types": "./dist/node.d.mts",
32
+ "import": "./dist/node.mjs",
33
+ "default": "./dist/node.mjs"
34
+ }
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "src",
39
+ "!src/**/*.test.ts",
40
+ "!src/**/*.suite.ts"
41
+ ],
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "dependencies": {
46
+ "@venn-lang/sdk": "0.1.0",
47
+ "@venn-lang/types": "0.1.0",
48
+ "@venn-lang/contracts": "0.1.0"
49
+ },
50
+ "devDependencies": {
51
+ "tsdown": "^0.22.14",
52
+ "typescript": "^7.0.2",
53
+ "vitest": "^4.1.10",
54
+ "@types/node": "^24"
55
+ },
56
+ "scripts": {
57
+ "build": "tsdown",
58
+ "test": "vitest run",
59
+ "typecheck": "tsc --noEmit"
60
+ }
61
+ }
@@ -0,0 +1,110 @@
1
+ import type { HttpRequest } from "../port/index.js";
2
+ import type { RequestParams } from "./request.types.js";
3
+
4
+ interface Payload {
5
+ body?: string;
6
+ contentType?: string;
7
+ }
8
+
9
+ export interface BuildArgs {
10
+ method: string;
11
+ url: unknown;
12
+ params: RequestParams;
13
+ baseUrl: unknown;
14
+ signal?: AbortSignal;
15
+ }
16
+
17
+ /** Turn one action call into the request the HttpClient port sends. */
18
+ export function buildRequest(args: BuildArgs): HttpRequest {
19
+ const payload = bodyOf(args.params);
20
+ return {
21
+ method: args.method,
22
+ url: withQuery(absoluteUrl(String(args.url ?? ""), args.baseUrl), args.params.query),
23
+ headers: headersOf(args.params, payload.contentType),
24
+ body: payload.body,
25
+ signal: args.signal,
26
+ };
27
+ }
28
+
29
+ const BOUNDARY = "----vennFormBoundary";
30
+
31
+ /** A map means JSON and a string means raw, unless `encode` says otherwise. */
32
+ function bodyOf(params: RequestParams): Payload {
33
+ if (params.body === undefined) return {};
34
+ switch (params.encode ?? defaultEncoding(params.body)) {
35
+ case "raw":
36
+ return { body: String(params.body) };
37
+ case "form":
38
+ return { body: encode(asMap(params.body)), contentType: FORM_TYPE };
39
+ case "multipart":
40
+ return multipart(asMap(params.body));
41
+ default:
42
+ return { body: JSON.stringify(params.body), contentType: "application/json" };
43
+ }
44
+ }
45
+
46
+ function defaultEncoding(body: unknown): string {
47
+ return typeof body === "string" ? "raw" : "json";
48
+ }
49
+
50
+ const FORM_TYPE = "application/x-www-form-urlencoded";
51
+
52
+ function multipart(values: Record<string, string | number | boolean>): Payload {
53
+ const parts = Object.entries(values).map(
54
+ ([key, value]) =>
55
+ `--${BOUNDARY}\r\nContent-Disposition: form-data; name="${key}"\r\n\r\n${value}\r\n`,
56
+ );
57
+ return {
58
+ body: `${parts.join("")}--${BOUNDARY}--\r\n`,
59
+ contentType: `multipart/form-data; boundary=${BOUNDARY}`,
60
+ };
61
+ }
62
+
63
+ function asMap(body: unknown): Record<string, string | number | boolean> {
64
+ return (body ?? {}) as Record<string, string | number | boolean>;
65
+ }
66
+
67
+ function headersOf(params: RequestParams, contentType: string | undefined): Record<string, string> {
68
+ const headers: Record<string, string> = {};
69
+ for (const [key, value] of Object.entries(params.headers ?? {})) headers[key] = String(value);
70
+ if (contentType && !hasHeader(headers, "content-type")) headers["Content-Type"] = contentType;
71
+ const auth = authorization(params);
72
+ if (auth && !hasHeader(headers, "authorization")) headers.Authorization = auth;
73
+ return headers;
74
+ }
75
+
76
+ function authorization(params: RequestParams): string | undefined {
77
+ if (params.bearer) return `Bearer ${params.bearer}`;
78
+ if (!params.basic) return undefined;
79
+ return `Basic ${base64(`${params.basic.user}:${params.basic.pass}`)}`;
80
+ }
81
+
82
+ function withQuery(url: string, query: RequestParams["query"]): string {
83
+ const encoded = query ? encode(query) : "";
84
+ if (!encoded) return url;
85
+ return `${url}${url.includes("?") ? "&" : "?"}${encoded}`;
86
+ }
87
+
88
+ function encode(values: Record<string, string | number | boolean>): string {
89
+ const search = new URLSearchParams();
90
+ for (const [key, value] of Object.entries(values)) search.append(key, String(value));
91
+ return search.toString();
92
+ }
93
+
94
+ function hasHeader(headers: Record<string, string>, name: string): boolean {
95
+ return Object.keys(headers).some((key) => key.toLowerCase() === name);
96
+ }
97
+
98
+ function base64(text: string): string {
99
+ return btoa(text);
100
+ }
101
+
102
+ /** Resolve a relative path against `config.baseUrl`; absolute URLs pass through. */
103
+ function absoluteUrl(url: string, baseUrl: unknown): string {
104
+ if (typeof baseUrl !== "string" || baseUrl === "" || hasScheme(url)) return url;
105
+ return `${baseUrl.replace(/\/+$/, "")}/${url.replace(/^\/+/, "")}`;
106
+ }
107
+
108
+ function hasScheme(url: string): boolean {
109
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(url);
110
+ }
@@ -0,0 +1,36 @@
1
+ import { type ActionDefinition, arg, defineAction } from "@venn-lang/sdk";
2
+ import { t } from "@venn-lang/types";
3
+ import { HttpClientPort } from "../port/index.js";
4
+ import { buildRequest } from "./build-request.js";
5
+ import type { RequestParams } from "./request.types.js";
6
+ import { requestParams } from "./request-params.js";
7
+
8
+ /**
9
+ * Build the action for one HTTP verb.
10
+ *
11
+ * Every verb has the same shape: the URL is the single positional argument and
12
+ * everything else rides in the options map, so `http.get` and `http.post` read
13
+ * alike at the call site.
14
+ *
15
+ * @param config.name The verb as a flow writes it, such as `get`.
16
+ * @param config.method The method put on the wire, such as `GET`.
17
+ */
18
+ export function httpAction(config: { name: string; method: string }): ActionDefinition {
19
+ return defineAction({
20
+ name: config.name,
21
+ doc: `HTTP ${config.method} request.`,
22
+ params: requestParams.optional(),
23
+ args: [arg("url", t.string, "Where to send it. Relative paths join the configured base URL.")],
24
+ result: t.ref("http.Response"),
25
+ run: (ctx, input) =>
26
+ ctx.port(HttpClientPort).request(
27
+ buildRequest({
28
+ method: config.method,
29
+ url: input.args[0],
30
+ params: (input.params ?? {}) as RequestParams,
31
+ baseUrl: ctx.config.baseUrl,
32
+ signal: ctx.signal,
33
+ }),
34
+ ),
35
+ });
36
+ }
@@ -0,0 +1,18 @@
1
+ import type { ActionDefinition } from "@venn-lang/sdk";
2
+ import { onAction } from "../server/on-action.js";
3
+ import { serveAction } from "../server/serve-action.js";
4
+ import { httpAction } from "./http-action.js";
5
+
6
+ /** The http namespace's verbs. Adding one is a single line here. */
7
+ export const httpActions: ActionDefinition[] = [
8
+ httpAction({ name: "get", method: "GET" }),
9
+ httpAction({ name: "post", method: "POST" }),
10
+ httpAction({ name: "put", method: "PUT" }),
11
+ httpAction({ name: "patch", method: "PATCH" }),
12
+ httpAction({ name: "delete", method: "DELETE" }),
13
+ httpAction({ name: "head", method: "HEAD" }),
14
+ httpAction({ name: "options", method: "OPTIONS" }),
15
+ // Serving, not requesting: a server stays, and the requests arrive later.
16
+ serveAction(),
17
+ onAction(),
18
+ ];
@@ -0,0 +1,32 @@
1
+ import { type ZodType, z } from "@venn-lang/sdk";
2
+ import type { RequestParams } from "./request.types.js";
3
+
4
+ const scalar = z.union([z.string(), z.number(), z.boolean()]);
5
+ const scalarMap = z.record(z.string(), scalar);
6
+
7
+ /**
8
+ * Validation for {@link RequestParams}, the options map every http verb accepts.
9
+ *
10
+ * Each key carries its own `.describe()` because the editor reads this schema to
11
+ * offer the keys, document them and reject the ones that do not exist. Keeping
12
+ * the wording here keeps it beside the rule it describes.
13
+ */
14
+ export const requestParams: ZodType<RequestParams> = z.object({
15
+ headers: scalarMap
16
+ .optional()
17
+ .describe("Extra headers. Anything you set here wins over what Venn would infer."),
18
+ query: scalarMap.optional().describe("Appended to the URL as a query string, encoded for you."),
19
+ body: z
20
+ .unknown()
21
+ .optional()
22
+ .describe("What to send. A map becomes JSON; a string is sent as written."),
23
+ encode: z
24
+ .enum(["json", "form", "multipart", "raw"])
25
+ .optional()
26
+ .describe("How to serialise `body`. Defaults to `json` for a map, `raw` for a string."),
27
+ bearer: z.string().optional().describe("Shorthand for `Authorization: Bearer …`."),
28
+ basic: z
29
+ .object({ user: z.string(), pass: z.string() })
30
+ .optional()
31
+ .describe("Shorthand for HTTP basic auth."),
32
+ });
@@ -0,0 +1,28 @@
1
+ /** A value that may appear in a header, a query string or a form field. */
2
+ export type Scalar = string | number | boolean;
3
+
4
+ /** How `body` is put on the wire. */
5
+ export type Encoding = "json" | "form" | "multipart" | "raw";
6
+
7
+ /**
8
+ * Everything a request may carry. One map keeps the call site flat:
9
+ * `http.post "/token" { body: { … }, encode: "form", bearer: token }`.
10
+ *
11
+ * There is a single payload key, `body`, as `fetch` and `axios` have. Separate
12
+ * `json:` and `form:` keys would make one idea look like two; the wire format is
13
+ * {@link Encoding}'s job instead.
14
+ */
15
+ export interface RequestParams {
16
+ /** Extra headers. Anything set here wins over what Venn would infer. */
17
+ headers?: Record<string, Scalar>;
18
+ /** Appended as a query string, encoded for you. */
19
+ query?: Record<string, Scalar>;
20
+ /** What to send. A map becomes JSON; a string is sent as written. */
21
+ body?: unknown;
22
+ /** Override the serialisation of {@link body}. */
23
+ encode?: Encoding;
24
+ /** Shorthand for `Authorization: Bearer …`. */
25
+ bearer?: string;
26
+ /** Shorthand for HTTP basic auth. */
27
+ basic?: { user: string; pass: string };
28
+ }
@@ -0,0 +1,37 @@
1
+ import type { HttpClient, HttpResponse } from "../port/index.js";
2
+
3
+ /**
4
+ * A 200 response with a small JSON body, for tests that only care about a field
5
+ * or two.
6
+ *
7
+ * @param overrides Fields to replace on the default response.
8
+ * @returns A complete {@link HttpResponse}.
9
+ */
10
+ export function okResponse(overrides: Partial<HttpResponse> = {}): HttpResponse {
11
+ return {
12
+ status: 200,
13
+ ok: true,
14
+ headers: {},
15
+ body: '{"ok":true}',
16
+ json: { ok: true },
17
+ time: 0,
18
+ ...overrides,
19
+ };
20
+ }
21
+
22
+ /**
23
+ * The double: answers from a table keyed by the request's full URL.
24
+ *
25
+ * A URL with no entry gets {@link okResponse}, so a test only has to name the
26
+ * responses it cares about. Never touches the network.
27
+ *
28
+ * @param args.responses Canned responses, keyed by the URL the flow requests.
29
+ */
30
+ export function createFakeClient(
31
+ args: { responses?: Record<string, HttpResponse> } = {},
32
+ ): HttpClient {
33
+ const responses = args.responses ?? {};
34
+ return {
35
+ request: async (req) => responses[req.url] ?? okResponse(),
36
+ };
37
+ }
@@ -0,0 +1,59 @@
1
+ import type { HttpClient, HttpResponse } from "../port/index.js";
2
+
3
+ /**
4
+ * The real client, over the global `fetch`. Requires the `net` capability.
5
+ *
6
+ * The body is always read as text and parsed afterwards, so a reply that claims
7
+ * JSON but is not still arrives whole in `body` instead of throwing.
8
+ */
9
+ export function createFetchClient(): HttpClient {
10
+ return {
11
+ async request(req): Promise<HttpResponse> {
12
+ const response = await fetch(req.url, {
13
+ method: req.method,
14
+ headers: req.headers,
15
+ body: req.body,
16
+ signal: req.signal,
17
+ });
18
+ const body = await response.text();
19
+ return mapResponse({
20
+ status: response.status,
21
+ ok: response.ok,
22
+ headers: response.headers,
23
+ body,
24
+ });
25
+ },
26
+ };
27
+ }
28
+
29
+ function mapResponse(args: {
30
+ status: number;
31
+ ok: boolean;
32
+ headers: Headers;
33
+ body: string;
34
+ }): HttpResponse {
35
+ return {
36
+ status: args.status,
37
+ ok: args.ok,
38
+ headers: toObject(args.headers),
39
+ body: args.body,
40
+ json: tryJson(args.body),
41
+ time: 0,
42
+ };
43
+ }
44
+
45
+ function toObject(headers: Headers): Record<string, string> {
46
+ const out: Record<string, string> = {};
47
+ headers.forEach((value, key) => {
48
+ out[key] = value;
49
+ });
50
+ return out;
51
+ }
52
+
53
+ function tryJson(body: string): unknown {
54
+ try {
55
+ return JSON.parse(body);
56
+ } catch {
57
+ return undefined;
58
+ }
59
+ }
@@ -0,0 +1,2 @@
1
+ export { createFakeClient, okResponse } from "./fake-client.js";
2
+ export { createFetchClient } from "./fetch-client.js";
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The `http` plugin: request verbs, a server, and the types they trade in.
3
+ *
4
+ * Every byte on the wire crosses the `HttpClient` or `HttpServer` port, so a
5
+ * flow runs unchanged against a real socket or against the in-memory double.
6
+ */
7
+
8
+ export * from "./clients/index.js";
9
+ export { httpPlugin, httpPlugin as default } from "./plugin.js";
10
+ export * from "./port/index.js";
11
+ export * from "./server/index.js";
@@ -0,0 +1,29 @@
1
+ import { arg, defineMatcher, type MatcherDefinition, optionalArg } from "@venn-lang/sdk";
2
+ import { t } from "@venn-lang/types";
3
+
4
+ /**
5
+ * `expect res header "content-type"`: passes if the response carries the header.
6
+ *
7
+ * The lookup is case-insensitive, because header casing is whatever the far end
8
+ * happened to send.
9
+ */
10
+ export const header: MatcherDefinition = defineMatcher({
11
+ name: "header",
12
+ args: [
13
+ arg("name", t.string, "Which header to read."),
14
+ optionalArg(
15
+ "value",
16
+ t.string,
17
+ "What it should say. Omit it to check the header is merely present.",
18
+ ),
19
+ ],
20
+ appliesTo: "Response",
21
+ test: ({ subject, args }) => hasHeader(subject, String(args[0])),
22
+ message: ({ args }) => `expected the response to carry header "${String(args[0])}"`,
23
+ });
24
+
25
+ function hasHeader(subject: unknown, name: string): boolean {
26
+ const headers = (subject as { headers?: Record<string, string> }).headers ?? {};
27
+ const target = name.toLowerCase();
28
+ return Object.keys(headers).some((key) => key.toLowerCase() === target);
29
+ }
@@ -0,0 +1,4 @@
1
+ import type { MatcherDefinition } from "@venn-lang/sdk";
2
+ import { header } from "./header.js";
3
+
4
+ export const httpMatchers: MatcherDefinition[] = [header];