@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.
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/dist/index.d.ts +213 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +577 -0
- package/dist/index.js.map +1 -0
- package/dist/node.d.mts +63 -0
- package/dist/node.d.mts.map +1 -0
- package/dist/node.mjs +146 -0
- package/dist/node.mjs.map +1 -0
- package/package.json +61 -0
- package/src/actions/build-request.ts +110 -0
- package/src/actions/http-action.ts +36 -0
- package/src/actions/index.ts +18 -0
- package/src/actions/request-params.ts +32 -0
- package/src/actions/request.types.ts +28 -0
- package/src/clients/fake-client.ts +37 -0
- package/src/clients/fetch-client.ts +59 -0
- package/src/clients/index.ts +2 -0
- package/src/index.ts +11 -0
- package/src/matchers/header.ts +29 -0
- package/src/matchers/index.ts +4 -0
- package/src/node.ts +6 -0
- package/src/plugin.ts +22 -0
- package/src/port/http-client.port.ts +15 -0
- package/src/port/http-client.types.ts +29 -0
- package/src/port/index.ts +2 -0
- package/src/server/http-server.errors.ts +36 -0
- package/src/server/http-server.port.ts +15 -0
- package/src/server/http-server.types.ts +39 -0
- package/src/server/index.ts +12 -0
- package/src/server/memory-server.ts +78 -0
- package/src/server/node-server.ts +122 -0
- package/src/server/on-action.ts +42 -0
- package/src/server/serve-action.ts +99 -0
- package/src/types.ts +51 -0
package/src/node.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The node-only corner of `@venn-lang/http`. Everything reachable from here touches
|
|
3
|
+
* `node:*`, which is why it sits behind its own subpath: the package's main
|
|
4
|
+
* entry stays platform-neutral and loads in a Web Worker.
|
|
5
|
+
*/
|
|
6
|
+
export { createNodeServer, type NodeHttpServer } from "./server/node-server.js";
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { definePlugin, type PluginDefinition } from "@venn-lang/sdk";
|
|
2
|
+
import { httpActions } from "./actions/index.js";
|
|
3
|
+
import { httpMatchers } from "./matchers/index.js";
|
|
4
|
+
import { httpTypeDefs } from "./types.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The `http` namespace: the request verbs, `http.serve`/`http.on`, the `header`
|
|
8
|
+
* matcher and the types they trade in.
|
|
9
|
+
*
|
|
10
|
+
* Requires the `net` capability, so a host without it refuses the plugin at load
|
|
11
|
+
* time rather than failing mid-flow. The compiler treats it exactly as it treats
|
|
12
|
+
* a third-party plugin.
|
|
13
|
+
*/
|
|
14
|
+
export const httpPlugin: PluginDefinition = definePlugin({
|
|
15
|
+
name: "venn/http",
|
|
16
|
+
version: "0.0.0",
|
|
17
|
+
namespace: "http",
|
|
18
|
+
requires: ["net"],
|
|
19
|
+
actions: httpActions,
|
|
20
|
+
matchers: httpMatchers,
|
|
21
|
+
typeDefs: httpTypeDefs,
|
|
22
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Port } from "@venn-lang/contracts";
|
|
2
|
+
import type { HttpClient } from "./http-client.types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The port descriptor an http verb resolves through `ctx.port(...)`.
|
|
6
|
+
*
|
|
7
|
+
* Declares the `net` capability, so a host that cannot open sockets refuses the
|
|
8
|
+
* binding at load time with a readable diagnostic.
|
|
9
|
+
*/
|
|
10
|
+
export const HttpClientPort: Port<HttpClient> = {
|
|
11
|
+
id: "venn.port.http-client",
|
|
12
|
+
version: 1,
|
|
13
|
+
requires: ["net"],
|
|
14
|
+
methods: ["request"],
|
|
15
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** One request, ready to send: the verb, the absolute URL and the payload. */
|
|
2
|
+
export interface HttpRequest {
|
|
3
|
+
method: string;
|
|
4
|
+
url: string;
|
|
5
|
+
headers?: Record<string, string>;
|
|
6
|
+
body?: string;
|
|
7
|
+
/** Aborted when a `race` this request runs inside has already been won. */
|
|
8
|
+
signal?: AbortSignal;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** What an http verb hands back, and what `res` holds in a flow. */
|
|
12
|
+
export interface HttpResponse {
|
|
13
|
+
status: number;
|
|
14
|
+
ok: boolean;
|
|
15
|
+
headers: Record<string, string>;
|
|
16
|
+
body: string;
|
|
17
|
+
json: unknown;
|
|
18
|
+
time: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Sending one request and reading the reply.
|
|
23
|
+
*
|
|
24
|
+
* Two implementations: `createFetchClient` over a real socket and
|
|
25
|
+
* `createFakeClient` for tests. The conformance suite is what says they agree.
|
|
26
|
+
*/
|
|
27
|
+
export interface HttpClient {
|
|
28
|
+
request(req: HttpRequest): Promise<HttpResponse>;
|
|
29
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { VennError } from "@venn-lang/contracts";
|
|
2
|
+
|
|
3
|
+
/** VN7020: the address the flow asked for is already taken by something else. */
|
|
4
|
+
export function portInUse(args: { port: number; host: string }): VennError {
|
|
5
|
+
return new VennError({
|
|
6
|
+
code: "VN7020",
|
|
7
|
+
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.`,
|
|
8
|
+
detail: { port: args.port, host: args.host, cause: "EADDRINUSE" },
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** VN7021: the socket refused to bind for any other reason. */
|
|
13
|
+
export function listenFailed(args: { port: number; host: string; cause: string }): VennError {
|
|
14
|
+
return new VennError({
|
|
15
|
+
code: "VN7021",
|
|
16
|
+
message: `Could not listen on ${args.host}:${args.port} — ${args.cause}.`,
|
|
17
|
+
detail: { port: args.port, host: args.host, cause: args.cause },
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Whatever the socket threw, as a Venn error: VN7020 for `EADDRINUSE`, VN7021
|
|
23
|
+
* for anything else.
|
|
24
|
+
*
|
|
25
|
+
* The translation lives at the producer so no caller has to read a `node:net`
|
|
26
|
+
* errno to know what went wrong.
|
|
27
|
+
*/
|
|
28
|
+
export function asListenError(args: { port: number; host: string; error: unknown }): VennError {
|
|
29
|
+
const error = args.error as { code?: string; message?: string } | undefined;
|
|
30
|
+
if (error?.code === "EADDRINUSE") return portInUse({ port: args.port, host: args.host });
|
|
31
|
+
return listenFailed({
|
|
32
|
+
port: args.port,
|
|
33
|
+
host: args.host,
|
|
34
|
+
cause: error?.message ?? String(args.error),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Port } from "@venn-lang/contracts";
|
|
2
|
+
import type { HttpServer } from "./http-server.types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The port descriptor `http.serve` resolves through `ctx.port(...)`.
|
|
6
|
+
*
|
|
7
|
+
* Declares the `net` capability, so a host that cannot bind a socket refuses the
|
|
8
|
+
* binding at load time rather than mid-flow.
|
|
9
|
+
*/
|
|
10
|
+
export const HttpServerPort: Port<HttpServer> = {
|
|
11
|
+
id: "venn.port.http-server",
|
|
12
|
+
version: 1,
|
|
13
|
+
requires: ["net"],
|
|
14
|
+
methods: ["listen"],
|
|
15
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** One request a server received, as the language sees it. */
|
|
2
|
+
export interface ServerRequest {
|
|
3
|
+
method: string;
|
|
4
|
+
/** The path with its query string, exactly as it arrived. */
|
|
5
|
+
url: string;
|
|
6
|
+
headers: Record<string, string>;
|
|
7
|
+
body: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** What a handler answers with. Every field has a sensible default. */
|
|
11
|
+
export 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
|
+
|
|
18
|
+
/** Called once per request. Returning nothing sends `204 No Content`. */
|
|
19
|
+
export type RequestHandler = (
|
|
20
|
+
request: ServerRequest,
|
|
21
|
+
) => ServerReply | undefined | Promise<ServerReply | undefined>;
|
|
22
|
+
|
|
23
|
+
/** A server that is listening, and how to stop it. */
|
|
24
|
+
export interface RunningServer {
|
|
25
|
+
/** The port it actually bound to. Asking for 0 gets one chosen for you. */
|
|
26
|
+
readonly port: number;
|
|
27
|
+
close(): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Accepting requests over HTTP.
|
|
32
|
+
*
|
|
33
|
+
* Two implementations: `createNodeServer` binds a real socket, and
|
|
34
|
+
* `createMemoryServer` keeps the handler in memory so a test can deliver a
|
|
35
|
+
* request without a network. The conformance suite is what says they agree.
|
|
36
|
+
*/
|
|
37
|
+
export interface HttpServer {
|
|
38
|
+
listen(args: { port: number; host?: string; handle: RequestHandler }): Promise<RunningServer>;
|
|
39
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { asListenError, listenFailed, portInUse } from "./http-server.errors.js";
|
|
2
|
+
export { HttpServerPort } from "./http-server.port.js";
|
|
3
|
+
export type {
|
|
4
|
+
HttpServer,
|
|
5
|
+
RequestHandler,
|
|
6
|
+
RunningServer,
|
|
7
|
+
ServerReply,
|
|
8
|
+
ServerRequest,
|
|
9
|
+
} from "./http-server.types.js";
|
|
10
|
+
export { createMemoryServer, type MemoryHttpServer, type MemoryServer } from "./memory-server.js";
|
|
11
|
+
export { onAction } from "./on-action.js";
|
|
12
|
+
export { type ServeHandle, serveAction } from "./serve-action.js";
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { portInUse } from "./http-server.errors.js";
|
|
2
|
+
import type {
|
|
3
|
+
HttpServer,
|
|
4
|
+
RequestHandler,
|
|
5
|
+
RunningServer,
|
|
6
|
+
ServerReply,
|
|
7
|
+
ServerRequest,
|
|
8
|
+
} from "./http-server.types.js";
|
|
9
|
+
|
|
10
|
+
/** Where the double starts handing out ports when a flow asks for any free one. */
|
|
11
|
+
const EPHEMERAL_START = 49152;
|
|
12
|
+
|
|
13
|
+
/** A server that is listening in memory, plus the way to knock on its door. */
|
|
14
|
+
export interface MemoryServer extends RunningServer {
|
|
15
|
+
/** Deliver a request as though it had arrived over the network. */
|
|
16
|
+
deliver(request: Partial<ServerRequest>): Promise<ServerReply>;
|
|
17
|
+
readonly closed: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The double factory, which keeps every server it started so a test can find it. */
|
|
21
|
+
export interface MemoryHttpServer extends HttpServer {
|
|
22
|
+
/** Every server started through this, newest last. */
|
|
23
|
+
readonly started: readonly MemoryServer[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The double: no socket, no network, no waiting.
|
|
28
|
+
*
|
|
29
|
+
* A test starts the flow that serves, hands it a request and reads the reply,
|
|
30
|
+
* running the same handler the real server would call. Ports are book-kept here
|
|
31
|
+
* rather than by the operating system, so tests beside each other never collide.
|
|
32
|
+
*/
|
|
33
|
+
export function createMemoryServer(): MemoryHttpServer {
|
|
34
|
+
const started: MemoryServer[] = [];
|
|
35
|
+
const bound = new Set<number>();
|
|
36
|
+
let next = EPHEMERAL_START;
|
|
37
|
+
return {
|
|
38
|
+
started,
|
|
39
|
+
// Binding the same port twice must fail here exactly as it would against a
|
|
40
|
+
// real socket, so the double tracks what it has handed out.
|
|
41
|
+
listen: async ({ port, host, handle }) => {
|
|
42
|
+
const chosen = port === 0 ? next++ : port;
|
|
43
|
+
if (bound.has(chosen)) throw portInUse({ port: chosen, host: host ?? "127.0.0.1" });
|
|
44
|
+
bound.add(chosen);
|
|
45
|
+
const server = memoryServer({ port: chosen, handle, release: () => bound.delete(chosen) });
|
|
46
|
+
started.push(server);
|
|
47
|
+
return server;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function memoryServer(args: {
|
|
53
|
+
port: number;
|
|
54
|
+
handle: RequestHandler;
|
|
55
|
+
release: () => void;
|
|
56
|
+
}): MemoryServer {
|
|
57
|
+
const state = { closed: false };
|
|
58
|
+
return {
|
|
59
|
+
port: args.port,
|
|
60
|
+
get closed() {
|
|
61
|
+
return state.closed;
|
|
62
|
+
},
|
|
63
|
+
close: async () => {
|
|
64
|
+
state.closed = true;
|
|
65
|
+
args.release();
|
|
66
|
+
},
|
|
67
|
+
deliver: async (request) => (await args.handle(fill(request))) ?? { status: 204 },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function fill(request: Partial<ServerRequest>): ServerRequest {
|
|
72
|
+
return {
|
|
73
|
+
method: request.method ?? "GET",
|
|
74
|
+
url: request.url ?? "/",
|
|
75
|
+
headers: request.headers ?? {},
|
|
76
|
+
body: request.body ?? "",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
+
import { asListenError } from "./http-server.errors.js";
|
|
3
|
+
import type {
|
|
4
|
+
HttpServer,
|
|
5
|
+
RequestHandler,
|
|
6
|
+
RunningServer,
|
|
7
|
+
ServerReply,
|
|
8
|
+
ServerRequest,
|
|
9
|
+
} from "./http-server.types.js";
|
|
10
|
+
|
|
11
|
+
/** A node HttpServer, plus the way to hang up everything it still has open. */
|
|
12
|
+
export interface NodeHttpServer extends HttpServer {
|
|
13
|
+
/**
|
|
14
|
+
* Close every server started here that is still listening.
|
|
15
|
+
*
|
|
16
|
+
* A process owns its sockets, so whoever owns the process (the CLI) needs one
|
|
17
|
+
* call to give them all back on the way out.
|
|
18
|
+
*/
|
|
19
|
+
closeAll(): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The real implementation: a bound socket, on the port the OS gave it.
|
|
24
|
+
*
|
|
25
|
+
* Only this file reaches for `node:http`, which is why it sits behind the
|
|
26
|
+
* `@venn-lang/http/node` subpath. The rest of the package stays platform-neutral and
|
|
27
|
+
* runs wherever the language runs, the editor's worker included.
|
|
28
|
+
*
|
|
29
|
+
* @throws VN7020 if the port is taken, VN7021 if the socket refuses to bind.
|
|
30
|
+
*/
|
|
31
|
+
export function createNodeServer(): NodeHttpServer {
|
|
32
|
+
const open = new Set<RunningServer>();
|
|
33
|
+
return {
|
|
34
|
+
listen: async (args) => {
|
|
35
|
+
const server = await bind(args);
|
|
36
|
+
open.add(server);
|
|
37
|
+
return { port: server.port, close: () => forget(open, server) };
|
|
38
|
+
},
|
|
39
|
+
closeAll: async () => {
|
|
40
|
+
for (const server of [...open]) await forget(open, server);
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Closing is idempotent from the set's point of view: gone is gone. */
|
|
46
|
+
async function forget(open: Set<RunningServer>, server: RunningServer): Promise<void> {
|
|
47
|
+
open.delete(server);
|
|
48
|
+
await server.close();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function bind(args: {
|
|
52
|
+
port: number;
|
|
53
|
+
host?: string;
|
|
54
|
+
handle: RequestHandler;
|
|
55
|
+
}): Promise<RunningServer> {
|
|
56
|
+
const { port, host, handle } = args;
|
|
57
|
+
return new Promise<RunningServer>((resolve, reject) => {
|
|
58
|
+
const at = host ?? "127.0.0.1";
|
|
59
|
+
const server = createServer((request, response) => {
|
|
60
|
+
void answer(request, response, handle);
|
|
61
|
+
});
|
|
62
|
+
// A busy port is the flow's problem, not a `node:net` errno: translate here.
|
|
63
|
+
server.once("error", (error) => reject(asListenError({ port, host: at, error })));
|
|
64
|
+
server.listen(port, at, () => resolve(running(server)));
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function running(server: ReturnType<typeof createServer>): RunningServer {
|
|
69
|
+
const address = server.address();
|
|
70
|
+
return {
|
|
71
|
+
port: typeof address === "object" && address ? address.port : 0,
|
|
72
|
+
close: () =>
|
|
73
|
+
new Promise<void>((resolve) => {
|
|
74
|
+
server.closeAllConnections?.();
|
|
75
|
+
server.close(() => resolve());
|
|
76
|
+
}),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A handler that throws becomes a 500: one bad request must not kill the server. */
|
|
81
|
+
async function answer(
|
|
82
|
+
request: IncomingMessage,
|
|
83
|
+
response: ServerResponse,
|
|
84
|
+
handle: RequestHandler,
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
try {
|
|
87
|
+
send(response, (await handle(await read(request))) ?? { status: 204 });
|
|
88
|
+
} catch (error) {
|
|
89
|
+
send(response, { status: 500, body: { error: String((error as Error)?.message ?? error) } });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function send(response: ServerResponse, reply: ServerReply): void {
|
|
94
|
+
const json = typeof reply.body !== "string" && reply.body !== undefined;
|
|
95
|
+
const headers = { ...(json ? { "content-type": "application/json" } : {}), ...reply.headers };
|
|
96
|
+
response.writeHead(reply.status ?? 200, headers);
|
|
97
|
+
response.end(body(reply.body, json));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function body(value: unknown, json: boolean): string {
|
|
101
|
+
if (value === undefined) return "";
|
|
102
|
+
return json ? JSON.stringify(value) : String(value);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function read(request: IncomingMessage): Promise<ServerRequest> {
|
|
106
|
+
const chunks: Buffer[] = [];
|
|
107
|
+
for await (const chunk of request) chunks.push(chunk as Buffer);
|
|
108
|
+
return {
|
|
109
|
+
method: request.method ?? "GET",
|
|
110
|
+
url: request.url ?? "/",
|
|
111
|
+
headers: plain(request.headers),
|
|
112
|
+
body: Buffer.concat(chunks).toString("utf8"),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function plain(headers: IncomingMessage["headers"]): Record<string, string> {
|
|
117
|
+
const out: Record<string, string> = {};
|
|
118
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
119
|
+
if (value !== undefined) out[key] = Array.isArray(value) ? value.join(", ") : value;
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type ActionDefinition, arg, defineAction } from "@venn-lang/sdk";
|
|
2
|
+
import { t } from "@venn-lang/types";
|
|
3
|
+
import type { ServeHandle } from "./serve-action.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `http.on server handler`: say what the server answers with.
|
|
7
|
+
*
|
|
8
|
+
* The handler is an ordinary `fn`, so everything the language already does
|
|
9
|
+
* applies inside it: it can call any verb, and it waits for what it reaches for
|
|
10
|
+
* without saying so. Whatever it returns becomes the reply. Calling `http.on`
|
|
11
|
+
* again replaces the handler.
|
|
12
|
+
*/
|
|
13
|
+
export function onAction(): ActionDefinition {
|
|
14
|
+
return defineAction({
|
|
15
|
+
name: "on",
|
|
16
|
+
doc: "Answer this server's requests with a function. `fn (req) => { … }` is the reply.",
|
|
17
|
+
// The callback type is what gives `req` its shape, so `http.on api req => …`
|
|
18
|
+
// needs no annotation in the flow.
|
|
19
|
+
args: [
|
|
20
|
+
arg("server", t.ref("http.Server"), "The handle `http.serve` gave back."),
|
|
21
|
+
arg(
|
|
22
|
+
"handler",
|
|
23
|
+
t.callback([t.ref("http.Request")], t.dynamic, 1),
|
|
24
|
+
"Called for every request. What it returns is the reply.",
|
|
25
|
+
),
|
|
26
|
+
],
|
|
27
|
+
result: t.void,
|
|
28
|
+
run: (ctx, input) => {
|
|
29
|
+
const server = asServer(input.args[0]);
|
|
30
|
+
const handler = input.args[1];
|
|
31
|
+
if (!server) throw new Error("`http.on` needs a server, as `http.serve` returns.");
|
|
32
|
+
if (handler === undefined) throw new Error("`http.on` needs a function to answer with.");
|
|
33
|
+
server.onRequest((request) => ctx.invoke(handler, [request]));
|
|
34
|
+
return undefined;
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function asServer(value: unknown): ServeHandle | undefined {
|
|
40
|
+
if (typeof value !== "object" || value === null) return undefined;
|
|
41
|
+
return (value as ServeHandle).kind === "http-server" ? (value as ServeHandle) : undefined;
|
|
42
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { type ActionDefinition, defineAction, z } from "@venn-lang/sdk";
|
|
2
|
+
import { t } from "@venn-lang/types";
|
|
3
|
+
import { HttpServerPort } from "./http-server.port.js";
|
|
4
|
+
import type { ServerReply, ServerRequest } from "./http-server.types.js";
|
|
5
|
+
|
|
6
|
+
const serveParams = z.object({
|
|
7
|
+
port: z.number().optional().describe("Which port to bind. 0 asks for any free one."),
|
|
8
|
+
host: z.string().optional().describe("Which interface to bind. Defaults to 127.0.0.1."),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The value `http.serve` hands back, seen by flows as `http.Server`.
|
|
13
|
+
*
|
|
14
|
+
* A server is not a request-response verb: it stays, and the requests arrive
|
|
15
|
+
* afterwards. So the verb returns something the program holds on to: the port it
|
|
16
|
+
* got, and what it may do with it.
|
|
17
|
+
*/
|
|
18
|
+
export interface ServeHandle {
|
|
19
|
+
kind: "http-server";
|
|
20
|
+
port: number;
|
|
21
|
+
/** Deliver one request to whatever `handle` was last given. Used by tests. */
|
|
22
|
+
deliver: (request: Partial<ServerRequest>) => Promise<ServerReply>;
|
|
23
|
+
close: () => Promise<void>;
|
|
24
|
+
/** Replace the handler. `http.on` is how a flow reaches this. */
|
|
25
|
+
onRequest: (handle: (request: ServerRequest) => unknown) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `let api = http.serve { port: 8080 }`: start listening.
|
|
30
|
+
*
|
|
31
|
+
* The handler starts as a 404 and `http.on` replaces it, so a request arriving
|
|
32
|
+
* before the flow has said what to do with it gets an answer instead of hanging.
|
|
33
|
+
*/
|
|
34
|
+
export function serveAction(): ActionDefinition {
|
|
35
|
+
return defineAction({
|
|
36
|
+
name: "serve",
|
|
37
|
+
doc: "Start an HTTP server and hand back a handle. `http.on` says what to answer.",
|
|
38
|
+
params: serveParams.optional(),
|
|
39
|
+
result: t.ref("http.Server"),
|
|
40
|
+
run: async (ctx, input) => {
|
|
41
|
+
const params = (input.params ?? {}) as { port?: number; host?: string };
|
|
42
|
+
const state: HandlerState = { handle: notFound };
|
|
43
|
+
const running = await ctx.port(HttpServerPort).listen({
|
|
44
|
+
port: params.port ?? 0,
|
|
45
|
+
host: params.host,
|
|
46
|
+
handle: (request: ServerRequest) => state.handle(request),
|
|
47
|
+
});
|
|
48
|
+
return handle(running, state);
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface HandlerState {
|
|
54
|
+
handle: (request: ServerRequest) => ServerReply | Promise<ServerReply>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function handle(
|
|
58
|
+
running: { port: number; close: () => Promise<void> },
|
|
59
|
+
state: HandlerState,
|
|
60
|
+
): ServeHandle {
|
|
61
|
+
return {
|
|
62
|
+
kind: "http-server",
|
|
63
|
+
port: running.port,
|
|
64
|
+
close: () => running.close(),
|
|
65
|
+
deliver: async (request) => state.handle(fill(request)),
|
|
66
|
+
onRequest: (given) => {
|
|
67
|
+
state.handle = async (request) => asReply(await given(request));
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Whatever the flow answered, as a reply. A map with a `status` is taken at its
|
|
74
|
+
* word; anything else is the body, so `=> { ok: true }` does the obvious thing.
|
|
75
|
+
*/
|
|
76
|
+
function asReply(value: unknown): ServerReply {
|
|
77
|
+
if (value === undefined || value === null) return { status: 204 };
|
|
78
|
+
if (isReplyShape(value)) return value as ServerReply;
|
|
79
|
+
return { status: 200, body: value };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isReplyShape(value: unknown): boolean {
|
|
83
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
84
|
+
const shape = value as Record<string, unknown>;
|
|
85
|
+
return "status" in shape || "headers" in shape || "body" in shape;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function notFound(): ServerReply {
|
|
89
|
+
return { status: 404, body: { error: "This server has no handler yet." } };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function fill(request: Partial<ServerRequest>): ServerRequest {
|
|
93
|
+
return {
|
|
94
|
+
method: request.method ?? "GET",
|
|
95
|
+
url: request.url ?? "/",
|
|
96
|
+
headers: request.headers ?? {},
|
|
97
|
+
body: request.body ?? "",
|
|
98
|
+
};
|
|
99
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type TypeSpec, t } from "@venn-lang/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The types the plugin publishes to flows: `http.Request`, `http.Reply`,
|
|
5
|
+
* `http.Server` and `http.Response`.
|
|
6
|
+
*
|
|
7
|
+
* They are data, not TypeScript, because the compiler and the editor read them
|
|
8
|
+
* without loading the plugin's implementation. Keep them in step by hand with
|
|
9
|
+
* `server/http-server.types.ts` and `port/http-client.types.ts`.
|
|
10
|
+
*/
|
|
11
|
+
export const httpTypeDefs: Readonly<Record<string, TypeSpec>> = {
|
|
12
|
+
/** One request, as the handler receives it. */
|
|
13
|
+
Request: t.record({
|
|
14
|
+
method: t.string,
|
|
15
|
+
url: t.string,
|
|
16
|
+
headers: t.map(t.string),
|
|
17
|
+
body: t.string,
|
|
18
|
+
}),
|
|
19
|
+
/** What a handler may answer with. Anything else is taken as the body. */
|
|
20
|
+
Reply: t.record(
|
|
21
|
+
{ status: t.number, headers: t.map(t.string), body: t.dynamic },
|
|
22
|
+
{ optional: ["status", "headers", "body"], open: true },
|
|
23
|
+
),
|
|
24
|
+
/**
|
|
25
|
+
* A bound socket: the port it got, and how to give it back.
|
|
26
|
+
*
|
|
27
|
+
* Attaching a handler is `http.on`, so the handle does not offer that itself.
|
|
28
|
+
* The type names only what a program may do with the value, which is why it is
|
|
29
|
+
* opaque with two visible members rather than fully opaque.
|
|
30
|
+
*/
|
|
31
|
+
Server: t.opaque("http.Server", { port: t.number, close: t.fn([], t.void) }),
|
|
32
|
+
/**
|
|
33
|
+
* One response, as the client verbs give it back.
|
|
34
|
+
*
|
|
35
|
+
* `body` is the raw text, always a string whatever came over the wire. `json`
|
|
36
|
+
* is that text parsed, and is the one field nothing can know the shape of: it
|
|
37
|
+
* is whatever the far end chose to send. Name a shape for it, as in
|
|
38
|
+
* `const price: Price = res.json`, and everything after reads as that shape.
|
|
39
|
+
*/
|
|
40
|
+
Response: t.record(
|
|
41
|
+
{
|
|
42
|
+
status: t.number,
|
|
43
|
+
ok: t.bool,
|
|
44
|
+
headers: t.map(t.string),
|
|
45
|
+
body: t.string,
|
|
46
|
+
json: t.dynamic,
|
|
47
|
+
time: t.number,
|
|
48
|
+
},
|
|
49
|
+
{ open: true },
|
|
50
|
+
),
|
|
51
|
+
};
|