From 6b30da86fbb3945c15552ac216b505d66556fd35 Mon Sep 17 00:00:00 2001 From: HalfSweet Date: Wed, 19 Aug 2026 23:39:06 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(net)!:=20network=20modules=20=E2=80=94?= =?UTF-8?q?=20contracts,=20SDK,=20ServicePump/headless,=20sim=20and=20brow?= =?UTF-8?q?ser=20hosts,=20Rust=20core=20(stack=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract and guest side of the network stack (stacked PR A of three; B adds the portable C core and the POSIX/TLS drivers, C the ESP-IDF host): - contracts/spec/{net,ws,httpd}.ts: the net spec at v2 (ops 6–9 readInto / limits / write / endBody, op 2 retired, streaming headers / readable / end / error events, the shared error vocabulary with the tls_* codes) and the new ws and httpd module specs; gen-rust emits net/ws/httpd, the new gen-c emits engine/net/include/pocketjs/net/spec.h (the generated header rides here, the core that consumes it lands in B); tests/contract.ts byte-compares every mirror. - @pocketjs/framework/net is a support module (NetworkError, AbortController/AbortSignal, URL, getNetworkLimits, shared types); net/http (fetch, Headers, Request, Response, BodyStream, serve) and net/websocket (connect) carry the modules. A per-module guest binding drains one poll per tick from the framework service pump, settles Promises inside that pump and copies bodies through readInto. @pocketjs/framework/headless runs the frame transaction without a UI. - contracts/spec/platforms.ts replaces net.http with the role-split network.http.client(.tls) / network.http.server(.tls) / network.websocket.client(.tls) ids; no stock target advertises them. - hosts/sim/{net,httpd,ws}.ts are the deterministic hosts; hosts/web/net.js moves to the streaming v2 contract; engine/crates/pocket-net is rewritten to the v2 boundary over an HttpClientBackend. BREAKING CHANGE: `@pocketjs/framework/net` no longer exports fetch / NetError / PocketResponse — import fetch from `@pocketjs/framework/net/http` (streaming Response, NetworkError); the capability id `net.http` is replaced by `network.http.client` and its siblings. --- contracts/spec/gen-c.ts | 232 ++++ contracts/spec/gen-rust.ts | 173 ++- contracts/spec/httpd.ts | 204 ++++ contracts/spec/net.ts | 260 ++++- contracts/spec/platforms.ts | 23 +- contracts/spec/ws.ts | 195 ++++ docs/NET.md | 255 +++-- docs/RUNTIMES.md | 2 +- engine/core/src/spec.rs | 151 ++- engine/crates/pocket-net/src/lib.rs | 1398 +++++++++++++++-------- engine/net/include/pocketjs/net/spec.h | 161 +++ framework/compiler/subpaths.ts | 5 +- framework/src/headless.ts | 39 + framework/src/net-api.ts | 332 ------ framework/src/net/abort.ts | 87 ++ framework/src/net/binding.ts | 204 ++++ framework/src/net/body.ts | 613 ++++++++++ framework/src/net/errors.ts | 74 ++ framework/src/net/http.ts | 1458 ++++++++++++++++++++++++ framework/src/net/index.ts | 42 + framework/src/net/types.ts | 36 + framework/src/net/url.ts | Bin 0 -> 9954 bytes framework/src/net/websocket.ts | 463 ++++++++ hosts/sim/httpd.ts | 416 +++++++ hosts/sim/net.ts | 314 +++-- hosts/sim/sim.ts | 3 + hosts/sim/ws.ts | 308 +++++ hosts/web/net.js | 449 +++++--- package.json | 17 +- site/content/docs/concepts.md | 18 +- site/content/docs/net.md | 172 +-- tests/contract.ts | 11 + tests/net-httpd.test.ts | 208 ++++ tests/net-web.test.js | 54 +- tests/net-websocket.test.ts | 232 ++++ tests/net.test.ts | 341 ++++-- tools/test.ts | 2 + 37 files changed, 7543 insertions(+), 1409 deletions(-) create mode 100644 contracts/spec/gen-c.ts create mode 100644 contracts/spec/httpd.ts create mode 100644 contracts/spec/ws.ts create mode 100644 engine/net/include/pocketjs/net/spec.h create mode 100644 framework/src/headless.ts delete mode 100644 framework/src/net-api.ts create mode 100644 framework/src/net/abort.ts create mode 100644 framework/src/net/binding.ts create mode 100644 framework/src/net/body.ts create mode 100644 framework/src/net/errors.ts create mode 100644 framework/src/net/http.ts create mode 100644 framework/src/net/index.ts create mode 100644 framework/src/net/types.ts create mode 100644 framework/src/net/url.ts create mode 100644 framework/src/net/websocket.ts create mode 100644 hosts/sim/httpd.ts create mode 100644 hosts/sim/ws.ts create mode 100644 tests/net-httpd.test.ts create mode 100644 tests/net-websocket.test.ts diff --git a/contracts/spec/gen-c.ts b/contracts/spec/gen-c.ts new file mode 100644 index 00000000..7af0ca23 --- /dev/null +++ b/contracts/spec/gen-c.ts @@ -0,0 +1,232 @@ +// Deterministic codegen: contracts/spec/{net,ws,httpd}.ts -> +// engine/net/include/pocketjs/net/spec.h — the C mirror of the network +// module boundaries consumed by the portable C core (engine/net) and every C +// host that mounts `globalThis.net` / `ws` / `httpd`. +// +// Run from PocketJS/: bun contracts/spec/gen-c.ts (or `bun run gen`) +// +// tests/contract.ts imports generateC() and byte-compares its output against +// the committed header, so the generated file can never drift from the spec. +// Keep this generator deterministic (insertion order only, no dates/env). + +import { + HTTPD_DEFAULT_BODY_IDLE_MS, + HTTPD_DEFAULT_CLOSE_MS, + HTTPD_DEFAULT_HANDLER_MS, + HTTPD_DEFAULT_HEADER_MS, + HTTPD_DEFAULT_KEEP_ALIVE_MS, + HTTPD_DEFAULT_REQUEST_QUEUE_BYTES, + HTTPD_EVENT, + HTTPD_MAX_BACKLOG, + HTTPD_MAX_CONNECTIONS, + HTTPD_MAX_EVENTS_PER_TICK, + HTTPD_MAX_HEADERS, + HTTPD_MAX_HEADER_BYTES, + HTTPD_MAX_INFLIGHT, + HTTPD_MAX_REQUEST_QUEUE_BYTES, + HTTPD_MAX_SEND_QUEUE_BYTES, + HTTPD_MAX_SERVERS, + HTTPD_MAX_TARGET_BYTES, + HTTPD_MAX_TICK_BYTES, + HTTPD_MAX_TIMEOUT_MS, + HTTPD_OP, + HTTPD_SEND_ACCEPTED, + HTTPD_SEND_BACKPRESSURE, + HTTPD_SEND_HIGH_WATER_BYTES, + HTTPD_SEND_INVALID, + HTTPD_SEND_INVALID_REQUEST, + HTTPD_SEND_LOW_WATER_BYTES, + HTTPD_SPEC_MAJOR, + HTTPD_SPEC_MINOR, +} from "./httpd.ts"; +import { + NET_DEFAULT_AGGREGATE_BYTES, + NET_DEFAULT_QUEUE_BYTES, + NET_DEFAULT_TIMEOUT_MS, + NET_ERROR, + NET_EVENT, + NET_MAX_AGGREGATE_BYTES, + NET_MAX_EVENTS_PER_TICK, + NET_MAX_HEADER_BYTES, + NET_MAX_HEADERS, + NET_MAX_INFLIGHT, + NET_MAX_QUEUE_BYTES, + NET_MAX_REDIRECTS, + NET_MAX_REQUEST_BYTES, + NET_MAX_TICK_BYTES, + NET_MAX_TIMEOUT_MS, + NET_METHODS_FORBIDDEN, + NET_OP, + NET_SPEC_MAJOR, + NET_SPEC_MINOR, + NET_TLS_MIN_VERSION, +} from "./net.ts"; +import { + WS_BLOB_KEY, + WS_CONTROL_PAYLOAD_MAX, + WS_DEFAULT_CLOSE_MS, + WS_DEFAULT_CONNECT_MS, + WS_EVENT, + WS_FORBIDDEN_HEADERS, + WS_MAX_CONNECT_MS, + WS_MAX_EVENTS_PER_TICK, + WS_MAX_HANDSHAKE_HEADERS, + WS_MAX_HANDSHAKE_HEADER_BYTES, + WS_MAX_MESSAGE_BYTES, + WS_MAX_RECEIVE_QUEUE_BYTES, + WS_MAX_RECEIVE_QUEUE_MESSAGES, + WS_MAX_SEND_QUEUE_BYTES, + WS_MAX_SOCKETS, + WS_MAX_TICK_BYTES, + WS_OP, + WS_OPCODE, + WS_SEND_ACCEPTED, + WS_SEND_ACCEPTED_HIGH_WATER, + WS_SEND_BACKPRESSURE, + WS_SEND_CLOSED, + WS_SEND_HIGH_WATER_BYTES, + WS_SEND_INVALID, + WS_SEND_LOW_WATER_BYTES, + WS_SPEC_MAJOR, + WS_SPEC_MINOR, +} from "./ws.ts"; + +/** camelCase -> SCREAMING_SNAKE_CASE. */ +function screaming(name: string): string { + return name.replace(/([A-Z])/g, "_$1").toUpperCase(); +} + +function cstr(s: string): string { + return JSON.stringify(s); +} + +export function generateC(): string { + const L: string[] = []; + const put = (s: string) => L.push(s); + + put("/* GENERATED — do not edit; run `bun contracts/spec/gen-c.ts`. */"); + put("/* C mirror of contracts/spec/{net,ws,httpd}.ts: the guest boundaries of the"); + put(" * network modules (`globalThis.net` / `ws` / `httpd`). Every value here is a"); + put(" * portable ceiling or a wire-visible constant; a host's limits() may only"); + put(" * tighten the ceilings. tests/contract.ts byte-compares this file. */"); + put("#ifndef POCKETJS_NET_SPEC_H"); + put("#define POCKETJS_NET_SPEC_H"); + put(""); + + // --- net ------------------------------------------------------------------- + put("/* --- net: HTTP Client (`globalThis.net`) --- */"); + put(`#define PNET_SPEC_MAJOR ${NET_SPEC_MAJOR}`); + put(`#define PNET_SPEC_MINOR ${NET_SPEC_MINOR}`); + for (const [name, v] of Object.entries(NET_OP)) { + put(`#define PNET_OP_${screaming(name)} ${v}`); + } + put(`#define PNET_MAX_INFLIGHT ${NET_MAX_INFLIGHT}`); + put(`#define PNET_MAX_REQUEST_BYTES ${NET_MAX_REQUEST_BYTES}`); + put(`#define PNET_DEFAULT_QUEUE_BYTES ${NET_DEFAULT_QUEUE_BYTES}`); + put(`#define PNET_MAX_QUEUE_BYTES ${NET_MAX_QUEUE_BYTES}`); + put(`#define PNET_DEFAULT_AGGREGATE_BYTES ${NET_DEFAULT_AGGREGATE_BYTES}`); + put(`#define PNET_MAX_AGGREGATE_BYTES ${NET_MAX_AGGREGATE_BYTES}`); + put(`#define PNET_MAX_EVENTS_PER_TICK ${NET_MAX_EVENTS_PER_TICK}`); + put(`#define PNET_MAX_TICK_BYTES ${NET_MAX_TICK_BYTES}`); + put(`#define PNET_MAX_HEADERS ${NET_MAX_HEADERS}`); + put(`#define PNET_MAX_HEADER_BYTES ${NET_MAX_HEADER_BYTES}`); + put(`#define PNET_DEFAULT_TIMEOUT_MS ${NET_DEFAULT_TIMEOUT_MS}`); + put(`#define PNET_MAX_TIMEOUT_MS ${NET_MAX_TIMEOUT_MS}`); + put(`#define PNET_MAX_REDIRECTS ${NET_MAX_REDIRECTS}`); + put(`#define PNET_TLS_MIN_VERSION ${cstr(NET_TLS_MIN_VERSION)}`); + put(`#define PNET_METHODS_FORBIDDEN_COUNT ${NET_METHODS_FORBIDDEN.length}`); + put( + `#define PNET_METHODS_FORBIDDEN { ${NET_METHODS_FORBIDDEN.map(cstr).join(", ")} }`, + ); + for (const [name, v] of Object.entries(NET_EVENT)) { + put(`#define PNET_EVENT_${screaming(name)} ${cstr(v)}`); + } + put("/* Error vocabulary shared by net, ws and httpd. */"); + for (const [name, v] of Object.entries(NET_ERROR)) { + put(`#define PNET_ERROR_${screaming(name)} ${cstr(v)}`); + } + put(""); + + // --- ws -------------------------------------------------------------------- + put("/* --- ws: WebSocket Client (`globalThis.ws`) --- */"); + put(`#define PWS_SPEC_MAJOR ${WS_SPEC_MAJOR}`); + put(`#define PWS_SPEC_MINOR ${WS_SPEC_MINOR}`); + for (const [name, v] of Object.entries(WS_OP)) { + put(`#define PWS_OP_${screaming(name)} ${v}`); + } + put(`#define PWS_SEND_ACCEPTED ${WS_SEND_ACCEPTED}`); + put(`#define PWS_SEND_ACCEPTED_HIGH_WATER ${WS_SEND_ACCEPTED_HIGH_WATER}`); + put(`#define PWS_SEND_CLOSED (${WS_SEND_CLOSED})`); + put(`#define PWS_SEND_BACKPRESSURE (${WS_SEND_BACKPRESSURE})`); + put(`#define PWS_SEND_INVALID (${WS_SEND_INVALID})`); + for (const [name, v] of Object.entries(WS_OPCODE)) { + put(`#define PWS_OPCODE_${screaming(name)} ${v}`); + } + for (const [name, v] of Object.entries(WS_EVENT)) { + put(`#define PWS_EVENT_${screaming(name)} ${cstr(v)}`); + } + put(`#define PWS_BLOB_KEY ${cstr(WS_BLOB_KEY)}`); + put(`#define PWS_FORBIDDEN_HEADERS_COUNT ${WS_FORBIDDEN_HEADERS.length}`); + put(`#define PWS_FORBIDDEN_HEADERS { ${WS_FORBIDDEN_HEADERS.map(cstr).join(", ")} }`); + put(`#define PWS_MAX_SOCKETS ${WS_MAX_SOCKETS}`); + put(`#define PWS_MAX_MESSAGE_BYTES ${WS_MAX_MESSAGE_BYTES}`); + put(`#define PWS_MAX_RECEIVE_QUEUE_BYTES ${WS_MAX_RECEIVE_QUEUE_BYTES}`); + put(`#define PWS_MAX_RECEIVE_QUEUE_MESSAGES ${WS_MAX_RECEIVE_QUEUE_MESSAGES}`); + put(`#define PWS_MAX_SEND_QUEUE_BYTES ${WS_MAX_SEND_QUEUE_BYTES}`); + put(`#define PWS_SEND_HIGH_WATER_BYTES ${WS_SEND_HIGH_WATER_BYTES}`); + put(`#define PWS_SEND_LOW_WATER_BYTES ${WS_SEND_LOW_WATER_BYTES}`); + put(`#define PWS_MAX_HANDSHAKE_HEADERS ${WS_MAX_HANDSHAKE_HEADERS}`); + put(`#define PWS_MAX_HANDSHAKE_HEADER_BYTES ${WS_MAX_HANDSHAKE_HEADER_BYTES}`); + put(`#define PWS_MAX_EVENTS_PER_TICK ${WS_MAX_EVENTS_PER_TICK}`); + put(`#define PWS_MAX_TICK_BYTES ${WS_MAX_TICK_BYTES}`); + put(`#define PWS_DEFAULT_CONNECT_MS ${WS_DEFAULT_CONNECT_MS}`); + put(`#define PWS_MAX_CONNECT_MS ${WS_MAX_CONNECT_MS}`); + put(`#define PWS_DEFAULT_CLOSE_MS ${WS_DEFAULT_CLOSE_MS}`); + put(`#define PWS_CONTROL_PAYLOAD_MAX ${WS_CONTROL_PAYLOAD_MAX}`); + put(""); + + // --- httpd ----------------------------------------------------------------- + put("/* --- httpd: HTTP Server (`globalThis.httpd`) --- */"); + put(`#define PHTTPD_SPEC_MAJOR ${HTTPD_SPEC_MAJOR}`); + put(`#define PHTTPD_SPEC_MINOR ${HTTPD_SPEC_MINOR}`); + for (const [name, v] of Object.entries(HTTPD_OP)) { + put(`#define PHTTPD_OP_${screaming(name)} ${v}`); + } + put(`#define PHTTPD_SEND_ACCEPTED ${HTTPD_SEND_ACCEPTED}`); + put(`#define PHTTPD_SEND_INVALID_REQUEST (${HTTPD_SEND_INVALID_REQUEST})`); + put(`#define PHTTPD_SEND_BACKPRESSURE (${HTTPD_SEND_BACKPRESSURE})`); + put(`#define PHTTPD_SEND_INVALID (${HTTPD_SEND_INVALID})`); + for (const [name, v] of Object.entries(HTTPD_EVENT)) { + put(`#define PHTTPD_EVENT_${screaming(name)} ${cstr(v)}`); + } + put(`#define PHTTPD_MAX_SERVERS ${HTTPD_MAX_SERVERS}`); + put(`#define PHTTPD_MAX_CONNECTIONS ${HTTPD_MAX_CONNECTIONS}`); + put(`#define PHTTPD_MAX_INFLIGHT ${HTTPD_MAX_INFLIGHT}`); + put(`#define PHTTPD_MAX_BACKLOG ${HTTPD_MAX_BACKLOG}`); + put(`#define PHTTPD_MAX_HEADERS ${HTTPD_MAX_HEADERS}`); + put(`#define PHTTPD_MAX_HEADER_BYTES ${HTTPD_MAX_HEADER_BYTES}`); + put(`#define PHTTPD_MAX_TARGET_BYTES ${HTTPD_MAX_TARGET_BYTES}`); + put(`#define PHTTPD_DEFAULT_REQUEST_QUEUE_BYTES ${HTTPD_DEFAULT_REQUEST_QUEUE_BYTES}`); + put(`#define PHTTPD_MAX_REQUEST_QUEUE_BYTES ${HTTPD_MAX_REQUEST_QUEUE_BYTES}`); + put(`#define PHTTPD_MAX_SEND_QUEUE_BYTES ${HTTPD_MAX_SEND_QUEUE_BYTES}`); + put(`#define PHTTPD_SEND_HIGH_WATER_BYTES ${HTTPD_SEND_HIGH_WATER_BYTES}`); + put(`#define PHTTPD_SEND_LOW_WATER_BYTES ${HTTPD_SEND_LOW_WATER_BYTES}`); + put(`#define PHTTPD_MAX_EVENTS_PER_TICK ${HTTPD_MAX_EVENTS_PER_TICK}`); + put(`#define PHTTPD_MAX_TICK_BYTES ${HTTPD_MAX_TICK_BYTES}`); + put(`#define PHTTPD_DEFAULT_HEADER_MS ${HTTPD_DEFAULT_HEADER_MS}`); + put(`#define PHTTPD_DEFAULT_BODY_IDLE_MS ${HTTPD_DEFAULT_BODY_IDLE_MS}`); + put(`#define PHTTPD_DEFAULT_HANDLER_MS ${HTTPD_DEFAULT_HANDLER_MS}`); + put(`#define PHTTPD_DEFAULT_KEEP_ALIVE_MS ${HTTPD_DEFAULT_KEEP_ALIVE_MS}`); + put(`#define PHTTPD_DEFAULT_CLOSE_MS ${HTTPD_DEFAULT_CLOSE_MS}`); + put(`#define PHTTPD_MAX_TIMEOUT_MS ${HTTPD_MAX_TIMEOUT_MS}`); + put(""); + put("#endif /* POCKETJS_NET_SPEC_H */"); + + return L.join("\n") + "\n"; +} + +if (import.meta.main) { + const out = new URL("../../engine/net/include/pocketjs/net/spec.h", import.meta.url).pathname; + await Bun.write(out, generateC()); + console.log(`wrote ${out}`); +} diff --git a/contracts/spec/gen-rust.ts b/contracts/spec/gen-rust.ts index 3fd2935c..d5ec1d36 100644 --- a/contracts/spec/gen-rust.ts +++ b/contracts/spec/gen-rust.ts @@ -1,4 +1,4 @@ -// Deterministic codegen: contracts/spec/{spec,audio,db,net}.ts -> engine/core/src/spec.rs. +// Deterministic codegen: contracts/spec/{spec,audio,db,fs,net,ws,httpd}.ts -> engine/core/src/spec.rs. // // Run from PocketJS/: bun contracts/spec/gen-rust.ts // @@ -36,20 +36,86 @@ import { FS_WRITE_TRUNCATE, } from "./fs.ts"; import { - NET_DEFAULT_RESPONSE_BYTES, + HTTPD_DEFAULT_BODY_IDLE_MS, + HTTPD_DEFAULT_CLOSE_MS, + HTTPD_DEFAULT_HANDLER_MS, + HTTPD_DEFAULT_HEADER_MS, + HTTPD_DEFAULT_KEEP_ALIVE_MS, + HTTPD_DEFAULT_REQUEST_QUEUE_BYTES, + HTTPD_EVENT, + HTTPD_MAX_BACKLOG, + HTTPD_MAX_CONNECTIONS, + HTTPD_MAX_EVENTS_PER_TICK, + HTTPD_MAX_HEADERS, + HTTPD_MAX_HEADER_BYTES, + HTTPD_MAX_INFLIGHT, + HTTPD_MAX_REQUEST_QUEUE_BYTES, + HTTPD_MAX_SEND_QUEUE_BYTES, + HTTPD_MAX_SERVERS, + HTTPD_MAX_TARGET_BYTES, + HTTPD_MAX_TICK_BYTES, + HTTPD_MAX_TIMEOUT_MS, + HTTPD_OP, + HTTPD_SEND_ACCEPTED, + HTTPD_SEND_BACKPRESSURE, + HTTPD_SEND_HIGH_WATER_BYTES, + HTTPD_SEND_INVALID, + HTTPD_SEND_INVALID_REQUEST, + HTTPD_SEND_LOW_WATER_BYTES, + HTTPD_SPEC_MAJOR, + HTTPD_SPEC_MINOR, +} from "./httpd.ts"; +import { + NET_DEFAULT_AGGREGATE_BYTES, + NET_DEFAULT_QUEUE_BYTES, NET_DEFAULT_TIMEOUT_MS, NET_ERROR, NET_EVENT, + NET_MAX_AGGREGATE_BYTES, + NET_MAX_EVENTS_PER_TICK, NET_MAX_HEADER_BYTES, NET_MAX_HEADERS, NET_MAX_INFLIGHT, + NET_MAX_QUEUE_BYTES, NET_MAX_REDIRECTS, NET_MAX_REQUEST_BYTES, - NET_MAX_RESPONSE_BYTES, + NET_MAX_TICK_BYTES, NET_MAX_TIMEOUT_MS, - NET_METHODS, + NET_METHODS_FORBIDDEN, NET_OP, + NET_SPEC_MAJOR, + NET_SPEC_MINOR, + NET_TLS_MIN_VERSION, } from "./net.ts"; +import { + WS_BLOB_KEY, + WS_CONTROL_PAYLOAD_MAX, + WS_DEFAULT_CLOSE_MS, + WS_DEFAULT_CONNECT_MS, + WS_EVENT, + WS_FORBIDDEN_HEADERS, + WS_MAX_CONNECT_MS, + WS_MAX_EVENTS_PER_TICK, + WS_MAX_HANDSHAKE_HEADERS, + WS_MAX_HANDSHAKE_HEADER_BYTES, + WS_MAX_MESSAGE_BYTES, + WS_MAX_RECEIVE_QUEUE_BYTES, + WS_MAX_RECEIVE_QUEUE_MESSAGES, + WS_MAX_SEND_QUEUE_BYTES, + WS_MAX_SOCKETS, + WS_MAX_TICK_BYTES, + WS_OP, + WS_OPCODE, + WS_SEND_ACCEPTED, + WS_SEND_ACCEPTED_HIGH_WATER, + WS_SEND_BACKPRESSURE, + WS_SEND_CLOSED, + WS_SEND_HIGH_WATER_BYTES, + WS_SEND_INVALID, + WS_SEND_LOW_WATER_BYTES, + WS_SPEC_MAJOR, + WS_SPEC_MINOR, +} from "./ws.ts"; import { ANALOG_CENTER, ANIMATABLE, @@ -552,30 +618,117 @@ export function generateRust(): string { put("}"); put(""); - // --- net module --------------------------------------------------------------- - put("/// NET module boundary (contracts/spec/net.ts — `globalThis.net`)."); - put("/// Bounded whole-response HTTP; completions batch to tick boundaries."); + // --- net module (HTTP Client) ------------------------------------------------ + put("/// NET module boundary (contracts/spec/net.ts — `globalThis.net`, spec v2)."); + put("/// Streaming HTTP/1.1 client; completions batch to tick boundaries."); put("pub mod net {"); + put(` pub const SPEC_MAJOR: u32 = ${NET_SPEC_MAJOR};`); + put(` pub const SPEC_MINOR: u32 = ${NET_SPEC_MINOR};`); for (const [name, v] of Object.entries(NET_OP)) { put(` pub const OP_${screaming(name)}: u8 = ${v};`); } put(` pub const MAX_INFLIGHT: usize = ${NET_MAX_INFLIGHT};`); put(` pub const MAX_REQUEST_BYTES: usize = ${NET_MAX_REQUEST_BYTES};`); - put(` pub const DEFAULT_RESPONSE_BYTES: usize = ${NET_DEFAULT_RESPONSE_BYTES};`); - put(` pub const MAX_RESPONSE_BYTES: usize = ${NET_MAX_RESPONSE_BYTES};`); + put(` pub const DEFAULT_QUEUE_BYTES: usize = ${NET_DEFAULT_QUEUE_BYTES};`); + put(` pub const MAX_QUEUE_BYTES: usize = ${NET_MAX_QUEUE_BYTES};`); + put(` pub const DEFAULT_AGGREGATE_BYTES: usize = ${NET_DEFAULT_AGGREGATE_BYTES};`); + put(` pub const MAX_AGGREGATE_BYTES: usize = ${NET_MAX_AGGREGATE_BYTES};`); + put(` pub const MAX_EVENTS_PER_TICK: usize = ${NET_MAX_EVENTS_PER_TICK};`); + put(` pub const MAX_TICK_BYTES: usize = ${NET_MAX_TICK_BYTES};`); put(` pub const MAX_HEADERS: usize = ${NET_MAX_HEADERS};`); put(` pub const MAX_HEADER_BYTES: usize = ${NET_MAX_HEADER_BYTES};`); put(` pub const DEFAULT_TIMEOUT_MS: u32 = ${NET_DEFAULT_TIMEOUT_MS};`); put(` pub const MAX_TIMEOUT_MS: u32 = ${NET_MAX_TIMEOUT_MS};`); put(` pub const MAX_REDIRECTS: usize = ${NET_MAX_REDIRECTS};`); - put(` pub const METHODS: [&str; ${NET_METHODS.length}] = [${NET_METHODS.map((method) => JSON.stringify(method)).join(", ")}];`); + put(` pub const TLS_MIN_VERSION: &str = ${JSON.stringify(NET_TLS_MIN_VERSION)};`); + put(` pub const METHODS_FORBIDDEN: [&str; ${NET_METHODS_FORBIDDEN.length}] = [${NET_METHODS_FORBIDDEN.map((method) => JSON.stringify(method)).join(", ")}];`); for (const [name, v] of Object.entries(NET_EVENT)) { put(` pub const EVENT_${screaming(name)}: &str = ${JSON.stringify(v)};`); } + put(" /// Error vocabulary shared by net, ws and httpd."); for (const [name, v] of Object.entries(NET_ERROR)) { put(` pub const ERROR_${screaming(name)}: &str = ${JSON.stringify(v)};`); } put("}"); + put(""); + + // --- ws module (WebSocket Client) -------------------------------------------- + put("/// WS module boundary (contracts/spec/ws.ts — `globalThis.ws`, spec v2)."); + put("/// RFC 6455 client; messages batch to tick boundaries."); + put("pub mod ws {"); + put(` pub const SPEC_MAJOR: u32 = ${WS_SPEC_MAJOR};`); + put(` pub const SPEC_MINOR: u32 = ${WS_SPEC_MINOR};`); + for (const [name, v] of Object.entries(WS_OP)) { + put(` pub const OP_${screaming(name)}: u8 = ${v};`); + } + put(` pub const SEND_ACCEPTED: i32 = ${WS_SEND_ACCEPTED};`); + put(` pub const SEND_ACCEPTED_HIGH_WATER: i32 = ${WS_SEND_ACCEPTED_HIGH_WATER};`); + put(` pub const SEND_CLOSED: i32 = ${WS_SEND_CLOSED};`); + put(` pub const SEND_BACKPRESSURE: i32 = ${WS_SEND_BACKPRESSURE};`); + put(` pub const SEND_INVALID: i32 = ${WS_SEND_INVALID};`); + for (const [name, v] of Object.entries(WS_OPCODE)) { + put(` pub const OPCODE_${screaming(name)}: u8 = ${v};`); + } + for (const [name, v] of Object.entries(WS_EVENT)) { + put(` pub const EVENT_${screaming(name)}: &str = ${JSON.stringify(v)};`); + } + put(` pub const BLOB_KEY: &str = ${JSON.stringify(WS_BLOB_KEY)};`); + put(` pub const FORBIDDEN_HEADERS: [&str; ${WS_FORBIDDEN_HEADERS.length}] = [${WS_FORBIDDEN_HEADERS.map((h) => JSON.stringify(h)).join(", ")}];`); + put(` pub const MAX_SOCKETS: usize = ${WS_MAX_SOCKETS};`); + put(` pub const MAX_MESSAGE_BYTES: usize = ${WS_MAX_MESSAGE_BYTES};`); + put(` pub const MAX_RECEIVE_QUEUE_BYTES: usize = ${WS_MAX_RECEIVE_QUEUE_BYTES};`); + put(` pub const MAX_RECEIVE_QUEUE_MESSAGES: usize = ${WS_MAX_RECEIVE_QUEUE_MESSAGES};`); + put(` pub const MAX_SEND_QUEUE_BYTES: usize = ${WS_MAX_SEND_QUEUE_BYTES};`); + put(` pub const SEND_HIGH_WATER_BYTES: usize = ${WS_SEND_HIGH_WATER_BYTES};`); + put(` pub const SEND_LOW_WATER_BYTES: usize = ${WS_SEND_LOW_WATER_BYTES};`); + put(` pub const MAX_HANDSHAKE_HEADERS: usize = ${WS_MAX_HANDSHAKE_HEADERS};`); + put(` pub const MAX_HANDSHAKE_HEADER_BYTES: usize = ${WS_MAX_HANDSHAKE_HEADER_BYTES};`); + put(` pub const MAX_EVENTS_PER_TICK: usize = ${WS_MAX_EVENTS_PER_TICK};`); + put(` pub const MAX_TICK_BYTES: usize = ${WS_MAX_TICK_BYTES};`); + put(` pub const DEFAULT_CONNECT_MS: u32 = ${WS_DEFAULT_CONNECT_MS};`); + put(` pub const MAX_CONNECT_MS: u32 = ${WS_MAX_CONNECT_MS};`); + put(` pub const DEFAULT_CLOSE_MS: u32 = ${WS_DEFAULT_CLOSE_MS};`); + put(` pub const CONTROL_PAYLOAD_MAX: usize = ${WS_CONTROL_PAYLOAD_MAX};`); + put("}"); + put(""); + + // --- httpd module (HTTP Server) ---------------------------------------------- + put("/// HTTPD module boundary (contracts/spec/httpd.ts — `globalThis.httpd`, spec v2)."); + put("/// HTTP/1.1 server; requests batch to tick boundaries."); + put("pub mod httpd {"); + put(` pub const SPEC_MAJOR: u32 = ${HTTPD_SPEC_MAJOR};`); + put(` pub const SPEC_MINOR: u32 = ${HTTPD_SPEC_MINOR};`); + for (const [name, v] of Object.entries(HTTPD_OP)) { + put(` pub const OP_${screaming(name)}: u8 = ${v};`); + } + put(` pub const SEND_ACCEPTED: i32 = ${HTTPD_SEND_ACCEPTED};`); + put(` pub const SEND_INVALID_REQUEST: i32 = ${HTTPD_SEND_INVALID_REQUEST};`); + put(` pub const SEND_BACKPRESSURE: i32 = ${HTTPD_SEND_BACKPRESSURE};`); + put(` pub const SEND_INVALID: i32 = ${HTTPD_SEND_INVALID};`); + for (const [name, v] of Object.entries(HTTPD_EVENT)) { + put(` pub const EVENT_${screaming(name)}: &str = ${JSON.stringify(v)};`); + } + put(` pub const MAX_SERVERS: usize = ${HTTPD_MAX_SERVERS};`); + put(` pub const MAX_CONNECTIONS: usize = ${HTTPD_MAX_CONNECTIONS};`); + put(` pub const MAX_INFLIGHT: usize = ${HTTPD_MAX_INFLIGHT};`); + put(` pub const MAX_BACKLOG: usize = ${HTTPD_MAX_BACKLOG};`); + put(` pub const MAX_HEADERS: usize = ${HTTPD_MAX_HEADERS};`); + put(` pub const MAX_HEADER_BYTES: usize = ${HTTPD_MAX_HEADER_BYTES};`); + put(` pub const MAX_TARGET_BYTES: usize = ${HTTPD_MAX_TARGET_BYTES};`); + put(` pub const DEFAULT_REQUEST_QUEUE_BYTES: usize = ${HTTPD_DEFAULT_REQUEST_QUEUE_BYTES};`); + put(` pub const MAX_REQUEST_QUEUE_BYTES: usize = ${HTTPD_MAX_REQUEST_QUEUE_BYTES};`); + put(` pub const MAX_SEND_QUEUE_BYTES: usize = ${HTTPD_MAX_SEND_QUEUE_BYTES};`); + put(` pub const SEND_HIGH_WATER_BYTES: usize = ${HTTPD_SEND_HIGH_WATER_BYTES};`); + put(` pub const SEND_LOW_WATER_BYTES: usize = ${HTTPD_SEND_LOW_WATER_BYTES};`); + put(` pub const MAX_EVENTS_PER_TICK: usize = ${HTTPD_MAX_EVENTS_PER_TICK};`); + put(` pub const MAX_TICK_BYTES: usize = ${HTTPD_MAX_TICK_BYTES};`); + put(` pub const DEFAULT_HEADER_MS: u32 = ${HTTPD_DEFAULT_HEADER_MS};`); + put(` pub const DEFAULT_BODY_IDLE_MS: u32 = ${HTTPD_DEFAULT_BODY_IDLE_MS};`); + put(` pub const DEFAULT_HANDLER_MS: u32 = ${HTTPD_DEFAULT_HANDLER_MS};`); + put(` pub const DEFAULT_KEEP_ALIVE_MS: u32 = ${HTTPD_DEFAULT_KEEP_ALIVE_MS};`); + put(` pub const DEFAULT_CLOSE_MS: u32 = ${HTTPD_DEFAULT_CLOSE_MS};`); + put(` pub const MAX_TIMEOUT_MS: u32 = ${HTTPD_MAX_TIMEOUT_MS};`); + put("}"); return L.join("\n") + "\n"; } diff --git a/contracts/spec/httpd.ts b/contracts/spec/httpd.ts new file mode 100644 index 00000000..20b5fa90 --- /dev/null +++ b/contracts/spec/httpd.ts @@ -0,0 +1,204 @@ +// PocketJS httpd spec v2 — the boundary of the HTTP Server module +// (`globalThis.httpd`). +// +// The public SDK is `serve()` in `@pocketjs/framework/net/http`. This file +// fixes the guest ↔ core boundary underneath it. HTTP Server is its own +// module (spec, core, capability `network.http.server` / `.tls`, namespace); +// it shares the native HTTP/1.1 parser, transport/TLS/queue substrate, policy +// input and error vocabulary (contracts/spec/net.ts NET_ERROR) with the HTTP +// Client. The guest sees a server handle and request ids, never connections: +// keep-alive, the pipelining ban, `Expect: 100-continue` and HEAD body +// discard are core rules. +// +// Frame contract: identical to net — completions become visible at +// `begin_tick()`, `poll()` runs once per tick, `respond`/`write` only place +// bytes in the connection's bounded send queue and the network task writes +// them out as soon as `frame()` returns. +// +// If you change ANY value here: run `bun run gen` and commit the regenerated +// engine/core/src/spec.rs and engine/net/include/pocketjs/net/spec.h. + +export const HTTPD_SPEC_MAJOR = 2; +export const HTTPD_SPEC_MINOR = 0; + +// --------------------------------------------------------------------------- +// Ops (guest -> core, all synchronous; codes append-only) +// --------------------------------------------------------------------------- +// +// listen(metaJson) -> handle | -1 +// Static validation only (capability, (protocol, address, port) listen +// rule, insecureTransport, credential id, limits, server count). +// bind/listen happen on the network task: `listening` on success, +// terminal `error` on failure. +// stop(handle, graceful, timeoutMs) -> 0 | -1 +// Stop accepting and close idle connections; graceful waits for +// inflight requests until timeoutMs, then forces the rest. Terminal +// `closed{h}` follows; forced requests each get `aborted{code:"closed"}`. +// respond(req, metaJson, body:ArrayBuffer|null) -> 0 | -1 | -2 | -3 +// Send the response head; with meta.end=true (default) the body +// completes the response, else `write`/`endBody` stream it. -1 unknown/ +// answered/aborted req; -2 body does not fit the send queue (nothing +// accepted, `drain` armed — use end=false + write); -3 invalid meta. +// write(req, chunk:ArrayBuffer) -> 0 | -1 | -2 | -3 +// Append a body chunk after respond(end=false); accepted whole or not +// at all. -2 queue full (`drain` armed); -3 chunk > maxSendQueueBytes. +// endBody(req) -> 0 | -1 +// Finish a streamed response (writes the terminating chunk); the req id +// is invalid afterwards. +// readInto(req, into:ArrayBuffer, offset, length) -> bytes | -1 +// Read request-body bytes visible at the tick boundary; same semantics +// as net.readInto. EOF is `end{req}`. +// abort(req) +// Give the request up: the core closes the connection (or ends the body +// if a response started); next tick delivers `aborted{req, +// code:"cancelled"}`. No-op on a terminal req. +// poll() -> string | undefined +// lastError() -> string +// limits() -> string + +export const HTTPD_OP = { + listen: 1, + stop: 2, + respond: 3, + write: 4, + endBody: 5, + readInto: 6, + abort: 7, + poll: 8, + lastError: 9, + limits: 10, +} as const; + +/** `respond`/`write` return values. */ +export const HTTPD_SEND_ACCEPTED = 0; +export const HTTPD_SEND_INVALID_REQUEST = -1; +export const HTTPD_SEND_BACKPRESSURE = -2; +export const HTTPD_SEND_INVALID = -3; + +// --------------------------------------------------------------------------- +// Events (core -> guest, one JSON array per tick, sequence order) +// --------------------------------------------------------------------------- +// +// {"t":"listening","h":n,"address":"192.168.1.20","port":8080} +// {"t":"closed","h":n} +// {"t":"error","h":n,"code":"address_in_use","message":"…","causeCode":"…"} +// {"t":"request","h":n,"req":r,"method":"GET","target":"/a?b=1","headers":{…}, +// "remote":{"address":"…","port":51234},"length":12,"secure":false} +// {"t":"readable","req":r,"avail":12} +// {"t":"end","req":r} +// {"t":"drain","req":r} +// {"t":"aborted","req":r,"code":"closed"} +// +// Per server: `error` (before listening, terminal) or +// `listening → request* → [error →] closed`. Per req the terminal is either +// the application completing the response (respond end=true / endBody) or +// exactly one `aborted{code}` with code closed | timeout | response_too_large +// | cancelled. `request` is delivered only when an inflight slot and the +// per-tick event budget allow it. + +export const HTTPD_EVENT = { + listening: "listening", + closed: "closed", + error: "error", + request: "request", + readable: "readable", + end: "end", + drain: "drain", + aborted: "aborted", +} as const; + +// --------------------------------------------------------------------------- +// Data contract +// --------------------------------------------------------------------------- + +export interface HttpdListenMeta { + address: string; + /** 0 = ephemeral; must match a listen rule with port "ephemeral". */ + port: number; + backlog?: number; + tls?: { credential: string }; + limits?: { + maxConnections?: number; + maxInflight?: number; + maxHeaderBytes?: number; + maxBodyBytes?: number; + requestQueueBytes?: number; + sendQueueBytes?: number; + }; + timeouts?: { + headerMs?: number; + bodyIdleMs?: number; + handlerMs?: number; + keepAliveMs?: number; + closeMs?: number; + }; +} + +export interface HttpdRespondMeta { + status: number; + /** Reason phrase; empty selects the RFC 9110 default. */ + statusText?: string; + headers?: Record; + /** Known body length for a streamed response; omitted = chunked. */ + contentLength?: number; + /** false = stream the body with write/endBody. Default true. */ + end?: boolean; +} + +export interface HttpdLimits { + specMajor: number; + specMinor: number; + maxServers: number; + maxConnections: number; + maxInflight: number; + maxTlsInflight: number; + maxHeaders: number; + maxHeaderBytes: number; + maxTargetBytes: number; + defaultRequestQueueBytes: number; + maxRequestQueueBytes: number; + maxSendQueueBytes: number; + sendHighWaterBytes: number; + sendLowWaterBytes: number; + maxEventsPerTick: number; + maxTickBytes: number; + defaultHeaderMs: number; + defaultBodyIdleMs: number; + defaultHandlerMs: number; + defaultKeepAliveMs: number; + defaultCloseMs: number; + maxTimeoutMs: number; + tlsMinVersion: string; + features: readonly string[]; +} + +// --------------------------------------------------------------------------- +// Portable limits (ceilings; hosts only tighten) +// --------------------------------------------------------------------------- + +/** Listeners alive at once. */ +export const HTTPD_MAX_SERVERS = 2; +/** Per server: open connections / delivered-but-unanswered requests. */ +export const HTTPD_MAX_CONNECTIONS = 16; +export const HTTPD_MAX_INFLIGHT = 8; +export const HTTPD_MAX_BACKLOG = 16; +/** Request head: header count, total header bytes, request-target bytes; + * exceeding answers 431 / 414 and closes without delivering `request`. */ +export const HTTPD_MAX_HEADERS = 64; +export const HTTPD_MAX_HEADER_BYTES = 16 * 1024; +export const HTTPD_MAX_TARGET_BYTES = 2048; +/** Per-request native receive queue (backpressure window). */ +export const HTTPD_DEFAULT_REQUEST_QUEUE_BYTES = 32 * 1024; +export const HTTPD_MAX_REQUEST_QUEUE_BYTES = 256 * 1024; +/** Per-connection send queue and its `drain` thresholds. */ +export const HTTPD_MAX_SEND_QUEUE_BYTES = 256 * 1024; +export const HTTPD_SEND_HIGH_WATER_BYTES = 128 * 1024; +export const HTTPD_SEND_LOW_WATER_BYTES = 32 * 1024; +export const HTTPD_MAX_EVENTS_PER_TICK = 128; +export const HTTPD_MAX_TICK_BYTES = 256 * 1024; +export const HTTPD_DEFAULT_HEADER_MS = 10_000; +export const HTTPD_DEFAULT_BODY_IDLE_MS = 30_000; +export const HTTPD_DEFAULT_HANDLER_MS = 30_000; +export const HTTPD_DEFAULT_KEEP_ALIVE_MS = 15_000; +export const HTTPD_DEFAULT_CLOSE_MS = 5_000; +export const HTTPD_MAX_TIMEOUT_MS = 120_000; diff --git a/contracts/spec/net.ts b/contracts/spec/net.ts index 974c4bdb..7c24239c 100644 --- a/contracts/spec/net.ts +++ b/contracts/spec/net.ts @@ -1,123 +1,259 @@ -// PocketJS net spec — the boundary of the NET module (`globalThis.net`). +// PocketJS net spec v2 — the boundary of the HTTP Client module (`globalThis.net`). // -// This module deliberately exposes one bounded HTTP client primitive, not a -// browser networking stack. The public SDK is `fetch()`; the native boundary -// below stays smaller so embedded transports (ESP-IDF, ureq, platform HTTP) -// can implement it without reproducing WHATWG Request/Response/Streams. +// The public SDK is `@pocketjs/framework/net/http` (`fetch`, `Headers`, +// `Request`, `Response`, `BodyStream`). This file fixes the guest ↔ core +// boundary underneath it: numeric op codes, event names, the JSON data +// contract, portable limits and the shared error vocabulary. // // The four parts of the boundary: // // ops guest -> core intent (numeric codes below, append-only) -// events core -> guest facts (one JSON batch per tick) -// data contract request metadata JSON + borrowed request body + taken body +// events core -> guest facts (one JSON batch per tick, sequence order) +// data contract request metadata JSON + borrowed request body + copied +// response bytes (`readInto`) // frame contract transport never enters QuickJS; completions become visible -// only at a host tick boundary and Promise reactions run in -// that guest turn's normal microtask drain +// only at a host tick boundary (`begin_tick`), the framework +// service pump calls `poll` exactly once per frame, and +// Promise reactions run in that guest turn's job drain // // Ownership: -// start() BORROWS the request ArrayBuffer for the synchronous call. The host -// copies it before returning. take() BORROWS an exactly-sized destination, -// copies one completed response body into it, and succeeds at most once. +// start() BORROWS the request ArrayBuffer for the synchronous call; the host +// copies it before returning. readInto() BORROWS a destination ArrayBuffer +// and copies bytes that became visible at the last tick boundary into it, +// releasing the corresponding native queue space. // -// If you change ANY value here: run `bun contracts/spec/gen-rust.ts`, commit -// the regenerated engine/core/src/spec.rs (tests/contract.ts byte-compares). +// Host obligations: +// - `begin_tick()` before every `frame()`: swap transport completions into +// the visible set and freeze each handle's `readable` watermark; +// - no network task or callback ever calls QuickJS; +// - TLS (when the host advertises the "tls" feature): system trust store, +// SNI = authorized hostname, DNS-ID hostname verification, TLS 1.2 +// minimum, renegotiation and 0-RTT off, trusted wall clock or +// `tls_clock_untrusted`, never a plaintext fallback. +// +// If you change ANY value here: run `bun run gen` (contracts/spec/gen-rust.ts, +// contracts/spec/gen-c.ts), commit the regenerated engine/core/src/spec.rs and +// engine/net/include/pocketjs/net/spec.h (tests/contract.ts byte-compares). + +// --------------------------------------------------------------------------- +// Spec version — the host reports it from `limits()` (specMajor/specMinor); +// the SDK refuses a major mismatch with `unsupported`. +// --------------------------------------------------------------------------- + +export const NET_SPEC_MAJOR = 2; +export const NET_SPEC_MINOR = 0; // --------------------------------------------------------------------------- -// Net ops (the `net.*` native contract) +// Net ops (the `net.*` native contract; codes append-only, 2 is retired) // --------------------------------------------------------------------------- // // Signatures (authoritative; hosts marshal them however they like): -// start(metaJson:string, body:ArrayBuffer) -> handle | -1 -// metaJson = {url, method, headers, timeoutMs, maxBytes} -// The request is accepted or refused synchronously. Read lastError() on -// -1. A successful request completes asynchronously through poll(). -// take(handle, into:ArrayBuffer) -> bytesCopied | -1 -// Copy the completed response body exactly once. `into.byteLength` must -// equal the `bytes` field of the handle's done event. +// start(metaJson:string, body:ArrayBuffer|null) -> handle | -1 +// metaJson: see NetStartMeta below. Static validation only (URL, method, +// header syntax, scheme/capability, endpoint rule, insecureTransport, +// limits, inflight); the body is copied before the call returns. Read +// lastError() on -1. Checks that need DNS fail asynchronously with an +// `error` event. // cancel(handle) -// Best-effort transport cancellation and unconditional core cleanup. +// Best-effort transport close plus core cleanup; the handle's terminal +// `error{code:"cancelled"}` arrives with the next tick's batch. No-op on +// a handle that already reached its terminal event. // poll() -> string | undefined -// Drain the ENTIRE event batch visible at this tick as one JSON array. -// The SDK calls this once per tick only while requests are pending. +// The whole event batch visible at this tick as one JSON array, ordered +// by sequence. The SDK calls it exactly once per tick and only while at +// least one handle is live. // lastError() -> string // Portable `code: message` for the most recent synchronous refusal. +// readInto(handle, into:ArrayBuffer, offset, length) -> bytes | -1 +// Copy up to `length` visible unread body bytes into into[offset..] and +// release that queue space. 0 = no visible bytes right now (wait for the +// next `readable`); EOF is signalled by the `end` event; -1 = unknown or +// terminal handle. +// limits() -> string +// Read-only JSON with this host's effective limits and features (see +// NetLimits below). +// write(handle, chunk:ArrayBuffer) -> accepted | -1 (phase 2, reserved) +// endBody(handle) (phase 2, reserved) export const NET_OP = { start: 1, + /** v1 `take` — retired code, never reused. */ take: 2, cancel: 3, poll: 4, lastError: 5, + readInto: 6, + limits: 7, + write: 8, + endBody: 9, } as const; // --------------------------------------------------------------------------- // Events (core -> guest facts; all events for a tick in one JSON array) // --------------------------------------------------------------------------- // -// {"t":"done","h":n,"status":200,"url":"https://…","headers":{…},"bytes":5} -// {"t":"error","h":n,"code":"timeout","message":"…"} +// {"t":"headers","h":n,"status":200,"url":"http://…","headers":{…},"redirected":false,"length":5} +// {"t":"readable","h":n,"avail":1234} +// {"t":"end","h":n} +// {"t":"error","h":n,"code":"timeout","message":"…","causeCode":"…"} +// {"t":"drain","h":n} (phase 2) // -// A done event guarantees take(h, exactlySizedBuffer) is available. An error -// event guarantees no response body remains. Every accepted handle produces -// at most one terminal event unless the guest cancels it first. +// Per handle the sequence is `headers → readable* → end` or `… → error`; +// nothing follows `error`. `readable.avail` is the total visible unread byte +// count at the tick boundary and is sent at most once per handle per tick. +// `end` may arrive while visible bytes remain unread; the SDK drains them +// first. HTTP 4xx/5xx are successful exchanges; HEAD/204/304 produce +// `headers` + `end`. export const NET_EVENT = { - done: "done", + headers: "headers", + readable: "readable", + end: "end", error: "error", + drain: "drain", } as const; -/** Common application HTTP methods. CONNECT and TRACE are intentionally not - * client-app operations; custom methods are outside the portable v1 surface. */ -export const NET_METHODS = [ - "GET", - "HEAD", - "POST", - "PUT", - "PATCH", - "DELETE", - "OPTIONS", -] as const; +// --------------------------------------------------------------------------- +// Data contract +// --------------------------------------------------------------------------- -export type NetMethod = (typeof NET_METHODS)[number]; +/** `start` metadata. `queueBytes` is the native receive-queue capacity + * (backpressure window); `maxBodyBytes` is an optional total cap. Timeouts + * use the host monotonic clock: `connectMs` covers DNS + TCP + TLS, + * `headersMs` request-sent → response headers, `idleMs` body inactivity, + * `totalMs` the whole exchange. */ +export interface NetStartMeta { + url: string; + method: string; + headers: Record; + queueBytes?: number; + maxBodyBytes?: number; + timeouts?: { connectMs?: number; headersMs?: number; idleMs?: number; totalMs?: number }; + redirect?: "follow" | "manual" | "error"; + maxRedirects?: number; + tls?: { verification?: "full" | "development-insecure" }; +} + +/** `limits()` payload. Spec constants are portable ceilings; hosts only + * tighten, and this reports the tightened values. */ +export interface NetLimits { + specMajor: number; + specMinor: number; + maxInflight: number; + maxTlsInflight: number; + maxRequestBytes: number; + defaultQueueBytes: number; + maxQueueBytes: number; + defaultAggregateBytes: number; + maxAggregateBytes: number; + maxEventsPerTick: number; + maxTickBytes: number; + maxHeaders: number; + maxHeaderBytes: number; + defaultTimeoutMs: number; + maxTimeoutMs: number; + maxRedirects: number; + tlsMinVersion: string; + features: readonly string[]; +} // --------------------------------------------------------------------------- -// Bounded whole-response contract +// Portable limits (ceilings; a host's limits() may be smaller, never larger) // --------------------------------------------------------------------------- -/** Two concurrent requests cover the common app pattern while bounding - * transport state, TLS buffers and completed bodies on small hosts. */ -export const NET_MAX_INFLIGHT = 2; - +/** Concurrent live handles per runtime. */ +export const NET_MAX_INFLIGHT = 8; /** Request bodies are copied out of the guest during start(). */ -export const NET_MAX_REQUEST_BYTES = 64 * 1024; +export const NET_MAX_REQUEST_BYTES = 256 * 1024; +/** Per-handle native receive queue (backpressure window). */ +export const NET_DEFAULT_QUEUE_BYTES = 32 * 1024; +export const NET_MAX_QUEUE_BYTES = 256 * 1024; +/** SDK aggregate helpers (`text()`/`json()`/`arrayBuffer()`): total bytes + * before the SDK cancels the handle with `response_too_large`. */ +export const NET_DEFAULT_AGGREGATE_BYTES = 1024 * 1024; +export const NET_MAX_AGGREGATE_BYTES = 8 * 1024 * 1024; +/** Visible-set budget per tick: events and newly visible bytes across all + * handles. Excess stays queued natively and follows in sequence order. */ +export const NET_MAX_EVENTS_PER_TICK = 128; +export const NET_MAX_TICK_BYTES = 256 * 1024; -/** Default and absolute response-body limits. Transports should stop reading - * as soon as the selected limit is exceeded; the core checks again before a - * body becomes visible to the guest. */ -export const NET_DEFAULT_RESPONSE_BYTES = 128 * 1024; -export const NET_MAX_RESPONSE_BYTES = 256 * 1024; - -export const NET_MAX_HEADERS = 32; -export const NET_MAX_HEADER_BYTES = 8 * 1024; +export const NET_MAX_HEADERS = 64; +export const NET_MAX_HEADER_BYTES = 16 * 1024; export const NET_DEFAULT_TIMEOUT_MS = 30_000; export const NET_MAX_TIMEOUT_MS = 120_000; -export const NET_MAX_REDIRECTS = 3; +/** Default and maximum redirect hops; applications can only lower it. */ +export const NET_MAX_REDIRECTS = 5; +export const NET_TLS_MIN_VERSION = "1.2"; + +/** Methods that are never client-app operations; any other RFC 9110 token + * is accepted. */ +export const NET_METHODS_FORBIDDEN = ["CONNECT", "TRACE"] as const; + +// --------------------------------------------------------------------------- +// Errors — the vocabulary shared by net, ws and httpd. A core +// maps platform/library failures into these codes before crossing the +// boundary; the raw code may travel in `causeCode`. +// --------------------------------------------------------------------------- -/** Portable errors. A transport maps platform/library failures into these - * codes before crossing the module boundary. */ export const NET_ERROR = { - unavailable: "unavailable", + // synchronous refusal / runtime invalidRequest: "invalid_request", + invalidState: "invalid_state", + unsupported: "unsupported", + permissionDenied: "permission_denied", busy: "busy", + resourceLimit: "resource_limit", + // resolver / transport dns: "dns", connect: "connect", - tls: "tls", + addressInUse: "address_in_use", + closed: "closed", timeout: "timeout", + // tls + tlsCertificateInvalid: "tls_certificate_invalid", + tlsHostnameMismatch: "tls_hostname_mismatch", + tlsHandshakeFailed: "tls_handshake_failed", + tlsClockUntrusted: "tls_clock_untrusted", + // http redirect: "redirect", responseTooLarge: "response_too_large", protocol: "protocol", + // websocket + websocketHandshakeFailed: "websocket_handshake_failed", + websocketProtocolError: "websocket_protocol_error", + messageTooLarge: "message_too_large", + // other cancelled: "cancelled", other: "other", + /** SDK-only: the namespace is not mounted on this host. */ + unavailable: "unavailable", } as const; export type NetErrorCode = (typeof NET_ERROR)[keyof typeof NET_ERROR]; + +/** `NetworkError.category` is derived from the code, never sent by a host. */ +export function netErrorCategory( + code: string, +): "runtime" | "resolver" | "transport" | "tls" | "protocol" { + switch (code) { + case NET_ERROR.dns: + return "resolver"; + case NET_ERROR.connect: + case NET_ERROR.addressInUse: + return "transport"; + case NET_ERROR.tlsCertificateInvalid: + case NET_ERROR.tlsHostnameMismatch: + case NET_ERROR.tlsHandshakeFailed: + case NET_ERROR.tlsClockUntrusted: + return "tls"; + case NET_ERROR.redirect: + case NET_ERROR.responseTooLarge: + case NET_ERROR.protocol: + case NET_ERROR.websocketHandshakeFailed: + case NET_ERROR.websocketProtocolError: + case NET_ERROR.messageTooLarge: + return "protocol"; + default: + return "runtime"; + } +} diff --git a/contracts/spec/platforms.ts b/contracts/spec/platforms.ts index ee0f73d9..f45cf4f1 100644 --- a/contracts/spec/platforms.ts +++ b/contracts/spec/platforms.ts @@ -144,11 +144,24 @@ export const POCKET_CAPABILITIES = defineCapabilityRegistry([ // appends the id to its profile only when its native host ships the module // (the ring/thread discipline to copy is hosts/psp/src/audio.rs). "audio.pcm", - // Bounded whole-response HTTP through `fetch()` and the net module's own - // namespace (`globalThis.net`, contracts/spec/net.ts). Transport adapters - // remain host-owned; the browser dev host, deterministic sim and reference - // core exercise the contract without granting network access to every host. - "net.http", + // Network capabilities are split by protocol, role and TLS. + // Each id names one module + // boundary: `network.http.client` is `fetch()` over `globalThis.net` + // (contracts/spec/net.ts), `network.http.server` is `serve()` over + // `globalThis.httpd` (contracts/spec/httpd.ts), `network.websocket.client` + // is `connect()` over `globalThis.ws` (contracts/spec/ws.ts); the `.tls` + // ids are the TLS roles a host admits separately. Registered ahead of any + // stock TARGET advertising them: the sim hosts, the browser dev host and + // the reference cores (engine/net, engine/crates/pocket-net) implement and + // test the contracts, so apps can already declare the requirement and fail + // admission where the modules are absent. A device target appends an id to + // its profile only when its native host ships and tests the module. + "network.http.client", + "network.http.client.tls", + "network.http.server", + "network.http.server.tls", + "network.websocket.client", + "network.websocket.client.tls", // SQLite behind the db module's own namespace (`globalThis.db`, // contracts/spec/db.ts): five synchronous ops, rows as one JSON line per // query() call, per-app storage the host confines. Registered ahead of any diff --git a/contracts/spec/ws.ts b/contracts/spec/ws.ts new file mode 100644 index 00000000..5f298f6a --- /dev/null +++ b/contracts/spec/ws.ts @@ -0,0 +1,195 @@ +// PocketJS ws spec v2 — the boundary of the WebSocket Client module +// (`globalThis.ws`). +// +// The public SDK is `@pocketjs/framework/net/websocket` (`connect`). This file +// fixes the guest ↔ core boundary underneath it. WebSocket is its own module: +// its own spec, core, capability (`network.websocket.client` / `.tls`) and +// namespace, mounted only by hosts that ship it. It shares the transport/TLS/ +// queue substrate, the policy input, the frame contract and the error +// vocabulary (contracts/spec/net.ts NET_ERROR) with the HTTP modules. +// +// Frame contract: identical to net — completions become visible at +// `begin_tick()`, `poll()` runs once per tick from the framework service pump, +// handlers run synchronously inside that pump and return void, Promise +// reactions run in the same tick's job drain. The core answers pings itself +// (RFC 6455 §5.5.3) and never sends keepalive pings on its own. +// +// If you change ANY value here: run `bun run gen` and commit the regenerated +// engine/core/src/spec.rs and engine/net/include/pocketjs/net/spec.h. + +export const WS_SPEC_MAJOR = 2; +export const WS_SPEC_MINOR = 0; + +// --------------------------------------------------------------------------- +// Ops (guest -> core, all synchronous; codes append-only) +// --------------------------------------------------------------------------- +// +// connect(metaJson) -> handle | -1 +// Static validation only (`ws:`/`wss:` scheme + capability, endpoint +// rule, insecureTransport, header syntax + forbidden headers, protocol +// tokens, limits, socket count). DNS/filtering/TCP/TLS/handshake happen +// asynchronously: success arrives as `open`, failure as `error`. +// send(handle, opcode, payload:string|ArrayBuffer|null) -> status +// opcode uses the RFC 6455 values: 1 text, 2 binary, 9 ping, 10 pong. +// The payload is snapshotted into the bounded send queue inside the +// call; a message is accepted whole or not at all. Returns +// WS_SEND_ACCEPTED (0), WS_SEND_ACCEPTED_HIGH_WATER (1), +// WS_SEND_CLOSED (-1), WS_SEND_BACKPRESSURE (-2, `drain` armed), +// WS_SEND_INVALID (-3: over maxMessageBytes, control payload > 125, +// bad opcode). +// receiveInto(handle, into:ArrayBuffer, offset, length) -> bytes | -1 +// Dequeue the head BINARY message into into[offset..]. `length` must be +// >= the message's `bytes`, else -1 and nothing is dequeued. +// close(handle, code?, reason?) -> 0 | -1 | -3 +// Start the close handshake after the accepted messages; the terminal +// `close` event follows once the peer answers or closeMs elapses. code +// is omitted, 1000 or 3000–4999; reason is UTF-8 <= 123 bytes (else -3). +// -1 when not open or already closing. +// terminate(handle) +// Abort the transport without a Close frame; next tick delivers +// `close{code:1006, clean:false, local:true}` (or `error{cancelled}` if +// the handshake never completed). No-op on a terminal handle. +// bufferedAmount(handle) -> bytes | -1 +// Payload bytes accepted by the core and not yet handed to transport. +// poll() -> string | undefined +// lastError() -> string +// limits() -> string + +export const WS_OP = { + connect: 1, + send: 2, + receiveInto: 3, + close: 4, + terminate: 5, + bufferedAmount: 6, + poll: 7, + lastError: 8, + limits: 9, +} as const; + +/** `send` return values. */ +export const WS_SEND_ACCEPTED = 0; +export const WS_SEND_ACCEPTED_HIGH_WATER = 1; +export const WS_SEND_CLOSED = -1; +export const WS_SEND_BACKPRESSURE = -2; +export const WS_SEND_INVALID = -3; + +/** RFC 6455 opcodes accepted by `send`. */ +export const WS_OPCODE = { + text: 1, + binary: 2, + ping: 9, + pong: 10, +} as const; + +// --------------------------------------------------------------------------- +// Events (core -> guest, one JSON array per tick, sequence order) +// --------------------------------------------------------------------------- +// +// {"t":"open","h":n,"protocol":"telemetry.v1"} +// {"t":"message","h":n,"kind":"text","text":"…"} +// {"t":"message","h":n,"kind":"binary","bytes":1234} -> receiveInto +// {"t":"ping","h":n,"payload":{"$b":"base64"}} (already answered) +// {"t":"pong","h":n,"payload":{"$b":"base64"}} +// {"t":"drain","h":n} +// {"t":"error","h":n,"code":"…","message":"…","causeCode":"…","status":403} +// {"t":"close","h":n,"code":1000,"reason":"","clean":true,"local":false} +// +// Per handle: `error` (before open, terminal) or +// `open → (message | ping | pong | drain)* → [error →] close`; nothing +// follows `close`. Fragmented messages are reassembled natively; oversized +// inbound messages close with 1009, an unqueueable message with 1013, +// protocol violations with 1002, invalid UTF-8 with 1007 — all reported as +// `error{…} → close`. + +export const WS_EVENT = { + open: "open", + message: "message", + ping: "ping", + pong: "pong", + drain: "drain", + error: "error", + close: "close", +} as const; + +/** Marker key for a bytes payload inside event JSON (db/fs blob spelling). */ +export const WS_BLOB_KEY = "$b"; + +// --------------------------------------------------------------------------- +// Data contract +// --------------------------------------------------------------------------- + +export interface WsConnectMeta { + url: string; + protocols?: readonly string[]; + headers?: Record; + timeouts?: { connectMs?: number; closeMs?: number }; + limits?: { + maxMessageBytes?: number; + receiveQueueBytes?: number; + receiveQueueMessages?: number; + sendQueueBytes?: number; + }; + tls?: { verification?: "full" | "development-insecure" }; +} + +export interface WsLimits { + specMajor: number; + specMinor: number; + maxSockets: number; + maxTlsInflight: number; + maxMessageBytes: number; + maxReceiveQueueBytes: number; + maxReceiveQueueMessages: number; + maxSendQueueBytes: number; + sendHighWaterBytes: number; + sendLowWaterBytes: number; + maxHandshakeHeaders: number; + maxHandshakeHeaderBytes: number; + maxEventsPerTick: number; + maxTickBytes: number; + defaultConnectMs: number; + maxConnectMs: number; + defaultCloseMs: number; + tlsMinVersion: string; + features: readonly string[]; +} + +/** Request headers the guest may not set; the core owns them. */ +export const WS_FORBIDDEN_HEADERS = [ + "host", + "connection", + "upgrade", + "content-length", + "sec-websocket-key", + "sec-websocket-version", + "sec-websocket-protocol", + "sec-websocket-extensions", + "sec-websocket-accept", +] as const; + +// --------------------------------------------------------------------------- +// Portable limits (ceilings; hosts only tighten) +// --------------------------------------------------------------------------- + +/** Sockets alive at once, including handshaking and closing ones. */ +export const WS_MAX_SOCKETS = 8; +/** One message, inbound or outbound; fragment reassembly is bounded by it. */ +export const WS_MAX_MESSAGE_BYTES = 1024 * 1024; +/** Reassembled, undelivered inbound messages per socket. */ +export const WS_MAX_RECEIVE_QUEUE_BYTES = 1024 * 1024; +export const WS_MAX_RECEIVE_QUEUE_MESSAGES = 64; +/** Accepted, unsent outbound payload per socket. */ +export const WS_MAX_SEND_QUEUE_BYTES = 1024 * 1024; +/** `send` returns 1 above the high mark; `drain` fires below the low mark. */ +export const WS_SEND_HIGH_WATER_BYTES = 256 * 1024; +export const WS_SEND_LOW_WATER_BYTES = 64 * 1024; +export const WS_MAX_HANDSHAKE_HEADERS = 64; +export const WS_MAX_HANDSHAKE_HEADER_BYTES = 16 * 1024; +export const WS_MAX_EVENTS_PER_TICK = 128; +export const WS_MAX_TICK_BYTES = 256 * 1024; +export const WS_DEFAULT_CONNECT_MS = 30_000; +export const WS_MAX_CONNECT_MS = 120_000; +export const WS_DEFAULT_CLOSE_MS = 5_000; +/** RFC 6455 control-frame payload ceiling. */ +export const WS_CONTROL_PAYLOAD_MAX = 125; diff --git a/docs/NET.md b/docs/NET.md index dd16c767..04ab3ef2 100644 --- a/docs/NET.md +++ b/docs/NET.md @@ -1,139 +1,146 @@ -# NET module +# Network modules -The NET module gives a guest one bounded HTTP client API: +PocketJS networking is a set of explicitly imported modules over three +spec-pinned guest boundaries. Applications import from +`@pocketjs/framework/net/*`; hosts mount `globalThis.net`, `globalThis.ws` +and `globalThis.httpd`; the cores in between own the wire, the limits and +the tick-boundary delivery. The pinned boundaries live in +`contracts/spec/net.ts`, `contracts/spec/ws.ts` and `contracts/spec/httpd.ts`. ```ts -import { fetch } from "@pocketjs/framework/net"; +import { fetch, serve, Response } from "@pocketjs/framework/net/http"; +import { connect } from "@pocketjs/framework/net/websocket"; +import { AbortController, NetworkError, URL, getNetworkLimits } from "@pocketjs/framework/net"; -const response = await fetch("https://api.example.com/items", { +const response = await fetch("http://api.example.test/items", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "Pocket" }), - timeoutMs: 5_000, - maxBytes: 64 * 1024, + timeouts: { headersMs: 5_000 }, }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); -const value = await response.json(); +for await (const chunk of response.body!) consume(chunk); // or response.json() + +const server = await serve({ + hostname: "0.0.0.0", + port: 8080, + fetch: (request) => new Response(`hello ${new URL(request.url).pathname}`), +}); + +const socket = await connect("ws://broker.example.test/telemetry", { + protocols: ["telemetry.v1"], + socket: { message: (socket, data) => socket.send(data) }, +}); ``` -This is fetch-shaped, not the complete browser Fetch standard. V1 includes -`fetch`, common application methods, string/byte request bodies, headers, -timeouts, a response-size limit, and buffered `text()`, `json()`, `bytes()` -and `arrayBuffer()` reads. It does not include `Request`, `Headers`, streams, -cookies, cache, proxy configuration, `AbortSignal`, WebSocket, servers, or raw -sockets. +## Modules and capabilities -## Module ownership +| Import | Boundary | Capability | Status | +| --- | --- | --- | --- | +| `@pocketjs/framework/net` | none (support module: types, `AbortController`, `AbortSignal`, `URL`, `NetworkError`, `getNetworkLimits`) | — | delivered | +| `@pocketjs/framework/net/http` `fetch`, `Headers`, `Request`, `Response` | `globalThis.net` — `contracts/spec/net.ts` | `network.http.client` (+ `.tls`) | plaintext + TLS; C/Rust cores, sim/web/ESP-IDF hosts | +| `@pocketjs/framework/net/http` `serve` | `globalThis.httpd` — `contracts/spec/httpd.ts` | `network.http.server` (+ `.tls`) | staged contract, implemented in the C core and sim host | +| `@pocketjs/framework/net/websocket` `connect` | `globalThis.ws` — `contracts/spec/ws.ts` | `network.websocket.client` (+ `.tls`) | implemented in the C core and the sim and ESP-IDF hosts | -| Layer | Upstream artifact | Owns | -| --- | --- | --- | -| SDK | `framework/src/net-api.ts` | `fetch`, `PocketResponse`, validation, lazy Promise delivery | -| Spec | `contracts/spec/net.ts` | five ops, two event shapes, buffer ownership, limits, portable errors, tick timing | -| Core | `engine/crates/pocket-net` | handles, request lifecycle, limits, event batches, completed bodies, transport interface | -| Deterministic host | `hosts/sim/net.ts` | fixture routes and virtual-tick completions for conformance tests | -| Browser host | `hosts/web/net.js` | browser `fetch` transport, bounded streaming read, redirects, tick staging | - -The physical HTTP implementation belongs to the host that owns the network -resource. PocketJS does not choose one transport library for every runtime. -A desktop runtime can adapt `ureq`, an ESP runtime can adapt -`esp_http_client`, and an Apple host can adapt `URLSession`; none of those -libraries become part of the guest contract or the transport-neutral core. - -A product runtime outside this repository keeps its adapter in that runtime's -repository. An adapter belongs under `hosts//` here only when PocketJS -itself owns and tests that host. The framework SDK, canonical spec, reference -core, and deterministic sim stay upstream because every host must agree on -them. - -## Native transport boundary - -`pocket-net` asks the host for only three operations: - -```rust -pub trait HttpTransport { - fn start(&mut self, request: HttpRequest) -> Result<(), NetFailure>; - fn cancel(&mut self, handle: i32); - fn drain(&mut self, completions: &mut Vec); -} -``` +The capability ids are registered in `contracts/spec/platforms.ts`. **No stock +target advertises them yet**: a target appends an id only when its native host +ships and tests the module. Importing a module +never grants access; the host's immutable policy (connect/listen rules, +`insecureTransport`, `localNetwork`) is checked again on every command. -`start` hands an owned request to a worker or native async facility and must -return promptly. `drain` is non-blocking and is called by the host once at a -tick boundary. Network threads never call QuickJS. The reference core turns -drained completions into one JSON event batch; the guest consumes that batch -during its next normal turn. - -`NetSurface` — the one-line `globalThis.net` install on `pocket-mod` hosts — -is the crate's `mount` feature (default). A host with its own QuickJS wiring -depends with `default-features = false` and drives `NetCore` directly, so the -MCU build never compiles an engine it doesn't use (the `pocket-fs` pattern). - -For a runtime using `NetSurface`, the host loop is: - -```text -transport threads work independently - ↓ -net.begin_tick() drain completed transport work - ↓ -guest.frame(...) framework service pump calls net.poll() if needed - ↓ -guest job drain fetch Promise reactions run -``` +## Ownership -There is no idle native polling. The framework service-pump set is normally -empty. The first pending `fetch` registers the NET pump; the final completion -removes it. While requests are pending there is one `poll()` FFI call per -guest tick, and that call drains the whole visible batch rather than one event -per crossing. - -## Bounded whole responses - -V1 resolves `fetch` only after the response body is complete. The transport -still reads incrementally and must stop as soon as `maxBytes` is exceeded; -the reference core checks the final size again before making it visible. -Consequently a slow or large response does not block the guest and cannot -grow without bound, but V1 is not suitable for media downloads or other -payloads that fundamentally require streaming. - -| Limit | V1 value | -| --- | ---: | -| Concurrent requests | 2 | -| Request body | 64 KiB | -| Response body default | 128 KiB | -| Response body absolute maximum | 256 KiB | -| Headers | 32 fields / 8 KiB | -| Timeout | 30 s default / 120 s maximum | -| Redirects | 3 | - -Two concurrent requests bound TLS buffers, worker state, and completed-body -memory while covering the usual foreground request plus asset/config request. -The response cap is selected per call so a small JSON endpoint can use a much -tighter budget than the global ceiling. - -## Method set - -V1 accepts `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`, and `OPTIONS`. -These are the common application methods that portable embedded HTTP clients -can express. `CONNECT` creates a tunnel and `TRACE` has distinct security and -proxy semantics, so neither belongs in an app-level fetch module. Arbitrary -extension methods can be added later only when more than one real host needs -them; keeping a closed set today lets every target make the same promise. - -## Body ownership - -The request body is borrowed only for the synchronous `net.start` call and is -copied into host-owned memory before that call returns. A done event includes -the exact response byte count. The guest allocates one exactly-sized -`ArrayBuffer`, then `net.take(handle, buffer)` copies into it and deletes the -core's copy. This makes ownership explicit and keeps the ABI independent of a -specific QuickJS wrapper's object-lifetime rules. - -## Errors and HTTP status - -Transport failures reject with `NetError` and a portable `code` such as -`dns`, `connect`, `tls`, `timeout`, `redirect`, or `response_too_large`. -An HTTP 404 or 500 is a successful HTTP exchange: `fetch` resolves, -`response.status` carries the code, and `response.ok` is false. This preserves -the useful part of browser fetch behavior without importing its larger object -model. +| Layer | Artifact | Owns | +| --- | --- | --- | +| SDK | `framework/src/net/*.ts` | Fetch-shaped objects, body locking, `BodyStream` over `readInto`, the per-module guest binding (one `poll` per tick from the service pump), Promise settlement, `NetworkError` | +| Spec | `contracts/spec/{net,ws,httpd}.ts` | op codes (append-only), event shapes, metadata JSON, portable ceilings, the shared error vocabulary; generated mirrors `engine/core/src/spec.rs` and `engine/net/include/pocketjs/net/spec.h` (drift-guarded by `tests/contract.ts`) | +| C core | `engine/net` | HTTP/1.1 client and server, RFC 6455 client, strict framing, bounded queues, policy, tick queues, and the TLS handshake state machine; a `pnet_driver_ops` socket driver (`drivers/posix`) and an optional `pnet_tls_ops` TLS provider (`drivers/openssl`, ESP-TLS) are the only host interfaces | +| Rust core | `engine/crates/pocket-net` | The HTTP Client core for Rust hosts over an `HttpClientBackend`; `mount` installs the six v2 ops through rquickjs | +| Deterministic hosts | `hosts/sim/{net,ws,httpd}.ts` | fixture routes/peers/injected requests with virtual-tick visibility for the SDK tests | +| Browser host | `hosts/web/net.js` | browser `fetch` behind the v2 ops (Browser profile: no redirect following, TLS by the browser) | +| ESP-IDF host | `hosts/esp-idf` | QuickJS-ng owner task, network task, bindings, AtomS3R/Tab5 bring-up, the hardware smoke | + +## TLS + +TLS is an add-on capability per protocol role (`network.http.client.tls`, +`network.websocket.client.tls`, …). A host advertises the `"tls"` feature — +and `https:`/`wss:` become usable — only when it supplies a **TlsProvider** +(`pnet_tls_ops`) to `pnet_runtime_create_tls`; there is never a plaintext +fallback. The core owns the connect deadline, cancellation and the policy; +the provider owns host trust, entropy and the wire. `serverName` equals the +authorized hostname and is both the SNI sent and the DNS-ID/IP-ID the +certificate must match (TLS 1.2 minimum, renegotiation and 0-RTT off). +Before any I/O, a verifying connection fails closed with +`tls_clock_untrusted` when the platform reports the wall clock untrusted +(the ESP host requires an SNTP/RTC sync first). Handshake failures map to the +four stable codes `tls_certificate_invalid`, `tls_hostname_mismatch`, +`tls_handshake_failed` and `tls_clock_untrusted`. + +Providers in the tree: `engine/net/drivers/openssl` (the reference +`NativeTlsProvider` for POSIX, and the peer for the conformance suite) and +`hosts/esp-idf/components/pocketjs_net_esptls` (ESP-TLS + the IDF certificate +bundle). The desktop conformance harness (`engine/net/test/tls_test.c`) +covers a valid chain, unknown CA, expired cert, hostname mismatch, an +untrusted clock, the development-insecure refusal and a WSS echo against an +in-process OpenSSL PKI. + +## Delivery + +Network facts enter the guest only at frame boundaries. The host runs the +tick boundary (`pnet_runtime_begin_tick()` in C, `NetCore::begin_tick()` in +Rust, `beginFrame()` in the browser host) **before** each `frame()`; that +freezes the visible set: completed events plus one `readable` watermark per +handle with new bytes, inserted ahead of that handle's `end`. Inside +`frame()` the framework service pump calls each mounted module's `poll()` +once, and the SDK copies body bytes with `readInto` in the same call graph. +Promise reactions run in the same tick's job drain. **The upper bound for a +network round trip to reach application code is one frame period**; the +per-tick budget (`maxEventsPerTick`, `maxTickBytes`) leaves excess events +queued natively in sequence order for the next tick. + +Body bytes never live in JS until read: the native receive queue +(`queueBytes`, default 32 KiB, host-tightened on MCUs) is the backpressure +window — when it is full the core stops reading the socket and TCP flow +control holds the peer. `text()`, `json()` and `arrayBuffer()` are SDK +helpers over the same path with an aggregate cap (`response_too_large`). + +## Errors + +Every failure is a `NetworkError` with a stable `code` from +`contracts/spec/net.ts` and a derived `category`: + +| Category | Codes | +| --- | --- | +| runtime | `cancelled` `timeout` `closed` `invalid_request` `invalid_state` `busy` `resource_limit` `unsupported` `permission_denied` `unavailable` | +| resolver | `dns` | +| transport | `connect` `address_in_use` | +| tls | `tls_certificate_invalid` `tls_hostname_mismatch` `tls_handshake_failed` `tls_clock_untrusted` | +| protocol | `redirect` `response_too_large` `protocol` `websocket_handshake_failed` `websocket_protocol_error` `message_too_large` | + +Platform codes travel in `causeCode`; HTTP status of a failed WebSocket +handshake in `reasonCode`. HTTP 4xx/5xx are successful exchanges. + +## Limits + +Spec constants are portable ceilings; each host reports its tightened values +through `limits()` (`getNetworkLimits()` in the SDK). The ESP-IDF host +defaults to 4 HTTP handles, 16 KiB receive queues (64 KiB max), 256 KiB +aggregate default, 64 KiB per-tick bytes, 4 WebSocket sockets with 64 KiB +messages, 8 server connections / 4 inflight requests, and a 1 MiB core heap +cap; the measured smoke steady state is documented in +[hosts/esp-idf/README.md](../hosts/esp-idf/README.md). + +## Headless hosts + +A host without a UI still ticks `frame()`. `mountHeadless()` from +`@pocketjs/framework/headless` installs the frame transaction prefix +(virtual clock → service pumps → effect delivery → optional app hook) +without a renderer, so a display-less device runs the same network delivery +model. The ESP-IDF smoke firmware uses it. + +## Testing + +- `bun test tests/net.test.ts tests/net-httpd.test.ts tests/net-websocket.test.ts tests/net-web.test.js` — SDK against the deterministic hosts. +- `cmake -S engine/net -B engine/net/build && cmake --build engine/net/build && ctest --test-dir engine/net/build` — C core unit tests, the socket harness, and (when OpenSSL is present) the TLS conformance suite, all under ASan/UBSan. +- `cargo test -p pocket-net --manifest-path engine/Cargo.toml` — Rust core. +- `bun tools/net-peer.ts` + `hosts/esp-idf/examples/net-smoke` — the hardware gate against an independent peer and board-to-board. diff --git a/docs/RUNTIMES.md b/docs/RUNTIMES.md index 81211004..7b809f3b 100644 --- a/docs/RUNTIMES.md +++ b/docs/RUNTIMES.md @@ -146,7 +146,7 @@ The grammar is implemented once, as infrastructure every runtime reuses: | Crate | Role | | --- | --- | | `pocket-mod` | Guest hosting: QuickJS realm lifecycle, surface mounting (`mount("ui", ops)`), per-tick pump (frame call + job drain + timers), console, hot reload. The "mod runtime" capability, as a library. | -| `pocket-net` | Transport-neutral NET core and `globalThis.net` surface: validates bounded HTTP requests, owns handles/bodies and tick event batches, and accepts a host-owned `HttpTransport` adapter. See [NET.md](./NET.md). | +| `pocket-net` | The Rust HTTP Client core behind `globalThis.net` (spec v2): validates requests against the immutable policy, owns handles, bounded receive queues, the tick-boundary visible set and `readInto`, and drives a host-owned `HttpClientBackend`. The C twin for MCU hosts is `engine/net`. See [NET.md](./NET.md). | | `pocket-ui-wgpu` | The `ui` surface, desktop edition: feeds paks to `pocketjs-core`, exposes the 17 `HostOps` ops to the guest, renders the DrawList through wgpu into any render target — a window (standalone app host) or an overlay pass over a 3D scene (game HUD). | | `pocket-widget` | The desktop-widget capability (WIDGET.md): a widget window shell whose guest ticks at a fixed rate while GPU frames render on demand, embedded `ui` surfaces bound onto meshes, and cursor-ray part picking mapped to declared inputs. `pocket-stage` is the first runtime on it; its bundled PSP stage runs admitted fixed-viewport apps unmodified. | | `pocketjs-core` | The 2D UI core (unchanged; now viewport-parameterized). | diff --git a/engine/core/src/spec.rs b/engine/core/src/spec.rs index a1420e62..581f750b 100644 --- a/engine/core/src/spec.rs +++ b/engine/core/src/spec.rs @@ -550,36 +550,161 @@ pub mod fs { pub const MAX_DIR_ENTRIES: usize = 256; } -/// NET module boundary (contracts/spec/net.ts — `globalThis.net`). -/// Bounded whole-response HTTP; completions batch to tick boundaries. +/// NET module boundary (contracts/spec/net.ts — `globalThis.net`, spec v2). +/// Streaming HTTP/1.1 client; completions batch to tick boundaries. pub mod net { + pub const SPEC_MAJOR: u32 = 2; + pub const SPEC_MINOR: u32 = 0; pub const OP_START: u8 = 1; pub const OP_TAKE: u8 = 2; pub const OP_CANCEL: u8 = 3; pub const OP_POLL: u8 = 4; pub const OP_LAST_ERROR: u8 = 5; - pub const MAX_INFLIGHT: usize = 2; - pub const MAX_REQUEST_BYTES: usize = 65536; - pub const DEFAULT_RESPONSE_BYTES: usize = 131072; - pub const MAX_RESPONSE_BYTES: usize = 262144; - pub const MAX_HEADERS: usize = 32; - pub const MAX_HEADER_BYTES: usize = 8192; + pub const OP_READ_INTO: u8 = 6; + pub const OP_LIMITS: u8 = 7; + pub const OP_WRITE: u8 = 8; + pub const OP_END_BODY: u8 = 9; + pub const MAX_INFLIGHT: usize = 8; + pub const MAX_REQUEST_BYTES: usize = 262144; + pub const DEFAULT_QUEUE_BYTES: usize = 32768; + pub const MAX_QUEUE_BYTES: usize = 262144; + pub const DEFAULT_AGGREGATE_BYTES: usize = 1048576; + pub const MAX_AGGREGATE_BYTES: usize = 8388608; + pub const MAX_EVENTS_PER_TICK: usize = 128; + pub const MAX_TICK_BYTES: usize = 262144; + pub const MAX_HEADERS: usize = 64; + pub const MAX_HEADER_BYTES: usize = 16384; pub const DEFAULT_TIMEOUT_MS: u32 = 30000; pub const MAX_TIMEOUT_MS: u32 = 120000; - pub const MAX_REDIRECTS: usize = 3; - pub const METHODS: [&str; 7] = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]; - pub const EVENT_DONE: &str = "done"; + pub const MAX_REDIRECTS: usize = 5; + pub const TLS_MIN_VERSION: &str = "1.2"; + pub const METHODS_FORBIDDEN: [&str; 2] = ["CONNECT", "TRACE"]; + pub const EVENT_HEADERS: &str = "headers"; + pub const EVENT_READABLE: &str = "readable"; + pub const EVENT_END: &str = "end"; pub const EVENT_ERROR: &str = "error"; - pub const ERROR_UNAVAILABLE: &str = "unavailable"; + pub const EVENT_DRAIN: &str = "drain"; + /// Error vocabulary shared by net, ws and httpd. pub const ERROR_INVALID_REQUEST: &str = "invalid_request"; + pub const ERROR_INVALID_STATE: &str = "invalid_state"; + pub const ERROR_UNSUPPORTED: &str = "unsupported"; + pub const ERROR_PERMISSION_DENIED: &str = "permission_denied"; pub const ERROR_BUSY: &str = "busy"; + pub const ERROR_RESOURCE_LIMIT: &str = "resource_limit"; pub const ERROR_DNS: &str = "dns"; pub const ERROR_CONNECT: &str = "connect"; - pub const ERROR_TLS: &str = "tls"; + pub const ERROR_ADDRESS_IN_USE: &str = "address_in_use"; + pub const ERROR_CLOSED: &str = "closed"; pub const ERROR_TIMEOUT: &str = "timeout"; + pub const ERROR_TLS_CERTIFICATE_INVALID: &str = "tls_certificate_invalid"; + pub const ERROR_TLS_HOSTNAME_MISMATCH: &str = "tls_hostname_mismatch"; + pub const ERROR_TLS_HANDSHAKE_FAILED: &str = "tls_handshake_failed"; + pub const ERROR_TLS_CLOCK_UNTRUSTED: &str = "tls_clock_untrusted"; pub const ERROR_REDIRECT: &str = "redirect"; pub const ERROR_RESPONSE_TOO_LARGE: &str = "response_too_large"; pub const ERROR_PROTOCOL: &str = "protocol"; + pub const ERROR_WEBSOCKET_HANDSHAKE_FAILED: &str = "websocket_handshake_failed"; + pub const ERROR_WEBSOCKET_PROTOCOL_ERROR: &str = "websocket_protocol_error"; + pub const ERROR_MESSAGE_TOO_LARGE: &str = "message_too_large"; pub const ERROR_CANCELLED: &str = "cancelled"; pub const ERROR_OTHER: &str = "other"; + pub const ERROR_UNAVAILABLE: &str = "unavailable"; +} + +/// WS module boundary (contracts/spec/ws.ts — `globalThis.ws`, spec v2). +/// RFC 6455 client; messages batch to tick boundaries. +pub mod ws { + pub const SPEC_MAJOR: u32 = 2; + pub const SPEC_MINOR: u32 = 0; + pub const OP_CONNECT: u8 = 1; + pub const OP_SEND: u8 = 2; + pub const OP_RECEIVE_INTO: u8 = 3; + pub const OP_CLOSE: u8 = 4; + pub const OP_TERMINATE: u8 = 5; + pub const OP_BUFFERED_AMOUNT: u8 = 6; + pub const OP_POLL: u8 = 7; + pub const OP_LAST_ERROR: u8 = 8; + pub const OP_LIMITS: u8 = 9; + pub const SEND_ACCEPTED: i32 = 0; + pub const SEND_ACCEPTED_HIGH_WATER: i32 = 1; + pub const SEND_CLOSED: i32 = -1; + pub const SEND_BACKPRESSURE: i32 = -2; + pub const SEND_INVALID: i32 = -3; + pub const OPCODE_TEXT: u8 = 1; + pub const OPCODE_BINARY: u8 = 2; + pub const OPCODE_PING: u8 = 9; + pub const OPCODE_PONG: u8 = 10; + pub const EVENT_OPEN: &str = "open"; + pub const EVENT_MESSAGE: &str = "message"; + pub const EVENT_PING: &str = "ping"; + pub const EVENT_PONG: &str = "pong"; + pub const EVENT_DRAIN: &str = "drain"; + pub const EVENT_ERROR: &str = "error"; + pub const EVENT_CLOSE: &str = "close"; + pub const BLOB_KEY: &str = "$b"; + pub const FORBIDDEN_HEADERS: [&str; 9] = ["host", "connection", "upgrade", "content-length", "sec-websocket-key", "sec-websocket-version", "sec-websocket-protocol", "sec-websocket-extensions", "sec-websocket-accept"]; + pub const MAX_SOCKETS: usize = 8; + pub const MAX_MESSAGE_BYTES: usize = 1048576; + pub const MAX_RECEIVE_QUEUE_BYTES: usize = 1048576; + pub const MAX_RECEIVE_QUEUE_MESSAGES: usize = 64; + pub const MAX_SEND_QUEUE_BYTES: usize = 1048576; + pub const SEND_HIGH_WATER_BYTES: usize = 262144; + pub const SEND_LOW_WATER_BYTES: usize = 65536; + pub const MAX_HANDSHAKE_HEADERS: usize = 64; + pub const MAX_HANDSHAKE_HEADER_BYTES: usize = 16384; + pub const MAX_EVENTS_PER_TICK: usize = 128; + pub const MAX_TICK_BYTES: usize = 262144; + pub const DEFAULT_CONNECT_MS: u32 = 30000; + pub const MAX_CONNECT_MS: u32 = 120000; + pub const DEFAULT_CLOSE_MS: u32 = 5000; + pub const CONTROL_PAYLOAD_MAX: usize = 125; +} + +/// HTTPD module boundary (contracts/spec/httpd.ts — `globalThis.httpd`, spec v2). +/// HTTP/1.1 server; requests batch to tick boundaries. +pub mod httpd { + pub const SPEC_MAJOR: u32 = 2; + pub const SPEC_MINOR: u32 = 0; + pub const OP_LISTEN: u8 = 1; + pub const OP_STOP: u8 = 2; + pub const OP_RESPOND: u8 = 3; + pub const OP_WRITE: u8 = 4; + pub const OP_END_BODY: u8 = 5; + pub const OP_READ_INTO: u8 = 6; + pub const OP_ABORT: u8 = 7; + pub const OP_POLL: u8 = 8; + pub const OP_LAST_ERROR: u8 = 9; + pub const OP_LIMITS: u8 = 10; + pub const SEND_ACCEPTED: i32 = 0; + pub const SEND_INVALID_REQUEST: i32 = -1; + pub const SEND_BACKPRESSURE: i32 = -2; + pub const SEND_INVALID: i32 = -3; + pub const EVENT_LISTENING: &str = "listening"; + pub const EVENT_CLOSED: &str = "closed"; + pub const EVENT_ERROR: &str = "error"; + pub const EVENT_REQUEST: &str = "request"; + pub const EVENT_READABLE: &str = "readable"; + pub const EVENT_END: &str = "end"; + pub const EVENT_DRAIN: &str = "drain"; + pub const EVENT_ABORTED: &str = "aborted"; + pub const MAX_SERVERS: usize = 2; + pub const MAX_CONNECTIONS: usize = 16; + pub const MAX_INFLIGHT: usize = 8; + pub const MAX_BACKLOG: usize = 16; + pub const MAX_HEADERS: usize = 64; + pub const MAX_HEADER_BYTES: usize = 16384; + pub const MAX_TARGET_BYTES: usize = 2048; + pub const DEFAULT_REQUEST_QUEUE_BYTES: usize = 32768; + pub const MAX_REQUEST_QUEUE_BYTES: usize = 262144; + pub const MAX_SEND_QUEUE_BYTES: usize = 262144; + pub const SEND_HIGH_WATER_BYTES: usize = 131072; + pub const SEND_LOW_WATER_BYTES: usize = 32768; + pub const MAX_EVENTS_PER_TICK: usize = 128; + pub const MAX_TICK_BYTES: usize = 262144; + pub const DEFAULT_HEADER_MS: u32 = 10000; + pub const DEFAULT_BODY_IDLE_MS: u32 = 30000; + pub const DEFAULT_HANDLER_MS: u32 = 30000; + pub const DEFAULT_KEEP_ALIVE_MS: u32 = 15000; + pub const DEFAULT_CLOSE_MS: u32 = 5000; + pub const MAX_TIMEOUT_MS: u32 = 120000; } diff --git a/engine/crates/pocket-net/src/lib.rs b/engine/crates/pocket-net/src/lib.rs index 21d41688..08a9dc65 100644 --- a/engine/crates/pocket-net/src/lib.rs +++ b/engine/crates/pocket-net/src/lib.rs @@ -1,65 +1,90 @@ -//! `pocket-net` — the transport-neutral core and mounted surface for the -//! PocketJS NET module (`contracts/spec/net.ts`). +//! pocket-net — the reference HTTP Client core behind `globalThis.net` +//! (contracts/spec/net.ts v2) for Rust hosts. //! -//! This crate owns handles, validation, limits, tick-boundary event batches, -//! response-body ownership and portable errors. It deliberately owns no DNS, -//! socket, TLS, HTTP parser, executor or thread. A runtime supplies an -//! [`HttpTransport`] implemented with the platform facility it already owns -//! (for example ESP-IDF HTTP, ureq, NSURLSession, or an application service). -//! The transport may work on other threads, but [`NetCore::begin_tick`] is -//! the only point at which its completions enter the single-threaded core. +//! The crate owns the guest-visible policy of the module — handle table, +//! immutable endpoint policy, per-handle bounded receive queues, the tick +//! boundary that freezes the visible set (`begin_tick`), the one `poll` batch +//! per tick, `readInto`, cancellation and the stable error vocabulary — and +//! delegates the wire to a host-supplied [`HttpClientBackend`]. A backend +//! implements HTTP/1.1 (or wraps a platform client) and reports streaming +//! completions; it never sees QuickJS. The `mount` feature installs the six +//! v2 ops on a `pocket_mod::Guest`. //! -//! Feature `mount` (default) adds [`NetSurface`], the pocket-mod adapter that -//! installs the five ops as `globalThis.net`. A host with its own QuickJS -//! wiring turns it off (`default-features = false`) and drives [`NetCore`] -//! directly — the MCU build then never compiles an engine it doesn't use. +//! Frame contract: the host calls +//! [`NetCore::begin_tick`] before every guest `frame()`; the framework +//! service pump then calls `poll` exactly once; completions that arrive +//! after `begin_tick` wait for the next tick. +//! +//! The portable C implementation of the same boundary (engine/net) is what +//! the ESP-IDF host links; both speak the spec verbatim. -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, VecDeque}; use pocketjs_core::spec::net as spec; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; -/// Fully validated request handed to a host-owned transport. The body is an -/// owned copy; a transport may move it to a worker without retaining JS data. +/// A request handed to the backend after the core validated it. #[derive(Clone, Debug, PartialEq, Eq)] pub struct HttpRequest { pub handle: i32, pub url: String, pub method: String, + /// Lowercased names; framing/connection headers already removed. pub headers: BTreeMap, pub body: Vec, - pub timeout_ms: u32, - pub max_bytes: usize, - pub max_redirects: usize, + pub connect_ms: u32, + pub headers_ms: u32, + pub idle_ms: u32, + pub total_ms: u32, + pub redirect: RedirectMode, + pub max_redirects: u32, + pub max_body_bytes: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RedirectMode { + Follow, + Manual, + Error, } -/// Normalized failure crossing from a host transport into the core. +/// A stable failure: `code` is clamped onto the spec vocabulary. #[derive(Clone, Debug, PartialEq, Eq)] pub struct NetFailure { pub code: String, pub message: String, + pub cause: Option, } impl NetFailure { pub fn new(code: impl Into, message: impl Into) -> Self { + let code = code.into(); Self { - code: normalize_error_code(&code.into()).to_string(), + code: normalize_error_code(&code).to_string(), message: message.into(), + cause: None, } } } -/// A transport completion. Response headers must already be normalized to -/// lowercase, with repeated fields combined according to that transport's -/// HTTP implementation. Cookie storage is outside the v1 contract. +/// Streaming completions a backend produces for a handle, in order: +/// `Headers → Body* → End` or `… → Error`. #[derive(Clone, Debug, PartialEq, Eq)] -pub enum TransportCompletion { - Done { +pub enum BackendEvent { + Headers { handle: i32, status: u16, url: String, headers: BTreeMap, - body: Vec, + redirected: bool, + length: Option, + }, + Body { + handle: i32, + chunk: Vec, + }, + End { + handle: i32, }, Error { handle: i32, @@ -67,616 +92,1071 @@ pub enum TransportCompletion { }, } -/// The only host-specific boundary in the reference implementation. -/// -/// `start` must return promptly after handing work to its native async -/// mechanism or worker. `drain` is called once at a host tick boundary and -/// must not block. Neither method may call into QuickJS. -pub trait HttpTransport { - fn start(&mut self, request: HttpRequest) -> std::result::Result<(), NetFailure>; +/// The host-specific wire layer. The core calls it only from the owner +/// thread's `start`/`cancel`/`begin_tick`; a backend that runs I/O elsewhere +/// hands results over through `drain` at the tick boundary. +pub trait HttpClientBackend { + /// Begin the exchange; refusal is synchronous (`resource_limit` etc.). + fn start(&mut self, request: HttpRequest) -> Result<(), NetFailure>; + /// Best-effort cancellation; a later completion for the handle is dropped. fn cancel(&mut self, handle: i32); - fn drain(&mut self, completions: &mut Vec); + /// Move every completed event into `out` (tick boundary). + fn drain(&mut self, out: &mut Vec); + /// Whether the transport can carry TLS (advertises the "tls" feature). + fn supports_tls(&self) -> bool { + false + } + /// The backend stops reading a handle whose queue is at capacity and + /// resumes when told; the default ignores backpressure hints. + fn set_paused(&mut self, _handle: i32, _paused: bool) {} +} + +// --------------------------------------------------------------------------- +// Policy — immutable host build inputs, never passed through an op +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NetPolicy { + #[serde(default)] + pub connect: Vec, + #[serde(default)] + pub insecure_transport: bool, + #[serde(default)] + pub local_network: bool, + #[serde(default)] + pub allow_invalid_tls_for_development: bool, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct ConnectRule { + pub protocol: String, + pub host: String, + pub port: PortRule, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(untagged)] +pub enum PortRule { + Single(u16), + Range { min: u16, max: u16 }, +} + +impl NetPolicy { + pub fn parse(json: &str) -> Result { + let policy: NetPolicy = serde_json::from_str(json).map_err(|e| e.to_string())?; + for rule in &policy.connect { + if !matches!(rule.protocol.as_str(), "http" | "https" | "ws" | "wss") { + return Err(format!("unknown protocol {}", rule.protocol)); + } + if rule.host.is_empty() { + return Err("empty host".into()); + } + if let PortRule::Range { min, max } = rule.port { + if min == 0 || min > max { + return Err("invalid port range".into()); + } + } + } + Ok(policy) + } + + /// Everything allowed on plaintext HTTP to loopback (tests, dev hosts). + pub fn permissive() -> Self { + Self { + connect: vec![ConnectRule { + protocol: "http".into(), + host: "*".into(), + port: PortRule::Range { min: 1, max: 65535 }, + }], + insecure_transport: true, + local_network: true, + allow_invalid_tls_for_development: false, + } + } + + pub fn allows(&self, protocol: &str, host: &str, port: u16) -> bool { + if matches!(protocol, "http" | "ws") && !self.insecure_transport { + return false; + } + self.connect.iter().any(|rule| { + rule.protocol == protocol + && match rule.port { + PortRule::Single(p) => p == port, + PortRule::Range { min, max } => (min..=max).contains(&port), + } + && host_matches(&rule.host, host) + }) + } +} + +fn host_matches(rule: &str, host: &str) -> bool { + if rule == "*" { + return true; + } + if let Some(suffix) = rule.strip_prefix('*') { + // "*.example.com" matches exactly one non-empty label. + return host.len() > suffix.len() + && host.ends_with(suffix) + && !host[..host.len() - suffix.len()].is_empty() + && !host[..host.len() - suffix.len()].contains('.'); + } + rule.eq_ignore_ascii_case(host) +} + +// --------------------------------------------------------------------------- +// Limits +// --------------------------------------------------------------------------- + +/// Host-tightened limits; `default()` is the spec ceiling. +#[derive(Clone, Debug)] +pub struct NetLimits { + pub max_inflight: usize, + pub max_request_bytes: usize, + pub default_queue_bytes: usize, + pub max_queue_bytes: usize, + pub default_aggregate_bytes: usize, + pub max_aggregate_bytes: usize, + pub max_events_per_tick: usize, + pub max_tick_bytes: usize, + pub max_headers: usize, + pub max_header_bytes: usize, + pub default_timeout_ms: u32, + pub max_timeout_ms: u32, + pub max_redirects: u32, +} + +impl Default for NetLimits { + fn default() -> Self { + Self { + max_inflight: spec::MAX_INFLIGHT, + max_request_bytes: spec::MAX_REQUEST_BYTES, + default_queue_bytes: spec::DEFAULT_QUEUE_BYTES, + max_queue_bytes: spec::MAX_QUEUE_BYTES, + default_aggregate_bytes: spec::DEFAULT_AGGREGATE_BYTES, + max_aggregate_bytes: spec::MAX_AGGREGATE_BYTES, + max_events_per_tick: spec::MAX_EVENTS_PER_TICK, + max_tick_bytes: spec::MAX_TICK_BYTES, + max_headers: spec::MAX_HEADERS, + max_header_bytes: spec::MAX_HEADER_BYTES, + default_timeout_ms: spec::DEFAULT_TIMEOUT_MS, + max_timeout_ms: spec::MAX_TIMEOUT_MS, + max_redirects: spec::MAX_REDIRECTS as u32, + } + } } +impl NetLimits { + fn clamp(mut self) -> Self { + let d = NetLimits::default(); + self.max_inflight = self.max_inflight.clamp(1, d.max_inflight); + self.max_request_bytes = self.max_request_bytes.clamp(1, d.max_request_bytes); + self.max_queue_bytes = self.max_queue_bytes.clamp(1, d.max_queue_bytes); + self.default_queue_bytes = self.default_queue_bytes.clamp(1, self.max_queue_bytes); + self.max_aggregate_bytes = self.max_aggregate_bytes.clamp(1, d.max_aggregate_bytes); + self.default_aggregate_bytes = self.default_aggregate_bytes.clamp(1, self.max_aggregate_bytes); + self.max_events_per_tick = self.max_events_per_tick.clamp(1, d.max_events_per_tick); + self.max_tick_bytes = self.max_tick_bytes.clamp(1, d.max_tick_bytes); + self.max_headers = self.max_headers.clamp(1, d.max_headers); + self.max_header_bytes = self.max_header_bytes.clamp(1, d.max_header_bytes); + self.max_timeout_ms = self.max_timeout_ms.clamp(1, d.max_timeout_ms); + self.default_timeout_ms = self.default_timeout_ms.clamp(1, self.max_timeout_ms); + self.max_redirects = self.max_redirects.min(d.max_redirects); + self + } +} + +// --------------------------------------------------------------------------- +// Core +// --------------------------------------------------------------------------- + #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -struct RequestMeta { +struct StartMeta { url: String, method: String, + #[serde(default)] headers: BTreeMap, - timeout_ms: u32, - max_bytes: usize, + #[serde(default)] + queue_bytes: Option, + #[serde(default)] + max_body_bytes: Option, + #[serde(default)] + timeouts: Option, + #[serde(default)] + redirect: Option, + #[serde(default)] + max_redirects: Option, + #[serde(default)] + tls: Option, } -#[derive(Serialize)] -#[serde(tag = "t")] -enum GuestEvent { - #[serde(rename = "done")] - Done { - #[serde(rename = "h")] - handle: i32, - status: u16, - url: String, - headers: BTreeMap, - bytes: usize, - }, - #[serde(rename = "error")] - Error { - #[serde(rename = "h")] - handle: i32, - code: String, - message: String, - }, +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Timeouts { + connect_ms: Option, + headers_ms: Option, + idle_ms: Option, + total_ms: Option, } -struct Inflight { - max_bytes: usize, +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct TlsMeta { + verification: Option, +} + +/// One rendered event waiting for a tick boundary or a poll. +struct QueuedEvent { + handle: i32, + /// `readable` insertions go before this event for the same handle. + barrier: bool, + weight: usize, + json: String, +} + +struct Handle { + head_pushed: bool, + terminal: bool, + queue_bytes: usize, + max_body_bytes: Option, + body_total: usize, + queue: VecDeque, + visible_bytes: usize, + dirty: bool, + paused: bool, } -/// Transport-neutral NET state machine. It is intentionally independent of -/// QuickJS; [`NetSurface`] below is only the namespace adapter. -pub struct NetCore { - transport: T, - inflight: HashMap, - bodies: HashMap>, - visible: Vec, +pub struct NetCore { + backend: B, + policy: NetPolicy, + limits: NetLimits, + development_build: bool, + handles: BTreeMap, next_handle: i32, + pending: VecDeque, + visible: VecDeque, last_error: String, + limits_json: String, } -impl NetCore { - pub fn new(transport: T) -> Self { - Self { - transport, - inflight: HashMap::new(), - bodies: HashMap::new(), - visible: Vec::new(), +impl NetCore { + pub fn new(backend: B, policy: NetPolicy) -> Self { + Self::with_limits(backend, policy, NetLimits::default()) + } + + pub fn with_limits(backend: B, policy: NetPolicy, limits: NetLimits) -> Self { + let limits = limits.clamp(); + let mut core = Self { + backend, + policy, + limits, + development_build: false, + handles: BTreeMap::new(), next_handle: 1, + pending: VecDeque::new(), + visible: VecDeque::new(), last_error: String::new(), - } + limits_json: String::new(), + }; + core.limits_json = core.render_limits(); + core + } + + /// Enable `tls.verification = "development-insecure"` when the policy + /// also allows it (never in production builds). + pub fn set_development_build(&mut self, enabled: bool) { + self.development_build = enabled; + } + + pub fn backend_mut(&mut self) -> &mut B { + &mut self.backend + } + + fn render_limits(&self) -> String { + let l = &self.limits; + let features = if self.backend.supports_tls() { "[\"tls\"]" } else { "[]" }; + format!( + "{{\"specMajor\":{},\"specMinor\":{},\"maxInflight\":{},\"maxTlsInflight\":{},\"maxRequestBytes\":{},\ + \"defaultQueueBytes\":{},\"maxQueueBytes\":{},\"defaultAggregateBytes\":{},\"maxAggregateBytes\":{},\ + \"maxEventsPerTick\":{},\"maxTickBytes\":{},\"maxHeaders\":{},\"maxHeaderBytes\":{},\ + \"defaultTimeoutMs\":{},\"maxTimeoutMs\":{},\"maxRedirects\":{},\"tlsMinVersion\":\"{}\",\"features\":{}}}", + spec::SPEC_MAJOR, + spec::SPEC_MINOR, + l.max_inflight, + if self.backend.supports_tls() { l.max_inflight } else { 0 }, + l.max_request_bytes, + l.default_queue_bytes, + l.max_queue_bytes, + l.default_aggregate_bytes, + l.max_aggregate_bytes, + l.max_events_per_tick, + l.max_tick_bytes, + l.max_headers, + l.max_header_bytes, + l.default_timeout_ms, + l.max_timeout_ms, + l.max_redirects, + spec::TLS_MIN_VERSION, + features + ) } - /// Mutable transport access is for host wiring and tests (for example to - /// push channel-backed completions); it never exposes guest state. - pub fn transport_mut(&mut self) -> &mut T { - &mut self.transport + /// `limits()` op: read-only JSON. + pub fn limits(&self) -> &str { + &self.limits_json } + /// `lastError()` op. + pub fn last_error(&self) -> &str { + &self.last_error + } + + /// Live handles (for hosts deciding whether to keep ticking the pump). + pub fn live(&self) -> usize { + self.handles.values().filter(|h| !h.terminal).count() + } + + fn refuse(&mut self, code: &str, message: impl Into) -> i32 { + self.last_error = format!("{}: {}", normalize_error_code(code), message.into()); + -1 + } + + /// `start(metaJson, body)` op: -1 with `lastError()` on refusal. pub fn start(&mut self, meta_json: &str, body: &[u8]) -> i32 { - match self.try_start(meta_json, body) { - Ok(handle) => handle, - Err(failure) => { - self.last_error = format!("{}: {}", failure.code, failure.message); - -1 + if self.live() >= self.limits.max_inflight { + return self.refuse(spec::ERROR_RESOURCE_LIMIT, "too many requests in flight"); + } + if body.len() > self.limits.max_request_bytes { + return self.refuse(spec::ERROR_RESOURCE_LIMIT, "request body too large"); + } + let meta: StartMeta = match serde_json::from_str(meta_json) { + Ok(meta) => meta, + Err(_) => return self.refuse(spec::ERROR_INVALID_REQUEST, "malformed request metadata"), + }; + let (scheme, host, port) = match parse_url(&meta.url) { + Some(parts) => parts, + None => return self.refuse(spec::ERROR_INVALID_REQUEST, "invalid url"), + }; + if scheme != "http" && scheme != "https" { + return self.refuse(spec::ERROR_INVALID_REQUEST, "url must be http: or https:"); + } + if scheme == "https" && !self.backend.supports_tls() { + return self.refuse(spec::ERROR_UNSUPPORTED, "this host does not provide network.http.client.tls"); + } + if !self.policy.allows(scheme, &host, port) { + return self.refuse(spec::ERROR_PERMISSION_DENIED, "endpoint is not an allowed connect rule"); + } + if !is_token(&meta.method) { + return self.refuse(spec::ERROR_INVALID_REQUEST, "invalid method"); + } + let upper = meta.method.to_ascii_uppercase(); + if spec::METHODS_FORBIDDEN.contains(&upper.as_str()) || upper == "TRACK" { + return self.refuse(spec::ERROR_INVALID_REQUEST, "method not allowed"); + } + if (upper == "GET" || upper == "HEAD") && !body.is_empty() { + return self.refuse(spec::ERROR_INVALID_REQUEST, "GET/HEAD cannot carry a body"); + } + let mut headers = BTreeMap::new(); + let mut header_bytes = 0usize; + for (name, value) in &meta.headers { + let lower = name.to_ascii_lowercase(); + if !is_token(&lower) || value.bytes().any(|b| (b < 0x20 && b != b'\t') || b == 0x7f) { + return self.refuse(spec::ERROR_INVALID_REQUEST, format!("invalid header {name}")); + } + if CORE_OWNED_HEADERS.contains(&lower.as_str()) { + continue; + } + header_bytes += lower.len() + value.len() + 4; + headers.insert(lower, value.clone()); + if headers.len() > self.limits.max_headers || header_bytes > self.limits.max_header_bytes { + return self.refuse(spec::ERROR_RESOURCE_LIMIT, "request headers exceed limits"); } } - } - - fn try_start(&mut self, meta_json: &str, body: &[u8]) -> std::result::Result { - if self.inflight.len() >= spec::MAX_INFLIGHT { - return Err(NetFailure::new( - spec::ERROR_BUSY, - format!("at most {} requests may be in flight", spec::MAX_INFLIGHT), - )); + let queue_bytes = meta.queue_bytes.unwrap_or(self.limits.default_queue_bytes); + if queue_bytes == 0 || queue_bytes > self.limits.max_queue_bytes { + return self.refuse(spec::ERROR_INVALID_REQUEST, "invalid queueBytes"); } - if body.len() > spec::MAX_REQUEST_BYTES { - return Err(invalid("request body exceeds 64 KiB")); + let timeouts = meta.timeouts.unwrap_or_default(); + let bounded = |value: Option, fallback: u32| -> Option { + match value { + None => Some(fallback), + Some(v) if v >= 1 && v <= self.limits.max_timeout_ms => Some(v), + Some(_) => None, + } + }; + let (Some(connect_ms), Some(headers_ms), Some(idle_ms), Some(total_ms)) = ( + bounded(timeouts.connect_ms, self.limits.default_timeout_ms), + bounded(timeouts.headers_ms, self.limits.default_timeout_ms), + bounded(timeouts.idle_ms, self.limits.default_timeout_ms), + bounded(timeouts.total_ms, self.limits.max_timeout_ms), + ) else { + return self.refuse(spec::ERROR_INVALID_REQUEST, "invalid timeouts"); + }; + let redirect = match meta.redirect.as_deref() { + None | Some("follow") => RedirectMode::Follow, + Some("manual") => RedirectMode::Manual, + Some("error") => RedirectMode::Error, + Some(_) => return self.refuse(spec::ERROR_INVALID_REQUEST, "invalid redirect"), + }; + let max_redirects = meta.max_redirects.unwrap_or(self.limits.max_redirects); + if max_redirects > self.limits.max_redirects { + return self.refuse(spec::ERROR_INVALID_REQUEST, "invalid maxRedirects"); + } + if let Some(tls) = &meta.tls { + match tls.verification.as_deref() { + None | Some("full") => {} + Some("development-insecure") => { + if !self.development_build || !self.policy.allow_invalid_tls_for_development { + return self.refuse(spec::ERROR_UNSUPPORTED, "development-insecure TLS is not enabled"); + } + } + Some(_) => return self.refuse(spec::ERROR_INVALID_REQUEST, "invalid tls.verification"), + } } - let meta: RequestMeta = - serde_json::from_str(meta_json).map_err(|_| invalid("malformed request metadata"))?; - validate_meta(&meta, body)?; - let handle = self.allocate_handle(); let request = HttpRequest { handle, url: meta.url, method: meta.method, - headers: meta.headers, + headers, body: body.to_vec(), - timeout_ms: meta.timeout_ms, - max_bytes: meta.max_bytes, - max_redirects: spec::MAX_REDIRECTS, + connect_ms, + headers_ms, + idle_ms, + total_ms, + redirect, + max_redirects, + max_body_bytes: meta.max_body_bytes, }; - let max_bytes = request.max_bytes; - // Reserve before submit so a transport that queues work immediately - // cannot race the accounting boundary. Roll back on refusal. - self.inflight.insert(handle, Inflight { max_bytes }); - if let Err(failure) = self.transport.start(request) { - self.inflight.remove(&handle); - return Err(failure); + // Reserve the handle before the backend sees it so a completion + // draining in the same tick cannot race the insertion. + self.handles.insert( + handle, + Handle { + head_pushed: false, + terminal: false, + queue_bytes, + max_body_bytes: meta.max_body_bytes, + body_total: 0, + queue: VecDeque::new(), + visible_bytes: 0, + dirty: false, + paused: false, + }, + ); + if let Err(failure) = self.backend.start(request) { + self.handles.remove(&handle); + return self.refuse(&failure.code, failure.message); } - Ok(handle) + handle } fn allocate_handle(&mut self) -> i32 { loop { let handle = self.next_handle; - self.next_handle = if self.next_handle == i32::MAX { - 1 - } else { - self.next_handle + 1 - }; - if !self.inflight.contains_key(&handle) && !self.bodies.contains_key(&handle) { + self.next_handle = if handle == i32::MAX { 1 } else { handle + 1 }; + if !self.handles.contains_key(&handle) { return handle; } } } - /// Drain non-blocking transport completions at a host tick boundary. - /// Call before the corresponding guest `frame()`; `poll()` during that - /// turn sees the resulting batch and never sees mid-tick transport state. - pub fn begin_tick(&mut self) { - let mut completions = Vec::new(); - self.transport.drain(&mut completions); - for completion in completions { - self.complete(completion); - } - } - - fn complete(&mut self, completion: TransportCompletion) { - match completion { - TransportCompletion::Done { - handle, - status, - url, - headers, - body, - } => { - let Some(request) = self.inflight.remove(&handle) else { - return; // cancelled, stale or duplicate completion - }; - let header_bytes = header_bytes(&headers); - let protocol_error = if !(100..=599).contains(&status) { - Some("invalid HTTP status") - } else if !is_http_url(&url) { - Some("invalid final URL") - } else if headers.len() > spec::MAX_HEADERS - || header_bytes > spec::MAX_HEADER_BYTES - || !valid_headers(&headers) - { - Some("response headers exceed the portable contract") - } else { - None - }; - if let Some(message) = protocol_error { - self.push_error(handle, spec::ERROR_PROTOCOL, message); - } else if body.len() > request.max_bytes || body.len() > spec::MAX_RESPONSE_BYTES { - self.push_error( - handle, - spec::ERROR_RESPONSE_TOO_LARGE, - format!("response exceeded {} bytes", request.max_bytes), - ); - } else { - let bytes = body.len(); - self.bodies.insert(handle, body); - self.visible.push(GuestEvent::Done { - handle, - status, - url, - headers, - bytes, - }); - } - } - TransportCompletion::Error { handle, failure } => { - if self.inflight.remove(&handle).is_none() { - return; - } - self.push_error(handle, &failure.code, failure.message); - } + /// `cancel(handle)` op: the terminal `error{cancelled}` arrives at the + /// next tick; a handle that already ended releases its unread bytes. + pub fn cancel(&mut self, handle: i32) { + let Some(h) = self.handles.get(&handle) else { return }; + if h.terminal { + self.handles.remove(&handle); + return; } + self.backend.cancel(handle); + self.fail(handle, NetFailure::new(spec::ERROR_CANCELLED, "cancelled")); } - fn push_error(&mut self, handle: i32, code: &str, message: impl Into) { - self.visible.push(GuestEvent::Error { + fn fail(&mut self, handle: i32, failure: NetFailure) { + let Some(h) = self.handles.get_mut(&handle) else { return }; + if h.terminal { + return; + } + h.terminal = true; + h.queue.clear(); + h.visible_bytes = 0; + h.dirty = false; + let mut json = format!( + "{{\"t\":\"error\",\"h\":{},\"code\":{},\"message\":{}", handle, - code: normalize_error_code(code).to_string(), - message: message.into(), - }); + json_string(&failure.code), + json_string(&failure.message) + ); + if let Some(cause) = &failure.cause { + json.push_str(",\"causeCode\":"); + json.push_str(&json_string(cause)); + } + json.push('}'); + self.pending.push_back(QueuedEvent { handle, barrier: true, weight: 0, json }); + // The handle stays until the terminal event was polled? No: errors + // carry no bytes, so nothing remains to read; drop it now. + self.handles.remove(&handle); } - pub fn cancel(&mut self, handle: i32) { - self.transport.cancel(handle); - self.inflight.remove(&handle); - self.bodies.remove(&handle); - self.visible.retain(|event| match event { - GuestEvent::Done { handle: h, .. } | GuestEvent::Error { handle: h, .. } => { - *h != handle + /// Tick boundary: drain the backend, apply completions, freeze the + /// visible set under the per-tick budget. Call before every `frame()`. + pub fn begin_tick(&mut self) { + let mut events = Vec::new(); + self.backend.drain(&mut events); + for event in events { + self.apply(event); + } + // Freeze readable watermarks (inserted before the handle's barrier). + let dirty: Vec = self + .handles + .iter() + .filter(|(_, h)| h.dirty && h.head_pushed) + .map(|(k, _)| *k) + .collect(); + for handle in dirty { + let h = self.handles.get_mut(&handle).unwrap(); + h.dirty = false; + h.visible_bytes = h.queue.len(); + let json = format!("{{\"t\":\"readable\",\"h\":{},\"avail\":{}}}", handle, h.visible_bytes); + let ev = QueuedEvent { handle, barrier: false, weight: h.visible_bytes, json }; + let at = self.pending.iter().position(|e| e.handle == handle && e.barrier); + match at { + Some(i) => self.pending.insert(i, ev), + None => self.pending.push_back(ev), } - }); - } - - pub fn take(&mut self, handle: i32) -> Option> { - self.bodies.remove(&handle) + } + // Budget: at least one event per tick, then cut on count or bytes. + let mut events = 0usize; + let mut bytes = 0usize; + while let Some(front) = self.pending.front() { + if events > 0 && (events >= self.limits.max_events_per_tick || bytes + front.weight > self.limits.max_tick_bytes) { + break; + } + let ev = self.pending.pop_front().unwrap(); + events += 1; + bytes += ev.weight; + self.visible.push_back(ev); + } } - pub fn take_into(&mut self, handle: i32, into: &mut [u8]) -> i32 { - let Some(body) = self.bodies.get(&handle) else { - return -1; - }; - if body.len() != into.len() { - return -1; + fn apply(&mut self, event: BackendEvent) { + match event { + BackendEvent::Headers { handle, status, url, headers, redirected, length } => { + let Some(h) = self.handles.get(&handle) else { return }; + if h.terminal || h.head_pushed { + return; + } + if !(100..=599).contains(&status) || !is_http_url(&url) { + self.backend.cancel(handle); + self.fail(handle, NetFailure::new(spec::ERROR_PROTOCOL, "malformed response head")); + return; + } + let header_bytes: usize = headers.iter().map(|(k, v)| k.len() + v.len() + 4).sum(); + if headers.len() > self.limits.max_headers + || header_bytes > self.limits.max_header_bytes + || !headers.iter().all(|(k, v)| is_token(k) && !v.contains(['\r', '\n'])) + { + self.backend.cancel(handle); + self.fail(handle, NetFailure::new(spec::ERROR_PROTOCOL, "response headers exceed the portable contract")); + return; + } + if let (Some(len), Some(max)) = (length, h.max_body_bytes) { + if len > max as u64 { + self.backend.cancel(handle); + self.fail(handle, NetFailure::new(spec::ERROR_RESPONSE_TOO_LARGE, "response exceeds maxBodyBytes")); + return; + } + } + let mut json = format!("{{\"t\":\"headers\",\"h\":{},\"status\":{},\"url\":{},\"headers\":{{", handle, status, json_string(&url)); + let mut first = true; + for (name, value) in &headers { + if !first { + json.push(','); + } + first = false; + json.push_str(&json_string(&name.to_ascii_lowercase())); + json.push(':'); + json.push_str(&json_string(value)); + } + json.push_str(&format!("}},\"redirected\":{}", redirected)); + if let Some(len) = length { + json.push_str(&format!(",\"length\":{len}")); + } + json.push('}'); + let weight = json.len(); + self.pending.push_back(QueuedEvent { handle, barrier: false, weight, json }); + self.handles.get_mut(&handle).unwrap().head_pushed = true; + } + BackendEvent::Body { handle, chunk } => { + let Some(h) = self.handles.get_mut(&handle) else { return }; + if h.terminal || !h.head_pushed { + return; + } + if let Some(max) = h.max_body_bytes { + if h.body_total + chunk.len() > max { + self.backend.cancel(handle); + self.fail(handle, NetFailure::new(spec::ERROR_RESPONSE_TOO_LARGE, "response exceeds maxBodyBytes")); + return; + } + } + h.body_total += chunk.len(); + h.queue.extend(chunk); + h.dirty = true; + if h.queue.len() >= h.queue_bytes && !h.paused { + h.paused = true; + self.backend.set_paused(handle, true); + } + } + BackendEvent::End { handle } => { + let Some(h) = self.handles.get_mut(&handle) else { return }; + if h.terminal { + return; + } + if !h.head_pushed { + self.fail(handle, NetFailure::new(spec::ERROR_PROTOCOL, "end before headers")); + return; + } + h.terminal = true; + let json = format!("{{\"t\":\"end\",\"h\":{handle}}}"); + self.pending.push_back(QueuedEvent { handle, barrier: true, weight: 0, json }); + if h.queue.is_empty() && !h.dirty { + self.handles.remove(&handle); + } + } + BackendEvent::Error { handle, failure } => self.fail(handle, failure), } - into.copy_from_slice(body); - self.bodies.remove(&handle); - into.len() as i32 } - /// Drain the whole tick batch in one serialization and one FFI crossing. + /// `poll()` op: the visible batch as one JSON array, or None. pub fn poll(&mut self) -> Option { if self.visible.is_empty() { return None; } - let events = std::mem::take(&mut self.visible); - Some(serde_json::to_string(&events).expect("GuestEvent serialization is infallible")) + let mut out = String::from("["); + let mut first = true; + while let Some(ev) = self.visible.pop_front() { + if !first { + out.push(','); + } + first = false; + out.push_str(&ev.json); + } + out.push(']'); + Some(out) } - pub fn last_error(&self) -> &str { - &self.last_error + /// `readInto(handle, buffer)` op: copies visible bytes; -1 for an unknown + /// or fully drained terminal handle, 0 when nothing is visible yet. + pub fn read_into(&mut self, handle: i32, into: &mut [u8]) -> i32 { + let Some(h) = self.handles.get_mut(&handle) else { return -1 }; + if !h.head_pushed { + return -1; + } + let want = into.len().min(h.visible_bytes); + for (i, slot) in into.iter_mut().take(want).enumerate() { + *slot = h.queue[i]; + } + h.queue.drain(..want); + h.visible_bytes -= want; + if h.paused && h.queue.len() < h.queue_bytes { + h.paused = false; + self.backend.set_paused(handle, false); + } + if h.terminal && h.queue.is_empty() && !h.dirty { + self.handles.remove(&handle); + } + want as i32 } } // --------------------------------------------------------------------------- -// Mount +// Helpers // --------------------------------------------------------------------------- -#[cfg(feature = "mount")] -use std::cell::RefCell; -#[cfg(feature = "mount")] -use std::rc::Rc; +const CORE_OWNED_HEADERS: [&str; 10] = [ + "host", "connection", "content-length", "transfer-encoding", "trailer", "te", "upgrade", "keep-alive", "expect", + "proxy-connection", +]; + +fn is_token(s: &str) -> bool { + !s.is_empty() + && s.bytes() + .all(|b| b > 0x20 && b < 0x7f && !b"()<>@,;:\\\"/[]?={}".contains(&b)) +} + +fn is_http_url(url: &str) -> bool { + parse_url(url).is_some_and(|(scheme, _, _)| scheme == "http" || scheme == "https") +} + +/// (scheme, lowercased host, effective port) for http/https/ws/wss URLs. +fn parse_url(url: &str) -> Option<(&'static str, String, u16)> { + let (scheme_raw, rest) = url.split_once("://")?; + let scheme: &'static str = match scheme_raw.to_ascii_lowercase().as_str() { + "http" => "http", + "https" => "https", + "ws" => "ws", + "wss" => "wss", + _ => return None, + }; + let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + let authority = &rest[..authority_end]; + if authority.is_empty() || authority.contains('@') || authority.contains(char::is_whitespace) { + return None; + } + let default_port = if scheme == "https" || scheme == "wss" { 443 } else { 80 }; + let (host, port) = if let Some(stripped) = authority.strip_prefix('[') { + let end = stripped.find(']')?; + let host = &stripped[..end]; + let after = &stripped[end + 1..]; + let port = match after.strip_prefix(':') { + Some(p) => p.parse::().ok()?, + None if after.is_empty() => default_port, + None => return None, + }; + (host.to_string(), port) + } else if let Some((host, port)) = authority.rsplit_once(':') { + (host.to_string(), port.parse::().ok()?) + } else { + (authority.to_string(), default_port) + }; + if host.is_empty() { + return None; + } + Some((scheme, host.to_ascii_lowercase(), port)) +} + +fn json_string(s: &str) -> String { + serde_json::to_string(s).unwrap_or_else(|_| "\"\"".into()) +} + +/// Clamp a code onto the shared vocabulary; unknown codes become `other`. +pub fn normalize_error_code(code: &str) -> &'static str { + match code { + spec::ERROR_INVALID_REQUEST => spec::ERROR_INVALID_REQUEST, + spec::ERROR_INVALID_STATE => spec::ERROR_INVALID_STATE, + spec::ERROR_UNSUPPORTED => spec::ERROR_UNSUPPORTED, + spec::ERROR_PERMISSION_DENIED => spec::ERROR_PERMISSION_DENIED, + spec::ERROR_BUSY => spec::ERROR_BUSY, + spec::ERROR_RESOURCE_LIMIT => spec::ERROR_RESOURCE_LIMIT, + spec::ERROR_DNS => spec::ERROR_DNS, + spec::ERROR_CONNECT => spec::ERROR_CONNECT, + spec::ERROR_ADDRESS_IN_USE => spec::ERROR_ADDRESS_IN_USE, + spec::ERROR_CLOSED => spec::ERROR_CLOSED, + spec::ERROR_TIMEOUT => spec::ERROR_TIMEOUT, + spec::ERROR_TLS_CERTIFICATE_INVALID => spec::ERROR_TLS_CERTIFICATE_INVALID, + spec::ERROR_TLS_HOSTNAME_MISMATCH => spec::ERROR_TLS_HOSTNAME_MISMATCH, + spec::ERROR_TLS_HANDSHAKE_FAILED => spec::ERROR_TLS_HANDSHAKE_FAILED, + spec::ERROR_TLS_CLOCK_UNTRUSTED => spec::ERROR_TLS_CLOCK_UNTRUSTED, + spec::ERROR_REDIRECT => spec::ERROR_REDIRECT, + spec::ERROR_RESPONSE_TOO_LARGE => spec::ERROR_RESPONSE_TOO_LARGE, + spec::ERROR_PROTOCOL => spec::ERROR_PROTOCOL, + spec::ERROR_WEBSOCKET_HANDSHAKE_FAILED => spec::ERROR_WEBSOCKET_HANDSHAKE_FAILED, + spec::ERROR_WEBSOCKET_PROTOCOL_ERROR => spec::ERROR_WEBSOCKET_PROTOCOL_ERROR, + spec::ERROR_MESSAGE_TOO_LARGE => spec::ERROR_MESSAGE_TOO_LARGE, + spec::ERROR_CANCELLED => spec::ERROR_CANCELLED, + spec::ERROR_UNAVAILABLE => spec::ERROR_UNAVAILABLE, + _ => spec::ERROR_OTHER, + } +} + +// --------------------------------------------------------------------------- +// Mount (rquickjs) +// --------------------------------------------------------------------------- #[cfg(feature = "mount")] use anyhow::Result; #[cfg(feature = "mount")] use pocket_mod::Guest; #[cfg(feature = "mount")] -use pocket_mod::qjs::{ArrayBuffer, Function}; +use pocket_mod::qjs::{ArrayBuffer, Function, Value}; +#[cfg(feature = "mount")] +use std::cell::RefCell; +#[cfg(feature = "mount")] +use std::rc::Rc; /// Clone-cheap mounted NET module. The host keeps a copy and calls -/// [`begin_tick`](Self::begin_tick); the namespace closures share the core. -/// Feature `mount` (default); a host with its own QuickJS wiring turns it -/// off and drives [`NetCore`] directly, spelling the five ops itself. +/// [`begin_tick`](Self::begin_tick) before every frame; the namespace closures +/// share the core. #[cfg(feature = "mount")] -pub struct NetSurface { - inner: Rc>>, +pub struct NetSurface { + inner: Rc>>, } #[cfg(feature = "mount")] -impl Clone for NetSurface { +impl Clone for NetSurface { fn clone(&self) -> Self { - Self { - inner: self.inner.clone(), - } + Self { inner: self.inner.clone() } } } #[cfg(feature = "mount")] -impl NetSurface { - pub fn new(transport: T) -> Self { - Self { - inner: Rc::new(RefCell::new(NetCore::new(transport))), - } +impl NetSurface { + pub fn new(core: NetCore) -> Self { + Self { inner: Rc::new(RefCell::new(core)) } } pub fn begin_tick(&self) { self.inner.borrow_mut().begin_tick(); } - pub fn with_core(&self, f: impl FnOnce(&mut NetCore) -> R) -> R { + pub fn with_core(&self, f: impl FnOnce(&mut NetCore) -> R) -> R { f(&mut self.inner.borrow_mut()) } - /// Mount exactly the five ops pinned in `contracts/spec/net.ts`. + /// Mount the six v2 ops of `contracts/spec/net.ts` on `globalThis.net`. pub fn mount(&self, guest: &Guest) -> Result<()> { guest.mount("net", |ctx, ns| { let core = self.inner.clone(); ns.set( "start", - Function::new(ctx.clone(), move |meta: String, body: ArrayBuffer| { - let Some(bytes) = body.as_bytes() else { - core.borrow_mut().last_error = - format!("{}: detached request body", spec::ERROR_INVALID_REQUEST); + Function::new(ctx.clone(), move |meta: String, body: Value| -> i32 { + let bytes: Vec = if body.is_null() || body.is_undefined() { + Vec::new() + } else if let Some(buffer) = body.as_object().and_then(|o| ArrayBuffer::from_object(o.clone())) { + match buffer.as_bytes() { + Some(b) => b.to_vec(), + None => { + core.borrow_mut().last_error = format!("{}: detached request body", spec::ERROR_INVALID_STATE); + return -1; + } + } + } else { + core.borrow_mut().last_error = format!("{}: body must be an ArrayBuffer or null", spec::ERROR_INVALID_REQUEST); return -1; }; - core.borrow_mut().start(&meta, bytes) + core.borrow_mut().start(&meta, &bytes) })?, )?; let core = self.inner.clone(); ns.set( - "take", - Function::new(ctx.clone(), move |handle: i32, into: ArrayBuffer| { - let Some(raw) = into.as_raw() else { - return -1; - }; - // QuickJS owns this mutable ArrayBuffer for the duration - // of the synchronous call. rquickjs exposes its raw span - // but intentionally cannot express JS mutability as &mut. - let bytes = - unsafe { std::slice::from_raw_parts_mut(raw.ptr.as_ptr(), raw.len) }; - core.borrow_mut().take_into(handle, bytes) - })?, + "cancel", + Function::new(ctx.clone(), move |handle: i32| core.borrow_mut().cancel(handle))?, )?; + let core = self.inner.clone(); + ns.set("poll", Function::new(ctx.clone(), move || core.borrow_mut().poll())?)?; + let core = self.inner.clone(); ns.set( - "cancel", - Function::new(ctx.clone(), move |handle: i32| { - core.borrow_mut().cancel(handle) - })?, + "lastError", + Function::new(ctx.clone(), move || core.borrow().last_error().to_string())?, )?; let core = self.inner.clone(); ns.set( - "poll", - Function::new(ctx.clone(), move || core.borrow_mut().poll())?, + "readInto", + Function::new(ctx.clone(), move |handle: i32, into: ArrayBuffer, offset: f64, length: f64| -> i32 { + let Some(raw) = into.as_raw() else { return -1 }; + let (offset, length) = (offset as usize, length as usize); + if offset > raw.len || length > raw.len - offset { + return -1; + } + // QuickJS owns this mutable ArrayBuffer for the duration + // of the synchronous call; rquickjs exposes the raw span + // but cannot express JS mutability as &mut. + let bytes = unsafe { std::slice::from_raw_parts_mut(raw.ptr.as_ptr().add(offset), length) }; + core.borrow_mut().read_into(handle, bytes) + })?, )?; let core = self.inner.clone(); ns.set( - "lastError", - Function::new(ctx.clone(), move || core.borrow().last_error().to_string())?, + "limits", + Function::new(ctx.clone(), move || core.borrow().limits().to_string())?, )?; Ok(()) }) } } -fn invalid(message: impl Into) -> NetFailure { - NetFailure::new(spec::ERROR_INVALID_REQUEST, message) -} - -fn is_http_url(url: &str) -> bool { - let rest = url - .strip_prefix("http://") - .or_else(|| url.strip_prefix("https://")); - matches!(rest, Some(value) if !value.is_empty() - && !value.starts_with('/') - && !value.bytes().any(|b| b.is_ascii_whitespace())) -} - -fn header_bytes(headers: &BTreeMap) -> usize { - headers - .iter() - .map(|(name, value)| name.len() + value.len() + 4) - .sum() -} - -fn valid_headers(headers: &BTreeMap) -> bool { - headers.iter().all(|(name, value)| { - !name.is_empty() - && name.bytes().all(|b| { - b.is_ascii_lowercase() - || b.is_ascii_digit() - || matches!( - b, - b'!' | b'#' - | b'$' - | b'%' - | b'&' - | b'\'' - | b'*' - | b'+' - | b'-' - | b'.' - | b'^' - | b'_' - | b'`' - | b'|' - | b'~' - ) - }) - && !value.contains(['\r', '\n']) - }) -} - -fn validate_meta(meta: &RequestMeta, body: &[u8]) -> std::result::Result<(), NetFailure> { - if !is_http_url(&meta.url) { - return Err(invalid("url must be absolute http:// or https://")); - } - if !spec::METHODS.contains(&meta.method.as_str()) { - return Err(invalid(format!("unsupported method {}", meta.method))); - } - if matches!(meta.method.as_str(), "GET" | "HEAD") && !body.is_empty() { - return Err(invalid(format!("{} cannot have a body", meta.method))); - } - if meta.timeout_ms == 0 || meta.timeout_ms > spec::MAX_TIMEOUT_MS { - return Err(invalid(format!( - "timeoutMs must be 1..{}", - spec::MAX_TIMEOUT_MS - ))); - } - if meta.max_bytes == 0 || meta.max_bytes > spec::MAX_RESPONSE_BYTES { - return Err(invalid(format!( - "maxBytes must be 1..{}", - spec::MAX_RESPONSE_BYTES - ))); - } - if meta.headers.len() > spec::MAX_HEADERS - || header_bytes(&meta.headers) > spec::MAX_HEADER_BYTES - || !valid_headers(&meta.headers) - { - return Err(invalid("request headers exceed the portable contract")); - } - Ok(()) -} - -fn normalize_error_code(code: &str) -> &'static str { - match code { - spec::ERROR_UNAVAILABLE => spec::ERROR_UNAVAILABLE, - spec::ERROR_INVALID_REQUEST => spec::ERROR_INVALID_REQUEST, - spec::ERROR_BUSY => spec::ERROR_BUSY, - spec::ERROR_DNS => spec::ERROR_DNS, - spec::ERROR_CONNECT => spec::ERROR_CONNECT, - spec::ERROR_TLS => spec::ERROR_TLS, - spec::ERROR_TIMEOUT => spec::ERROR_TIMEOUT, - spec::ERROR_REDIRECT => spec::ERROR_REDIRECT, - spec::ERROR_RESPONSE_TOO_LARGE => spec::ERROR_RESPONSE_TOO_LARGE, - spec::ERROR_PROTOCOL => spec::ERROR_PROTOCOL, - spec::ERROR_CANCELLED => spec::ERROR_CANCELLED, - _ => spec::ERROR_OTHER, - } -} +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; - use std::collections::VecDeque; #[derive(Default)] - struct FixtureTransport { + struct Fixture { started: Vec, cancelled: Vec, - completions: VecDeque, + queue: VecDeque, + paused: Vec<(i32, bool)>, + refuse: bool, } - impl HttpTransport for FixtureTransport { - fn start(&mut self, request: HttpRequest) -> std::result::Result<(), NetFailure> { + impl HttpClientBackend for Fixture { + fn start(&mut self, request: HttpRequest) -> Result<(), NetFailure> { + if self.refuse { + return Err(NetFailure::new(spec::ERROR_RESOURCE_LIMIT, "no sockets")); + } self.started.push(request); Ok(()) } - fn cancel(&mut self, handle: i32) { self.cancelled.push(handle); } - - fn drain(&mut self, completions: &mut Vec) { - completions.extend(self.completions.drain(..)); + fn drain(&mut self, out: &mut Vec) { + out.extend(self.queue.drain(..)); + } + fn set_paused(&mut self, handle: i32, paused: bool) { + self.paused.push((handle, paused)); } } - fn meta(max_bytes: usize) -> String { - format!( - r#"{{"url":"https://example.test/a","method":"GET","headers":{{}},"timeoutMs":30000,"maxBytes":{max_bytes}}}"# - ) + fn core() -> NetCore { + NetCore::new(Fixture::default(), NetPolicy::permissive()) } - #[test] - fn accepted_request_is_owned_and_only_visible_at_tick_boundary() { - let mut core = NetCore::new(FixtureTransport::default()); - let handle = core.start(&meta(16), &[]); - assert_eq!(handle, 1); - assert!(core.poll().is_none()); - assert_eq!( - core.transport_mut().started[0].max_redirects, - spec::MAX_REDIRECTS - ); + fn headers(handle: i32, length: Option) -> BackendEvent { + let mut h = BTreeMap::new(); + h.insert("content-type".to_string(), "text/plain".to_string()); + BackendEvent::Headers { handle, status: 200, url: "http://example.test/".into(), headers: h, redirected: false, length } + } - core.transport_mut() - .completions - .push_back(TransportCompletion::Done { - handle, - status: 200, - url: "https://example.test/a".into(), - headers: BTreeMap::from([("content-type".into(), "text/plain".into())]), - body: b"hello".to_vec(), - }); - assert!(core.poll().is_none()); + const META: &str = r#"{"url":"http://example.test/","method":"GET","headers":{"x-a":"1"}}"#; + + #[test] + fn events_become_visible_only_at_the_tick_boundary_in_order() { + let mut core = core(); + let h = core.start(META, &[]); + assert!(h > 0); + assert_eq!(core.backend_mut().started[0].headers.get("x-a").map(String::as_str), Some("1")); + core.backend_mut().queue.push_back(headers(h, Some(5))); + core.backend_mut().queue.push_back(BackendEvent::Body { handle: h, chunk: b"hello".to_vec() }); + core.backend_mut().queue.push_back(BackendEvent::End { handle: h }); + assert_eq!(core.poll(), None, "nothing visible before begin_tick"); core.begin_tick(); let batch = core.poll().unwrap(); assert_eq!( batch, - r#"[{"t":"done","h":1,"status":200,"url":"https://example.test/a","headers":{"content-type":"text/plain"},"bytes":5}]"# + format!( + "[{{\"t\":\"headers\",\"h\":{h},\"status\":200,\"url\":\"http://example.test/\",\"headers\":{{\"content-type\":\"text/plain\"}},\"redirected\":false,\"length\":5}},{{\"t\":\"readable\",\"h\":{h},\"avail\":5}},{{\"t\":\"end\",\"h\":{h}}}]" + ) ); - assert_eq!(core.take(handle).as_deref(), Some(&b"hello"[..])); - assert!(core.take(handle).is_none()); + let mut buf = [0u8; 8]; + assert_eq!(core.read_into(h, &mut buf), 5); + assert_eq!(&buf[..5], b"hello"); + assert_eq!(core.read_into(h, &mut buf), -1, "drained terminal handle retires"); + assert_eq!(core.live(), 0); } #[test] - fn limit_is_checked_again_after_transport_completion() { - let mut core = NetCore::new(FixtureTransport::default()); - let handle = core.start(&meta(4), &[]); - core.transport_mut() - .completions - .push_back(TransportCompletion::Done { - handle, - status: 200, - url: "https://example.test/a".into(), - headers: BTreeMap::new(), - body: b"12345".to_vec(), - }); - core.begin_tick(); - assert!( - core.poll() - .unwrap() - .contains(spec::ERROR_RESPONSE_TOO_LARGE) + fn readable_watermark_is_frozen_per_tick_and_backpressure_pauses() { + let mut core = NetCore::with_limits( + Fixture::default(), + NetPolicy::permissive(), + NetLimits { max_queue_bytes: 4, default_queue_bytes: 4, ..NetLimits::default() }, ); - assert!(core.take(handle).is_none()); + let h = core.start(META, &[]); + core.backend_mut().queue.push_back(headers(h, None)); + core.backend_mut().queue.push_back(BackendEvent::Body { handle: h, chunk: b"abcd".to_vec() }); + core.begin_tick(); + assert!(core.poll().unwrap().contains("\"avail\":4")); + assert_eq!(core.backend_mut().paused, vec![(h, true)]); + // Bytes arriving after the boundary are not visible yet. + core.backend_mut().queue.push_back(BackendEvent::Body { handle: h, chunk: b"ef".to_vec() }); + let mut buf = [0u8; 8]; + assert_eq!(core.read_into(h, &mut buf), 4); + assert_eq!(core.backend_mut().paused.last(), Some(&(h, false))); + core.begin_tick(); + assert!(core.poll().unwrap().contains("\"avail\":2")); + assert_eq!(core.read_into(h, &mut buf), 2); + assert_eq!(&buf[..2], b"ef"); } #[test] - fn rejects_invalid_and_excess_inflight_requests_synchronously() { - let mut core = NetCore::new(FixtureTransport::default()); - assert_eq!(core.start("{}", &[]), -1); + fn synchronous_refusals_and_policy() { + let mut core = core(); + assert_eq!(core.start(r#"{"url":"https://example.test/","method":"GET"}"#, &[]), -1); + assert!(core.last_error().starts_with(spec::ERROR_UNSUPPORTED)); + assert_eq!(core.start(r#"{"url":"http://example.test/","method":"TRACE"}"#, &[]), -1); assert!(core.last_error().starts_with(spec::ERROR_INVALID_REQUEST)); - assert!(core.start(&meta(16), &[]) > 0); - assert!(core.start(&meta(16), &[]) > 0); - assert_eq!(core.start(&meta(16), &[]), -1); - assert!(core.last_error().starts_with(spec::ERROR_BUSY)); + assert_eq!(core.start(r#"{"url":"http://example.test/","method":"GET","bogus":1}"#, &[]), -1); + assert_eq!(core.start(META, b"x"), -1, "GET with a body"); + let strict = NetPolicy::parse(r#"{"connect":[{"protocol":"http","host":"*.devices.test","port":{"min":8000,"max":8100}}],"insecureTransport":true}"#).unwrap(); + let mut core = NetCore::new(Fixture::default(), strict); + assert!(core.start(r#"{"url":"http://a.devices.test:8050/","method":"GET"}"#, &[]) > 0); + assert_eq!(core.start(r#"{"url":"http://a.b.devices.test:8050/","method":"GET"}"#, &[]), -1); + assert!(core.last_error().starts_with(spec::ERROR_PERMISSION_DENIED)); + let closed = NetPolicy::parse(r#"{"connect":[{"protocol":"http","host":"h","port":80}]}"#).unwrap(); + let mut core = NetCore::new(Fixture::default(), closed); + assert_eq!(core.start(r#"{"url":"http://h/","method":"GET"}"#, &[]), -1, "insecureTransport off"); + let mut core = NetCore::new(Fixture { refuse: true, ..Default::default() }, NetPolicy::permissive()); + assert_eq!(core.start(META, &[]), -1); + assert!(core.last_error().starts_with(spec::ERROR_RESOURCE_LIMIT)); + assert_eq!(core.live(), 0, "refused starts do not hold a handle"); + } + + #[test] + fn cancel_and_late_completions() { + let mut core = core(); + let h = core.start(META, &[]); + core.cancel(h); + assert_eq!(core.backend_mut().cancelled, vec![h]); + core.backend_mut().queue.push_back(headers(h, Some(1))); + core.begin_tick(); + let batch = core.poll().unwrap(); + assert!(batch.contains("\"code\":\"cancelled\"")); + assert!(!batch.contains("\"t\":\"headers\""), "late completion discarded"); + assert_eq!(core.poll(), None); } #[test] - fn cancellation_discards_late_completion() { - let mut core = NetCore::new(FixtureTransport::default()); - let handle = core.start(&meta(16), &[]); - core.cancel(handle); - core.transport_mut() - .completions - .push_back(TransportCompletion::Error { - handle, - failure: NetFailure::new(spec::ERROR_TIMEOUT, "late"), - }); + fn budget_truncation_preserves_order_across_ticks() { + let mut core = NetCore::with_limits( + Fixture::default(), + NetPolicy::permissive(), + NetLimits { max_events_per_tick: 2, ..NetLimits::default() }, + ); + let h = core.start(META, &[]); + core.backend_mut().queue.push_back(headers(h, Some(2))); + core.backend_mut().queue.push_back(BackendEvent::Body { handle: h, chunk: b"ab".to_vec() }); + core.backend_mut().queue.push_back(BackendEvent::End { handle: h }); core.begin_tick(); - assert!(core.poll().is_none()); - assert_eq!(core.transport_mut().cancelled, vec![handle]); + let first = core.poll().unwrap(); + assert!(first.contains("\"t\":\"headers\"") && first.contains("\"t\":\"readable\"") && !first.contains("\"t\":\"end\"")); + core.begin_tick(); + assert!(core.poll().unwrap().contains("\"t\":\"end\"")); + } + + #[test] + fn limits_report_the_spec_major_and_features() { + let core = core(); + assert!(core.limits().contains("\"specMajor\":2")); + assert!(core.limits().contains("\"features\":[]")); } #[cfg(feature = "mount")] #[test] - fn mounted_surface_copies_into_guest_owned_arraybuffer() { + fn mounts_the_v2_ops() { + use pocket_mod::qjs::Ctx; + let surface = NetSurface::new(core()); let guest = Guest::new().unwrap(); - let surface = NetSurface::new(FixtureTransport::default()); surface.mount(&guest).unwrap(); - let source = format!( - "globalThis.h = net.start({}, new ArrayBuffer(0)); globalThis.before = net.poll();", - serde_json::to_string(&meta(16)).unwrap() - ); - guest.eval("start", &source).unwrap(); - let handle: i32 = guest.with(|ctx| ctx.globals().get("h").unwrap()); - let before: Option = guest.with(|ctx| ctx.globals().get("before").unwrap()); - assert_eq!(handle, 1); - assert!(before.is_none()); - - surface.with_core(|core| { - core.transport_mut() - .completions - .push_back(TransportCompletion::Done { - handle, - status: 200, - url: "https://example.test/a".into(), - headers: BTreeMap::new(), - body: vec![7, 8, 9], - }); + guest.eval("t", "globalThis.h = net.start(JSON.stringify({url:'http://example.test/',method:'GET',headers:{}}), null);").unwrap(); + let h: i32 = guest.with(|ctx: Ctx| ctx.globals().get("h").unwrap()); + assert!(h > 0); + surface.with_core(|c| { + c.backend_mut().queue.push_back(headers(h, Some(3))); + c.backend_mut().queue.push_back(BackendEvent::Body { handle: h, chunk: b"abc".to_vec() }); + c.backend_mut().queue.push_back(BackendEvent::End { handle: h }); }); surface.begin_tick(); guest .eval( - "take", - "const e = JSON.parse(net.poll())[0];\ - const out = new ArrayBuffer(e.bytes);\ - globalThis.copied = net.take(e.h, out);\ - globalThis.first = new Uint8Array(out)[0];\ - globalThis.again = net.take(e.h, out);", + "t", + "const batch = JSON.parse(net.poll()); const buf = new ArrayBuffer(8); globalThis.n = net.readInto(globalThis.h, buf, 1, 4); globalThis.s = String.fromCharCode(...new Uint8Array(buf, 1, 3)); globalThis.k = batch.map(e => e.t).join(',');", ) .unwrap(); - let values: (i32, i32, i32) = guest.with(|ctx| { + let (n, s, k): (i32, String, String) = guest.with(|ctx: Ctx| { let g = ctx.globals(); - ( - g.get("copied").unwrap(), - g.get("first").unwrap(), - g.get("again").unwrap(), - ) + (g.get("n").unwrap(), g.get("s").unwrap(), g.get("k").unwrap()) }); - assert_eq!(values, (3, 7, -1)); + assert_eq!((n, s.as_str(), k.as_str()), (3, "abc", "headers,readable,end")); } } diff --git a/engine/net/include/pocketjs/net/spec.h b/engine/net/include/pocketjs/net/spec.h new file mode 100644 index 00000000..9b88ebb9 --- /dev/null +++ b/engine/net/include/pocketjs/net/spec.h @@ -0,0 +1,161 @@ +/* GENERATED — do not edit; run `bun contracts/spec/gen-c.ts`. */ +/* C mirror of contracts/spec/{net,ws,httpd}.ts: the guest boundaries of the + * network modules (`globalThis.net` / `ws` / `httpd`). Every value here is a + * portable ceiling or a wire-visible constant; a host's limits() may only + * tighten the ceilings. tests/contract.ts byte-compares this file. */ +#ifndef POCKETJS_NET_SPEC_H +#define POCKETJS_NET_SPEC_H + +/* --- net: HTTP Client (`globalThis.net`) --- */ +#define PNET_SPEC_MAJOR 2 +#define PNET_SPEC_MINOR 0 +#define PNET_OP_START 1 +#define PNET_OP_TAKE 2 +#define PNET_OP_CANCEL 3 +#define PNET_OP_POLL 4 +#define PNET_OP_LAST_ERROR 5 +#define PNET_OP_READ_INTO 6 +#define PNET_OP_LIMITS 7 +#define PNET_OP_WRITE 8 +#define PNET_OP_END_BODY 9 +#define PNET_MAX_INFLIGHT 8 +#define PNET_MAX_REQUEST_BYTES 262144 +#define PNET_DEFAULT_QUEUE_BYTES 32768 +#define PNET_MAX_QUEUE_BYTES 262144 +#define PNET_DEFAULT_AGGREGATE_BYTES 1048576 +#define PNET_MAX_AGGREGATE_BYTES 8388608 +#define PNET_MAX_EVENTS_PER_TICK 128 +#define PNET_MAX_TICK_BYTES 262144 +#define PNET_MAX_HEADERS 64 +#define PNET_MAX_HEADER_BYTES 16384 +#define PNET_DEFAULT_TIMEOUT_MS 30000 +#define PNET_MAX_TIMEOUT_MS 120000 +#define PNET_MAX_REDIRECTS 5 +#define PNET_TLS_MIN_VERSION "1.2" +#define PNET_METHODS_FORBIDDEN_COUNT 2 +#define PNET_METHODS_FORBIDDEN { "CONNECT", "TRACE" } +#define PNET_EVENT_HEADERS "headers" +#define PNET_EVENT_READABLE "readable" +#define PNET_EVENT_END "end" +#define PNET_EVENT_ERROR "error" +#define PNET_EVENT_DRAIN "drain" +/* Error vocabulary shared by net, ws and httpd. */ +#define PNET_ERROR_INVALID_REQUEST "invalid_request" +#define PNET_ERROR_INVALID_STATE "invalid_state" +#define PNET_ERROR_UNSUPPORTED "unsupported" +#define PNET_ERROR_PERMISSION_DENIED "permission_denied" +#define PNET_ERROR_BUSY "busy" +#define PNET_ERROR_RESOURCE_LIMIT "resource_limit" +#define PNET_ERROR_DNS "dns" +#define PNET_ERROR_CONNECT "connect" +#define PNET_ERROR_ADDRESS_IN_USE "address_in_use" +#define PNET_ERROR_CLOSED "closed" +#define PNET_ERROR_TIMEOUT "timeout" +#define PNET_ERROR_TLS_CERTIFICATE_INVALID "tls_certificate_invalid" +#define PNET_ERROR_TLS_HOSTNAME_MISMATCH "tls_hostname_mismatch" +#define PNET_ERROR_TLS_HANDSHAKE_FAILED "tls_handshake_failed" +#define PNET_ERROR_TLS_CLOCK_UNTRUSTED "tls_clock_untrusted" +#define PNET_ERROR_REDIRECT "redirect" +#define PNET_ERROR_RESPONSE_TOO_LARGE "response_too_large" +#define PNET_ERROR_PROTOCOL "protocol" +#define PNET_ERROR_WEBSOCKET_HANDSHAKE_FAILED "websocket_handshake_failed" +#define PNET_ERROR_WEBSOCKET_PROTOCOL_ERROR "websocket_protocol_error" +#define PNET_ERROR_MESSAGE_TOO_LARGE "message_too_large" +#define PNET_ERROR_CANCELLED "cancelled" +#define PNET_ERROR_OTHER "other" +#define PNET_ERROR_UNAVAILABLE "unavailable" + +/* --- ws: WebSocket Client (`globalThis.ws`) --- */ +#define PWS_SPEC_MAJOR 2 +#define PWS_SPEC_MINOR 0 +#define PWS_OP_CONNECT 1 +#define PWS_OP_SEND 2 +#define PWS_OP_RECEIVE_INTO 3 +#define PWS_OP_CLOSE 4 +#define PWS_OP_TERMINATE 5 +#define PWS_OP_BUFFERED_AMOUNT 6 +#define PWS_OP_POLL 7 +#define PWS_OP_LAST_ERROR 8 +#define PWS_OP_LIMITS 9 +#define PWS_SEND_ACCEPTED 0 +#define PWS_SEND_ACCEPTED_HIGH_WATER 1 +#define PWS_SEND_CLOSED (-1) +#define PWS_SEND_BACKPRESSURE (-2) +#define PWS_SEND_INVALID (-3) +#define PWS_OPCODE_TEXT 1 +#define PWS_OPCODE_BINARY 2 +#define PWS_OPCODE_PING 9 +#define PWS_OPCODE_PONG 10 +#define PWS_EVENT_OPEN "open" +#define PWS_EVENT_MESSAGE "message" +#define PWS_EVENT_PING "ping" +#define PWS_EVENT_PONG "pong" +#define PWS_EVENT_DRAIN "drain" +#define PWS_EVENT_ERROR "error" +#define PWS_EVENT_CLOSE "close" +#define PWS_BLOB_KEY "$b" +#define PWS_FORBIDDEN_HEADERS_COUNT 9 +#define PWS_FORBIDDEN_HEADERS { "host", "connection", "upgrade", "content-length", "sec-websocket-key", "sec-websocket-version", "sec-websocket-protocol", "sec-websocket-extensions", "sec-websocket-accept" } +#define PWS_MAX_SOCKETS 8 +#define PWS_MAX_MESSAGE_BYTES 1048576 +#define PWS_MAX_RECEIVE_QUEUE_BYTES 1048576 +#define PWS_MAX_RECEIVE_QUEUE_MESSAGES 64 +#define PWS_MAX_SEND_QUEUE_BYTES 1048576 +#define PWS_SEND_HIGH_WATER_BYTES 262144 +#define PWS_SEND_LOW_WATER_BYTES 65536 +#define PWS_MAX_HANDSHAKE_HEADERS 64 +#define PWS_MAX_HANDSHAKE_HEADER_BYTES 16384 +#define PWS_MAX_EVENTS_PER_TICK 128 +#define PWS_MAX_TICK_BYTES 262144 +#define PWS_DEFAULT_CONNECT_MS 30000 +#define PWS_MAX_CONNECT_MS 120000 +#define PWS_DEFAULT_CLOSE_MS 5000 +#define PWS_CONTROL_PAYLOAD_MAX 125 + +/* --- httpd: HTTP Server (`globalThis.httpd`) --- */ +#define PHTTPD_SPEC_MAJOR 2 +#define PHTTPD_SPEC_MINOR 0 +#define PHTTPD_OP_LISTEN 1 +#define PHTTPD_OP_STOP 2 +#define PHTTPD_OP_RESPOND 3 +#define PHTTPD_OP_WRITE 4 +#define PHTTPD_OP_END_BODY 5 +#define PHTTPD_OP_READ_INTO 6 +#define PHTTPD_OP_ABORT 7 +#define PHTTPD_OP_POLL 8 +#define PHTTPD_OP_LAST_ERROR 9 +#define PHTTPD_OP_LIMITS 10 +#define PHTTPD_SEND_ACCEPTED 0 +#define PHTTPD_SEND_INVALID_REQUEST (-1) +#define PHTTPD_SEND_BACKPRESSURE (-2) +#define PHTTPD_SEND_INVALID (-3) +#define PHTTPD_EVENT_LISTENING "listening" +#define PHTTPD_EVENT_CLOSED "closed" +#define PHTTPD_EVENT_ERROR "error" +#define PHTTPD_EVENT_REQUEST "request" +#define PHTTPD_EVENT_READABLE "readable" +#define PHTTPD_EVENT_END "end" +#define PHTTPD_EVENT_DRAIN "drain" +#define PHTTPD_EVENT_ABORTED "aborted" +#define PHTTPD_MAX_SERVERS 2 +#define PHTTPD_MAX_CONNECTIONS 16 +#define PHTTPD_MAX_INFLIGHT 8 +#define PHTTPD_MAX_BACKLOG 16 +#define PHTTPD_MAX_HEADERS 64 +#define PHTTPD_MAX_HEADER_BYTES 16384 +#define PHTTPD_MAX_TARGET_BYTES 2048 +#define PHTTPD_DEFAULT_REQUEST_QUEUE_BYTES 32768 +#define PHTTPD_MAX_REQUEST_QUEUE_BYTES 262144 +#define PHTTPD_MAX_SEND_QUEUE_BYTES 262144 +#define PHTTPD_SEND_HIGH_WATER_BYTES 131072 +#define PHTTPD_SEND_LOW_WATER_BYTES 32768 +#define PHTTPD_MAX_EVENTS_PER_TICK 128 +#define PHTTPD_MAX_TICK_BYTES 262144 +#define PHTTPD_DEFAULT_HEADER_MS 10000 +#define PHTTPD_DEFAULT_BODY_IDLE_MS 30000 +#define PHTTPD_DEFAULT_HANDLER_MS 30000 +#define PHTTPD_DEFAULT_KEEP_ALIVE_MS 15000 +#define PHTTPD_DEFAULT_CLOSE_MS 5000 +#define PHTTPD_MAX_TIMEOUT_MS 120000 + +#endif /* POCKETJS_NET_SPEC_H */ diff --git a/framework/compiler/subpaths.ts b/framework/compiler/subpaths.ts index 493d81dc..02f13972 100644 --- a/framework/compiler/subpaths.ts +++ b/framework/compiler/subpaths.ts @@ -82,7 +82,10 @@ export const SUBPATHS: Record = { kinetics: { file: { solid: "framework/src/kinetics.ts" } }, launcher: { file: "framework/src/launcher.ts" }, manifest: { file: "framework/src/manifest/index.ts" }, - net: { file: "framework/src/net-api.ts", aliases: TWINS }, + headless: { file: "framework/src/headless.ts", aliases: TWINS }, + net: { file: "framework/src/net/index.ts", aliases: TWINS }, + "net/http": { file: "framework/src/net/http.ts", aliases: TWINS }, + "net/websocket": { file: "framework/src/net/websocket.ts", aliases: TWINS }, osk: { file: { solid: "framework/src/osk.tsx" } }, package: { file: "contracts/spec/pocket-package.ts" }, platform: { file: "framework/src/platform.ts" }, diff --git a/framework/src/headless.ts b/framework/src/headless.ts new file mode 100644 index 00000000..b5a974cf --- /dev/null +++ b/framework/src/headless.ts @@ -0,0 +1,39 @@ +// Headless runtime entry: the frame transaction without a UI root. +// +// A host without a display (or a display it does not drive from PocketJS) +// still ticks the guest once per host tick through `globalThis.frame(...)`. +// `mountHeadless()` installs a frame handler that runs the same fixed +// prefix of the frame transaction the UI entries run — virtual clock → +// service pumps (network delivery) → effect delivery → app hook — and +// nothing else: no renderer, no input edge detection, no `globalThis.ui` +// requirement. Promise reactions raised inside the pumps run in the host's +// job drain after `frame()` returns, exactly as under `render()`. +// +// This is what the network smoke firmware and headless daemons use; a UI +// app keeps using `render()`/`mount()` from the framework entry. + +import { __advanceClock, resetClock } from "./clock.ts"; +import { __drainEffects, resetEffects } from "./effects.ts"; +import { installFrameHandler } from "./host.ts"; +import { runServicePumps } from "./services.ts"; + +export interface HeadlessOptions { + /** Called every frame after service pumps and effect delivery. */ + frame?: (buttons: number, analog: number) => void; +} + +/** Install the headless frame handler. Returns a disposer that uninstalls it. */ +export function mountHeadless(options: HeadlessOptions = {}): () => void { + resetClock(); // latches the host's __simHz clock policy (docs/DETERMINISM.md) + resetEffects(); + const hook = options.frame; + installFrameHandler((buttons: number, analog?: number) => { + __advanceClock(); // virtual frame++, fire due after() timers + runServicePumps(); // only modules with pending async work register here + __drainEffects(); // frame-boundary deliveries enter the world first + if (hook) hook(buttons, analog ?? 0); + }); + return () => { + (globalThis as { frame?: unknown }).frame = undefined; + }; +} diff --git a/framework/src/net-api.ts b/framework/src/net-api.ts deleted file mode 100644 index 5d034327..00000000 --- a/framework/src/net-api.ts +++ /dev/null @@ -1,332 +0,0 @@ -// PocketJS net SDK — a deliberately small, bounded fetch over globalThis.net. -// The native contract lives in contracts/spec/net.ts. This file is framework -// neutral and serves ./net, ./vue-vapor/net and ./octane/net. - -import { - NET_DEFAULT_RESPONSE_BYTES, - NET_DEFAULT_TIMEOUT_MS, - NET_ERROR, - NET_MAX_HEADER_BYTES, - NET_MAX_HEADERS, - NET_MAX_REQUEST_BYTES, - NET_MAX_RESPONSE_BYTES, - NET_MAX_TIMEOUT_MS, - NET_METHODS, - type NetErrorCode, - type NetMethod, -} from "../../contracts/spec/net.ts"; -import { stringToUtf8, utf8ToString } from "./bytes.ts"; -import { registerServicePump } from "./services.ts"; - -export { - NET_DEFAULT_RESPONSE_BYTES, - NET_DEFAULT_TIMEOUT_MS, - NET_MAX_REQUEST_BYTES, - NET_MAX_RESPONSE_BYTES, - NET_MAX_TIMEOUT_MS, - NET_METHODS, -}; -export type { NetErrorCode, NetMethod }; - -export interface NetOps { - /** Request body is borrowed for this synchronous call. */ - start(metaJson: string, body: ArrayBuffer): number; - /** Copy a completed body into an exactly-sized buffer, exactly once. */ - take(handle: number, into: ArrayBuffer): number; - cancel(handle: number): void; - /** One JSON array containing the entire event batch visible this tick. */ - poll(): string | undefined; - lastError(): string; -} - -export interface FetchOptions { - method?: NetMethod; - headers?: Readonly>; - body?: string | Uint8Array | ArrayBuffer; - /** 1..120000; defaults to 30000. Enforced by the native transport. */ - timeoutMs?: number; - /** Whole response-body cap; defaults to 128 KiB, absolute max 256 KiB. */ - maxBytes?: number; -} - -export class NetError extends Error { - readonly code: NetErrorCode; - - constructor(code: NetErrorCode, message: string) { - super(message); - this.name = "NetError"; - this.code = code; - } -} - -export class PocketResponse { - readonly status: number; - readonly url: string; - readonly headers: Readonly>; - readonly ok: boolean; - private readonly data: Uint8Array; - - constructor( - status: number, - url: string, - headers: Readonly>, - body: ArrayBuffer, - ) { - this.status = status; - this.url = url; - this.headers = Object.freeze({ ...headers }); - this.ok = status >= 200 && status < 300; - this.data = new Uint8Array(body); - } - - get byteLength(): number { - return this.data.byteLength; - } - - /** A copy, so response reads cannot mutate the body retained by this value. */ - async bytes(): Promise { - return this.data.slice(); - } - - async arrayBuffer(): Promise { - return this.data.slice().buffer as ArrayBuffer; - } - - async text(): Promise { - try { - return utf8ToString(this.data); - } catch { - throw new Error("net: response is not valid UTF-8"); - } - } - - async json(): Promise { - return JSON.parse(await this.text()) as T; - } -} - -interface DoneEvent { - t: "done"; - h: number; - status: number; - url: string; - headers: Record; - bytes: number; -} - -interface ErrorEvent { - t: "error"; - h: number; - code: NetErrorCode; - message: string; -} - -type NetEvent = DoneEvent | ErrorEvent; - -interface Pending { - readonly ops: NetOps; - readonly resolve: (response: PocketResponse) => void; - readonly reject: (error: NetError) => void; -} - -const pending = new Map(); -let stopPump: (() => void) | null = null; -let activeOps: NetOps | null = null; - -export function netHost(): NetOps | null { - const ns = (globalThis as { net?: unknown }).net; - if (!ns || typeof ns !== "object") return null; - const ops = ns as Partial; - return typeof ops.start === "function" && - typeof ops.take === "function" && - typeof ops.cancel === "function" && - typeof ops.poll === "function" && - typeof ops.lastError === "function" - ? (ops as NetOps) - : null; -} - -function errorCode(value: unknown): NetErrorCode { - const code = String(value); - for (const known of Object.values(NET_ERROR)) { - if (known === code) return known; - } - return NET_ERROR.other; -} - -function settle(ev: NetEvent): void { - const p = pending.get(ev.h); - if (!p) return; - pending.delete(ev.h); - if (ev.t === "error") { - p.reject(new NetError(errorCode(ev.code), String(ev.message || ev.code))); - } else { - if ( - !Number.isInteger(ev.status) || - ev.status < 100 || - ev.status > 599 || - typeof ev.url !== "string" || - typeof ev.headers !== "object" || - ev.headers === null || - !Number.isInteger(ev.bytes) || - ev.bytes < 0 || - ev.bytes > NET_MAX_RESPONSE_BYTES - ) { - p.ops.cancel(ev.h); - p.reject(new NetError(NET_ERROR.protocol, "net: malformed done event")); - } else { - const body = new ArrayBuffer(ev.bytes); - const copied = p.ops.take(ev.h, body); - if (copied !== ev.bytes) { - p.ops.cancel(ev.h); - p.reject(new NetError(NET_ERROR.protocol, "net: response body transfer failed")); - } else { - p.resolve(new PocketResponse(ev.status, ev.url, ev.headers, body)); - } - } - } - if (pending.size === 0 && stopPump) { - stopPump(); - stopPump = null; - activeOps = null; - } -} - -/** Internal module service hook. It performs exactly one native poll call and - * only exists in the frame pump while at least one fetch is pending. */ -export function __pumpNet(): void { - if (pending.size === 0) return; - const ops = activeOps; - if (!ops) return; - const batch = ops.poll(); - if (batch !== undefined) { - let events: unknown = null; - try { - events = JSON.parse(batch); - } catch { - // handled as a protocol failure below - } - if (!Array.isArray(events)) { - for (const [handle, p] of pending) { - ops.cancel(handle); - pending.delete(handle); - p.reject(new NetError(NET_ERROR.protocol, "net: malformed event batch")); - } - } else { - for (const event of events) { - if (!event || typeof event !== "object") continue; - const ev = event as Partial; - if (!Number.isInteger(ev.h) || (ev.t !== "done" && ev.t !== "error")) continue; - settle(ev as NetEvent); - } - } - } - if (pending.size === 0 && stopPump) { - stopPump(); - stopPump = null; - activeOps = null; - } -} - -function reject(code: NetErrorCode, message: string): Promise { - return Promise.reject(new NetError(code, message)); -} - -function integerInRange(value: number, min: number, max: number, label: string): number { - if (!Number.isInteger(value) || value < min || value > max) { - throw new NetError(NET_ERROR.invalidRequest, `net: ${label} must be ${min}..${max}`); - } - return value; -} - -function normalizeHeaders(input: Readonly> | undefined): Record { - const out = Object.create(null) as Record; - let count = 0; - let bytes = 0; - for (const rawName of Object.keys(input ?? {})) { - const name = rawName.toLowerCase(); - const value = String(input![rawName]); - if (!/^[!#$%&'*+.^_`|~0-9a-z-]+$/.test(name) || /[\r\n]/.test(value)) { - throw new NetError(NET_ERROR.invalidRequest, `net: invalid header ${rawName}`); - } - count++; - bytes += stringToUtf8(name).byteLength + stringToUtf8(value).byteLength + 4; - if (count > NET_MAX_HEADERS || bytes > NET_MAX_HEADER_BYTES) { - throw new NetError(NET_ERROR.invalidRequest, "net: request headers exceed limits"); - } - out[name] = value; - } - return out; -} - -function requestBody(body: FetchOptions["body"]): Uint8Array { - if (body === undefined) return new Uint8Array(0); - if (typeof body === "string") return stringToUtf8(body); - if (body instanceof Uint8Array) return body.slice(); - if (body instanceof ArrayBuffer) return new Uint8Array(body.slice(0)); - throw new NetError(NET_ERROR.invalidRequest, "net: body must be string or bytes"); -} - -/** The PocketJS HTTP client. It is fetch-shaped but intentionally not the - * complete browser Fetch API: no streams, cookies, cache, Request, Signal or - * implicit ambient authority. */ -export function fetch(url: string, options: FetchOptions = {}): Promise { - const ops = netHost(); - if (!ops) return reject(NET_ERROR.unavailable, "net: host did not mount the net module"); - - try { - if (typeof url !== "string" || !/^https?:\/\/[^\s/]+(?:\/|$)/.test(url)) { - throw new NetError(NET_ERROR.invalidRequest, "net: url must be absolute http:// or https://"); - } - const method = options.method ?? "GET"; - if (!(NET_METHODS as readonly string[]).includes(method)) { - throw new NetError(NET_ERROR.invalidRequest, `net: unsupported method ${String(method)}`); - } - const body = requestBody(options.body); - if ((method === "GET" || method === "HEAD") && body.byteLength > 0) { - throw new NetError(NET_ERROR.invalidRequest, `net: ${method} cannot have a body`); - } - if (body.byteLength > NET_MAX_REQUEST_BYTES) { - throw new NetError(NET_ERROR.invalidRequest, "net: request body exceeds 64 KiB"); - } - const timeoutMs = integerInRange( - options.timeoutMs ?? NET_DEFAULT_TIMEOUT_MS, - 1, - NET_MAX_TIMEOUT_MS, - "timeoutMs", - ); - const maxBytes = integerInRange( - options.maxBytes ?? NET_DEFAULT_RESPONSE_BYTES, - 1, - NET_MAX_RESPONSE_BYTES, - "maxBytes", - ); - const meta = JSON.stringify({ - url, - method, - headers: normalizeHeaders(options.headers), - timeoutMs, - maxBytes, - }); - if (activeOps && activeOps !== ops) { - throw new NetError(NET_ERROR.unavailable, "net: mounted host changed while requests are pending"); - } - const handle = ops.start(meta, body.buffer as ArrayBuffer); - if (!Number.isInteger(handle) || handle < 0) { - const detail = ops.lastError() || "unavailable: request refused"; - const split = detail.indexOf(":"); - const code = errorCode(split < 0 ? NET_ERROR.other : detail.slice(0, split)); - const message = split < 0 ? detail : detail.slice(split + 1).trim(); - return reject(code, message); - } - return new Promise((resolve, rejectPending) => { - pending.set(handle, { ops, resolve, reject: rejectPending }); - activeOps = ops; - if (!stopPump) stopPump = registerServicePump(__pumpNet); - }); - } catch (error) { - return error instanceof NetError - ? Promise.reject(error) - : reject(NET_ERROR.invalidRequest, String(error)); - } -} diff --git a/framework/src/net/abort.ts b/framework/src/net/abort.ts new file mode 100644 index 00000000..7828bf66 --- /dev/null +++ b/framework/src/net/abort.ts @@ -0,0 +1,87 @@ +// AbortController / AbortSignal for the network modules. QuickJS ships no +// DOM; the module provides its own pair with the DOM shape apps expect +// (`aborted`, `reason`, `throwIfAborted()`, `addEventListener("abort")`, +// `onabort`) so `fetch({ signal })` and `connect(...)` work on every host. +// The listeners run synchronously inside `abort()`, in registration order. + +type AbortListener = (event: { type: "abort"; target: AbortSignal }) => void; + +export class AbortSignal { + private _aborted = false; + private _reason: unknown = undefined; + private readonly listeners = new Set(); + onabort: AbortListener | null = null; + + get aborted(): boolean { + return this._aborted; + } + + get reason(): unknown { + return this._reason; + } + + throwIfAborted(): void { + if (this._aborted) throw this._reason; + } + + addEventListener(type: "abort", listener: AbortListener): void { + if (type !== "abort") return; + this.listeners.add(listener); + } + + removeEventListener(type: "abort", listener: AbortListener): void { + if (type !== "abort") return; + this.listeners.delete(listener); + } + + /** @internal */ + __abort(reason: unknown): void { + if (this._aborted) return; + this._aborted = true; + this._reason = reason === undefined ? new AbortError() : reason; + const event = { type: "abort" as const, target: this }; + if (this.onabort) this.onabort(event); + for (const listener of [...this.listeners]) listener(event); + this.listeners.clear(); + } + + static abort(reason?: unknown): AbortSignal { + const signal = new AbortSignal(); + signal.__abort(reason); + return signal; + } +} + +/** The default abort reason, DOMException-shaped. */ +export class AbortError extends Error { + readonly code = 20; + constructor(message = "The operation was aborted") { + super(message); + this.name = "AbortError"; + } +} + +export class AbortController { + readonly signal = new AbortSignal(); + + abort(reason?: unknown): void { + this.signal.__abort(reason); + } +} + +/** Accept a module signal or a host-native one (browser adapters) by shape. */ +export interface AbortSignalLike { + readonly aborted: boolean; + readonly reason?: unknown; + addEventListener(type: "abort", listener: (event?: unknown) => void): void; + removeEventListener?(type: "abort", listener: (event?: unknown) => void): void; +} + +export function isAbortSignalLike(value: unknown): value is AbortSignalLike { + return ( + !!value && + typeof value === "object" && + typeof (value as AbortSignalLike).aborted === "boolean" && + typeof (value as AbortSignalLike).addEventListener === "function" + ); +} diff --git a/framework/src/net/binding.ts b/framework/src/net/binding.ts new file mode 100644 index 00000000..44760da8 --- /dev/null +++ b/framework/src/net/binding.ts @@ -0,0 +1,204 @@ +// Network Guest Binding — the SDK-internal layer between the public modules +// and the spec-pinned namespaces (`globalThis.net` / `ws` / `httpd`). It +// finds a namespace, checks the spec major version once, drains one `poll` +// batch per tick from the framework service pump while a module has live +// handles, and hands each event to the module. Nothing here is public API. +// +// Delivery order: the host runs +// `begin_tick` before `frame()`; inside `frame()` the service pump calls a +// module's `poll` exactly once; the module updates JS state, calls handlers +// and settles Promises synchronously; Promise reactions run in the same +// tick's job drain. + +import { NET_ERROR } from "../../../contracts/spec/net.ts"; +import { registerServicePump } from "../services.ts"; +import { NetworkError, type NetworkProtocol } from "./errors.ts"; + +export interface NamespaceOps { + poll(): string | undefined; + lastError(): string; + limits(): string; +} + +export type EventRecord = Record & { t: string }; + +export interface ModuleBinding { + readonly name: string; + readonly protocol: NetworkProtocol; + /** The mounted ops, or null when the host did not mount the namespace. */ + ops(): Ops | null; + /** The mounted ops or a rejected-promise-style NetworkError. */ + require(operation: string): Ops; + /** Parsed `limits()` snapshot (cached after the first read). */ + limits(): Record; + /** Register/unregister interest in per-tick delivery. */ + retain(): void; + release(): void; + /** Number of live handles (for tests and diagnostics). */ + live(): number; + /** Runs one poll and dispatches (exposed for deterministic tests). */ + pump(): void; +} + +export interface BindingSpec { + name: string; + protocol: NetworkProtocol; + specMajor: number; + requiredOps: readonly (keyof Ops & string)[]; + dispatch(event: EventRecord, ops: Ops): void; + /** Called when a poll batch is malformed; the module must fail its handles. */ + onProtocolFailure(ops: Ops, error: NetworkError): void; +} + +export function createBinding(spec: BindingSpec): ModuleBinding { + let cachedOps: Ops | null = null; + let cachedLimits: Record | null = null; + let liveCount = 0; + let stopPump: (() => void) | null = null; + + function lookup(): Ops | null { + const ns = (globalThis as Record)[spec.name]; + if (!ns || typeof ns !== "object") return null; + for (const op of spec.requiredOps) { + if (typeof (ns as Record)[op] !== "function") return null; + } + return ns as Ops; + } + + function ops(): Ops | null { + const found = lookup(); + if (found && found !== cachedOps) { + // A different namespace object (host remounted): forget the snapshot. + cachedOps = found; + cachedLimits = null; + } else if (!found) { + cachedOps = null; + cachedLimits = null; + } + return found; + } + + function limits(): Record { + if (cachedLimits) return cachedLimits; + const found = ops(); + if (!found) { + throw new NetworkError(NET_ERROR.unavailable, `${spec.name}: host did not mount the module`, { + operation: "limits", + protocol: spec.protocol, + }); + } + let parsed: unknown = null; + try { + parsed = JSON.parse(found.limits()); + } catch { + parsed = null; + } + if (!parsed || typeof parsed !== "object") { + throw new NetworkError(NET_ERROR.protocol, `${spec.name}: malformed limits()`, { + operation: "limits", + protocol: spec.protocol, + }); + } + const record = parsed as Record; + if (record.specMajor !== spec.specMajor) { + throw new NetworkError( + NET_ERROR.unsupported, + `${spec.name}: host speaks spec ${String(record.specMajor)}, SDK requires ${spec.specMajor}`, + { operation: "limits", protocol: spec.protocol }, + ); + } + cachedLimits = Object.freeze(record); + return cachedLimits; + } + + function require(operation: string): Ops { + const found = ops(); + if (!found) { + throw new NetworkError(NET_ERROR.unavailable, `${spec.name}: host did not mount the module`, { + operation, + protocol: spec.protocol, + }); + } + limits(); // spec version check on first use + return found; + } + + function pump(): void { + if (liveCount === 0) return; + const found = cachedOps ?? ops(); + if (!found) return; + const batch = found.poll(); + if (batch === undefined) return; + let events: unknown = null; + try { + events = JSON.parse(batch); + } catch { + events = null; + } + if (!Array.isArray(events)) { + spec.onProtocolFailure( + found, + new NetworkError(NET_ERROR.protocol, `${spec.name}: malformed event batch`, { + operation: "poll", + protocol: spec.protocol, + }), + ); + return; + } + for (const event of events) { + if (!event || typeof event !== "object") continue; + const record = event as EventRecord; + if (typeof record.t !== "string") continue; + spec.dispatch(record, found); + } + } + + function retain(): void { + liveCount++; + if (!stopPump) stopPump = registerServicePump(pump); + } + + function release(): void { + if (liveCount > 0) liveCount--; + if (liveCount === 0 && stopPump) { + stopPump(); + stopPump = null; + } + } + + return { + name: spec.name, + protocol: spec.protocol, + ops, + require, + limits, + retain, + release, + live: () => liveCount, + pump, + }; +} + +/** Integer option validation shared by the modules. */ +export function integerOption( + value: unknown, + label: string, + min: number, + max: number, + operation: string, + protocol: NetworkProtocol, +): number { + if (typeof value !== "number" || !Number.isInteger(value) || value < min || value > max) { + throw new NetworkError(NET_ERROR.invalidRequest, `${label} must be an integer from ${min} through ${max}`, { + operation, + protocol, + }); + } + return value; +} + +/** Read `name` from a limits snapshot as a positive integer, else fallback. */ +export function limitNumber(limits: Record, name: string, fallback: number): number { + const v = limits[name]; + return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : fallback; +} diff --git a/framework/src/net/body.ts b/framework/src/net/body.ts new file mode 100644 index 00000000..3c7ccd06 --- /dev/null +++ b/framework/src/net/body.ts @@ -0,0 +1,613 @@ +// BodyStream — the single-consumer byte stream every HTTP body uses. +// Three flavours share one +// public shape: bytes already in JS (request bodies built from NetworkData, +// Response bodies constructed by the app), bytes that live in a native queue +// and cross only through the module's `readInto` op (client responses, +// server requests), and the bounded tee behind `clone()`. +// +// Native-backed streams consume only bytes that became visible at the last +// tick boundary; a read that cannot be satisfied parks until the next +// `readable`/`end`/`error` event delivered by the service pump. At most one +// read is pending per stream; the aggregate helpers (`text()`, `json()`, +// `arrayBuffer()`) sit on top of the same path and cancel the handle with +// `response_too_large` past their limit. + +import { NET_ERROR } from "../../../contracts/spec/net.ts"; +import { stringToUtf8, utf8ToString } from "../bytes.ts"; +import { NetworkError, type NetworkProtocol } from "./errors.ts"; + +export interface BodyReadResult { + bytes: number; + done: boolean; +} + +export interface BodyStream extends AsyncIterable { + readInto(destination: Uint8Array): Promise; + cancel(reason?: unknown): Promise; +} + +export type NetworkData = string | ArrayBuffer | ArrayBufferView; + +/** Snapshot NetworkData into an owned Uint8Array (strings as UTF-8, views by + * their current window). Detached buffers fail with `invalid_state`. */ +export function snapshotData(data: NetworkData, operation: string, protocol: NetworkProtocol): Uint8Array { + if (typeof data === "string") return stringToUtf8(data); + if (data instanceof ArrayBuffer) { + if (data.byteLength === 0 && isDetached(data)) { + throw new NetworkError(NET_ERROR.invalidState, "buffer is detached", { operation, protocol }); + } + return new Uint8Array(data.slice(0)); + } + if (ArrayBuffer.isView(data)) { + const view = data as ArrayBufferView; + if (view.byteLength === 0 && isDetached(view.buffer as ArrayBuffer)) { + throw new NetworkError(NET_ERROR.invalidState, "buffer is detached", { operation, protocol }); + } + return new Uint8Array(view.buffer as ArrayBuffer, view.byteOffset, view.byteLength).slice(); + } + throw new NetworkError(NET_ERROR.invalidRequest, "body must be a string, ArrayBuffer or ArrayBufferView", { + operation, + protocol, + }); +} + +function isDetached(buffer: ArrayBuffer): boolean { + const b = buffer as ArrayBuffer & { detached?: boolean }; + if (typeof b.detached === "boolean") return b.detached; + try { + new Uint8Array(buffer); + return false; + } catch { + return true; + } +} + +/** Common lock/consumption bookkeeping. */ +abstract class BaseBody implements BodyStream { + protected locked = false; + protected consumed = false; + protected readonly protocol: NetworkProtocol; + + constructor(protocol: NetworkProtocol) { + this.protocol = protocol; + } + + /** True once any reader, iterator or helper took the stream. */ + get bodyUsed(): boolean { + return this.locked; + } + + protected lock(operation: string): void { + if (this.locked) { + throw new NetworkError(NET_ERROR.invalidState, "body is already in use", { + operation, + protocol: this.protocol, + }); + } + this.locked = true; + } + + abstract readInto(destination: Uint8Array): Promise; + abstract cancel(reason?: unknown): Promise; + /** Bytes known to arrive in total, or -1 when unknown. */ + abstract knownLength(): number; + /** Bytes readable right now without waiting. */ + abstract available(): number; + + [Symbol.asyncIterator](): AsyncIterator { + // Async iteration takes the lock lazily on the first next() so that + // `for await` over an already-locked body rejects rather than throws. + const chunkBytes = 16 * 1024; + let started = false; + let finished = false; + return { + next: async (): Promise> => { + if (finished) return { value: undefined, done: true }; + if (!started) { + started = true; + this.lock("iterate"); + } + for (;;) { + const size = Math.max(1, Math.min(chunkBytes, this.available() || chunkBytes)); + const chunk = new Uint8Array(size); + const { bytes, done } = await this.readIntoLocked(chunk); + if (bytes > 0) return { value: chunk.subarray(0, bytes), done: false }; + if (done) { + finished = true; + return { value: undefined, done: true }; + } + } + }, + return: async (): Promise> => { + finished = true; + await this.cancel(); + return { value: undefined, done: true }; + }, + }; + } + + /** readInto for a caller that already holds the lock. */ + protected abstract readIntoLocked(destination: Uint8Array): Promise; + + /** Aggregate helper: whole body as bytes, bounded by `limitBytes`. */ + async collect(limitBytes: number, operation: string): Promise { + this.lock(operation); + const tooLarge = async (): Promise => { + await this.cancel(); + throw new NetworkError(NET_ERROR.responseTooLarge, `body exceeds ${limitBytes} bytes`, { + operation, + protocol: this.protocol, + }); + }; + const known = this.knownLength(); + if (known > limitBytes) return tooLarge(); + if (known >= 0) { + // Content-Length known: one exact allocation, filled as bytes arrive. + const buffer = new Uint8Array(known); + let filled = 0; + let done = false; + while (filled < known && !done) { + const r = await this.readIntoLocked(buffer.subarray(filled)); + filled += r.bytes; + done = r.done; + } + if (!done) { + // The last bytes and the terminal event may land in different + // ticks; observe EOF so the handle retires before we return. + const probe = new Uint8Array(1); + const r = await this.readIntoLocked(probe); + if (r.bytes > 0) { + await this.cancel(); + throw new NetworkError(NET_ERROR.protocol, "body exceeds its declared length", { + operation, + protocol: this.protocol, + }); + } + } + return filled === known ? buffer : buffer.subarray(0, filled); + } + // Unknown length (chunked / close-delimited): grow geometrically. + let buffer = new Uint8Array(Math.min(limitBytes, Math.max(this.available(), 8 * 1024))); + let filled = 0; + for (;;) { + if (filled === buffer.length) { + if (buffer.length >= limitBytes) return tooLarge(); + const grown = new Uint8Array(Math.min(limitBytes, Math.max(buffer.length * 2, filled + this.available()))); + grown.set(buffer); + buffer = grown; + } + const r = await this.readIntoLocked(buffer.subarray(filled)); + filled += r.bytes; + if (r.done) break; + } + return filled === buffer.length ? buffer : buffer.slice(0, filled); + } + + async collectText(limitBytes: number, operation: string): Promise { + const bytes = await this.collect(limitBytes, operation); + try { + return utf8ToString(bytes); + } catch { + throw new NetworkError(NET_ERROR.protocol, "body is not valid UTF-8", { + operation, + protocol: this.protocol, + }); + } + } +} + +/** Bytes already held in JS. */ +export class MemoryBody extends BaseBody { + private readonly bytes: Uint8Array; + private offset = 0; + private cancelled = false; + + constructor(bytes: Uint8Array, protocol: NetworkProtocol) { + super(protocol); + this.bytes = bytes; + } + + /** The unread bytes; used by the modules to snapshot outbound bodies. */ + peek(): Uint8Array { + return this.bytes.subarray(this.offset); + } + + knownLength(): number { + return this.bytes.length; + } + + available(): number { + return this.bytes.length - this.offset; + } + + readInto(destination: Uint8Array): Promise { + try { + this.lockOnce("readInto"); + } catch (error) { + return Promise.reject(error); + } + return this.readIntoLocked(destination); + } + + private lockOnce(operation: string): void { + if (!this.consumed) { + this.lock(operation); + this.consumed = true; + } + } + + protected async readIntoLocked(destination: Uint8Array): Promise { + if (destination.length === 0) { + throw new NetworkError(NET_ERROR.invalidRequest, "destination is empty", { + operation: "readInto", + protocol: this.protocol, + }); + } + if (this.cancelled) return { bytes: 0, done: true }; + const n = Math.min(destination.length, this.bytes.length - this.offset); + destination.set(this.bytes.subarray(this.offset, this.offset + n)); + this.offset += n; + return { bytes: n, done: this.offset >= this.bytes.length }; + } + + async cancel(): Promise { + this.cancelled = true; + this.locked = true; + this.offset = this.bytes.length; + } + + /** A second view of the same bytes (both start unread). */ + fork(): MemoryBody { + return new MemoryBody(this.bytes, this.protocol); + } +} + +/** The native side of a queue-backed stream: one `readInto` op bound to a + * handle, plus a way to cancel the handle. */ +export interface NativeSource { + /** Copy up to dest.length visible bytes into dest; -1 = handle gone. */ + pull(destination: Uint8Array): number; + /** Ask the module to cancel the handle; the terminal event follows later. */ + cancel(reason: unknown): void; +} + +/** Bytes that live in a native queue and cross through `readInto`. */ +export class NativeBody extends BaseBody { + private readonly source: NativeSource; + private avail = 0; + private ended = false; + private failure: NetworkError | null = null; + private terminal = false; + private waiter: { resolve: (r: BodyReadResult) => void; reject: (e: unknown) => void; dest: Uint8Array } | null = null; + private cancelWaiters: (() => void)[] = []; + private cancelRequested = false; + private readonly length: number; + + constructor(source: NativeSource, protocol: NetworkProtocol, knownLength: number) { + super(protocol); + this.source = source; + this.length = knownLength; + } + + knownLength(): number { + return this.length; + } + + available(): number { + return this.avail; + } + + /** True once end/error/cancel settled the native handle. */ + get isTerminal(): boolean { + return this.terminal; + } + + readInto(destination: Uint8Array): Promise { + try { + if (!this.consumed) { + this.lock("readInto"); + this.consumed = true; + } + } catch (error) { + return Promise.reject(error); + } + return this.readIntoLocked(destination); + } + + protected readIntoLocked(destination: Uint8Array): Promise { + if (destination.length === 0) { + return Promise.reject( + new NetworkError(NET_ERROR.invalidRequest, "destination is empty", { + operation: "readInto", + protocol: this.protocol, + }), + ); + } + if (this.waiter) { + return Promise.reject( + new NetworkError(NET_ERROR.busy, "a read is already pending", { + operation: "readInto", + protocol: this.protocol, + }), + ); + } + const immediate = this.tryRead(destination); + if (immediate) return Promise.resolve(immediate); + if (this.failure) return Promise.reject(this.failure); + return new Promise((resolve, reject) => { + this.waiter = { resolve, reject, dest: destination }; + }); + } + + /** Satisfy a read from visible bytes; null when nothing is readable yet. */ + private tryRead(destination: Uint8Array): BodyReadResult | null { + if (this.avail > 0) { + const want = Math.min(destination.length, this.avail); + const got = this.source.pull(destination.subarray(0, want)); + if (got < 0) { + this.avail = 0; + if (!this.ended && !this.failure) { + this.failure = new NetworkError(NET_ERROR.closed, "body handle is gone", { + operation: "readInto", + protocol: this.protocol, + }); + } + if (this.failure) return null; + return { bytes: 0, done: true }; + } + this.avail -= got; + if (got > 0 || this.avail === 0) { + return { bytes: got, done: this.ended && this.avail === 0 }; + } + } + if (this.ended) return { bytes: 0, done: true }; + return null; + } + + private settleWaiter(): void { + const w = this.waiter; + if (!w) return; + const result = this.tryRead(w.dest); + if (result) { + this.waiter = null; + w.resolve(result); + return; + } + if (this.failure) { + this.waiter = null; + w.reject(this.failure); + } + } + + /** Module callbacks (service pump delivery). */ + onReadable(avail: number): void { + if (this.terminal) return; + this.avail = Math.max(0, avail | 0); + this.settleWaiter(); + } + + onEnd(): void { + if (this.terminal) return; + this.ended = true; + this.terminal = true; + this.settleWaiter(); + this.flushCancelWaiters(); + } + + onError(error: NetworkError): void { + if (this.terminal) return; + this.terminal = true; + if (this.cancelRequested && error.code === NET_ERROR.cancelled) { + // A cancel we asked for: readers observe EOF, not an error. + this.ended = true; + this.avail = 0; + } else { + this.failure = error; + this.avail = 0; + } + this.settleWaiter(); + this.flushCancelWaiters(); + } + + private flushCancelWaiters(): void { + const waiters = this.cancelWaiters; + this.cancelWaiters = []; + for (const w of waiters) w(); + } + + cancel(reason?: unknown): Promise { + this.locked = true; + if (!this.cancelRequested) { + this.cancelRequested = true; + // Even after `end`, tell the module so unread native bytes are freed; + // on a retired handle the op is a no-op by contract. + this.source.cancel(reason); + } + this.avail = 0; + if (this.terminal) return Promise.resolve(); + return new Promise((resolve) => { + this.cancelWaiters.push(resolve); + }); + } +} + +/** Bounded tee for `clone()`: two branches over one source, each branch + * buffering what the other consumed first, up to `limitBytes`. When a branch + * falls behind by more than the limit, the leading branch waits (backpressure + * on the source) until the lagging branch reads or cancels. */ +export function teeBody( + source: BaseBody, + protocol: NetworkProtocol, + limitBytes: number, +): [TeeBranch, TeeBranch] { + const shared = new TeeShared(source, protocol, limitBytes); + return [shared.branch(0), shared.branch(1)]; +} + +class TeeShared { + readonly buffers: [Uint8Array[], Uint8Array[]] = [[], []]; + readonly buffered: [number, number] = [0, 0]; + readonly cancelled: [boolean, boolean] = [false, false]; + readonly waiters: [(() => void) | null, (() => void) | null] = [null, null]; + ended = false; + failure: unknown = null; + pulling: Promise | null = null; + readonly source: BaseBody; + readonly protocol: NetworkProtocol; + readonly limit: number; + + constructor(source: BaseBody, protocol: NetworkProtocol, limit: number) { + this.source = source; + this.protocol = protocol; + this.limit = limit; + this.source["lock"]("clone"); + } + + branch(index: 0 | 1): TeeBranch { + return new TeeBranch(this, index); + } + + wake(index: 0 | 1): void { + const w = this.waiters[index]; + if (w) { + this.waiters[index] = null; + w(); + } + } + + /** Pull one chunk from the source into both branch buffers. */ + pull(): Promise { + if (this.pulling) return this.pulling; + this.pulling = (async () => { + const chunk = new Uint8Array(16 * 1024); + try { + const { bytes, done } = await this.source["readIntoLocked"](chunk); + if (bytes > 0) { + const data = chunk.slice(0, bytes); + for (const i of [0, 1] as const) { + if (this.cancelled[i]) continue; + this.buffers[i].push(data); + this.buffered[i] += bytes; + } + } + if (done) this.ended = true; + } catch (error) { + this.failure = error; + } finally { + this.pulling = null; + this.wake(0); + this.wake(1); + } + })(); + return this.pulling; + } + + /** The other branch is too far behind to pull more. */ + blocked(index: 0 | 1): boolean { + const other = index === 0 ? 1 : 0; + return !this.cancelled[other] && this.buffered[other] >= this.limit; + } + + async cancelBranch(index: 0 | 1, reason: unknown): Promise { + this.cancelled[index] = true; + this.buffers[index] = []; + this.buffered[index] = 0; + const other = index === 0 ? 1 : 0; + this.wake(other); + if (this.cancelled[other]) await this.source.cancel(reason); + } +} + +export class TeeBranch extends BaseBody { + private readonly shared: TeeShared; + private readonly index: 0 | 1; + private consumedOnce = false; + + constructor(shared: TeeShared, index: 0 | 1) { + super(shared.protocol); + this.shared = shared; + this.index = index; + } + + knownLength(): number { + return this.shared.source.knownLength(); + } + + available(): number { + return this.shared.buffered[this.index]; + } + + readInto(destination: Uint8Array): Promise { + try { + if (!this.consumedOnce) { + this.lock("readInto"); + this.consumedOnce = true; + } + } catch (error) { + return Promise.reject(error); + } + return this.readIntoLocked(destination); + } + + protected async readIntoLocked(destination: Uint8Array): Promise { + if (destination.length === 0) { + throw new NetworkError(NET_ERROR.invalidRequest, "destination is empty", { + operation: "readInto", + protocol: this.protocol, + }); + } + const s = this.shared; + for (;;) { + if (s.cancelled[this.index]) return { bytes: 0, done: true }; + const queue = s.buffers[this.index]; + if (queue.length) { + let filled = 0; + while (queue.length && filled < destination.length) { + const head = queue[0]; + const n = Math.min(head.length, destination.length - filled); + destination.set(head.subarray(0, n), filled); + filled += n; + if (n === head.length) queue.shift(); + else queue[0] = head.subarray(n); + } + s.buffered[this.index] -= filled; + s.wake(this.index === 0 ? 1 : 0); + return { bytes: filled, done: s.ended && queue.length === 0 }; + } + if (s.failure) throw s.failure; + if (s.ended) return { bytes: 0, done: true }; + if (s.blocked(this.index)) { + await new Promise((resolve) => { + s.waiters[this.index] = resolve; + }); + continue; + } + await s.pull(); + } + } + + cancel(reason?: unknown): Promise { + this.locked = true; + return this.shared.cancelBranch(this.index, reason); + } +} + +/** Convert an app-supplied body input into a stream the module can use, or + * null when there is no body. */ +export function bodyFromInput( + input: NetworkData | BodyStream | AsyncIterable | null | undefined, + operation: string, + protocol: NetworkProtocol, +): BaseBody | AsyncIterable | null { + if (input === null || input === undefined) return null; + if (input instanceof BaseBody) return input; + if (typeof input === "string" || input instanceof ArrayBuffer || ArrayBuffer.isView(input)) { + return new MemoryBody(snapshotData(input as NetworkData, operation, protocol), protocol); + } + if (typeof (input as AsyncIterable)[Symbol.asyncIterator] === "function") { + return input as AsyncIterable; + } + throw new NetworkError(NET_ERROR.invalidRequest, "unsupported body type", { operation, protocol }); +} + +export { BaseBody }; diff --git a/framework/src/net/errors.ts b/framework/src/net/errors.ts new file mode 100644 index 00000000..c264621f --- /dev/null +++ b/framework/src/net/errors.ts @@ -0,0 +1,74 @@ +// NetworkError — the one public error class of the network modules. +// Codes are the stable strings +// of contracts/spec/net.ts NET_ERROR, shared by net, ws and httpd; the +// category is derived from the code, never sent by a host. + +import { NET_ERROR, netErrorCategory, type NetErrorCode } from "../../../contracts/spec/net.ts"; + +export type NetworkErrorCategory = "runtime" | "resolver" | "transport" | "tls" | "protocol"; +export type NetworkProtocol = "http" | "websocket" | "mqtt" | "tcp" | "udp"; + +export interface NetworkErrorInit { + operation: string; + temporary?: boolean; + address?: string; + port?: number; + protocol?: NetworkProtocol; + causeCode?: string; + reasonCode?: number; +} + +/** Codes a host may report as temporary conditions. */ +const TEMPORARY = new Set([ + NET_ERROR.dns, + NET_ERROR.connect, + NET_ERROR.timeout, + NET_ERROR.busy, + NET_ERROR.resourceLimit, +]); + +export class NetworkError extends Error { + readonly category: NetworkErrorCategory; + readonly code: string; + readonly operation: string; + readonly temporary: boolean; + readonly address?: string; + readonly port?: number; + readonly protocol?: NetworkProtocol; + readonly causeCode?: string; + readonly reasonCode?: number; + + constructor(code: string, message: string, init: NetworkErrorInit) { + super(message); + this.name = "NetworkError"; + this.code = code; + this.category = netErrorCategory(code); + this.operation = init.operation; + this.temporary = init.temporary ?? TEMPORARY.has(code); + if (init.address !== undefined) this.address = init.address; + if (init.port !== undefined) this.port = init.port; + if (init.protocol !== undefined) this.protocol = init.protocol; + if (init.causeCode !== undefined) this.causeCode = init.causeCode; + if (init.reasonCode !== undefined) this.reasonCode = init.reasonCode; + } +} + +const KNOWN_CODES = new Set(Object.values(NET_ERROR)); + +/** Clamp a host-reported code onto the shared vocabulary. */ +export function normalizeErrorCode(value: unknown): NetErrorCode { + const code = String(value); + return KNOWN_CODES.has(code) ? (code as NetErrorCode) : NET_ERROR.other; +} + +/** Turn a namespace `lastError()` string (`code: message`) into an error. */ +export function errorFromLastError( + detail: string, + operation: string, + protocol: NetworkProtocol, +): NetworkError { + const split = detail.indexOf(":"); + const code = normalizeErrorCode(split < 0 ? NET_ERROR.other : detail.slice(0, split)); + const message = split < 0 ? detail || "request refused" : detail.slice(split + 1).trim(); + return new NetworkError(code, message, { operation, protocol }); +} diff --git a/framework/src/net/http.ts b/framework/src/net/http.ts new file mode 100644 index 00000000..9275bbb4 --- /dev/null +++ b/framework/src/net/http.ts @@ -0,0 +1,1458 @@ +// @pocketjs/framework/net/http — HTTP Client (`fetch`) and HTTP Server +// (`serve`) over the `globalThis.net` / `globalThis.httpd` boundaries +// (contracts/spec/net.ts, contracts/spec/httpd.ts). Object shapes follow the +// WHATWG Fetch standard, with these PocketJS deviations: body locking, repeat +// consumption and detached input fail with a stable NetworkError; every +// network, permission, timeout and resource failure is a NetworkError too. +// +// Delivery: `fetch()` resolves when the response head is visible at a tick +// boundary; the body streams through `Response.body` (a BodyStream over the +// module's `readInto` op). `serve()` delivers each request from the same +// service pump and writes the handler's Response through `respond`/`write`. + +import { + NET_DEFAULT_AGGREGATE_BYTES, + NET_DEFAULT_QUEUE_BYTES, + NET_DEFAULT_TIMEOUT_MS, + NET_ERROR, + NET_MAX_AGGREGATE_BYTES, + NET_MAX_HEADER_BYTES, + NET_MAX_HEADERS, + NET_MAX_QUEUE_BYTES, + NET_MAX_REDIRECTS, + NET_MAX_REQUEST_BYTES, + NET_MAX_TIMEOUT_MS, + NET_METHODS_FORBIDDEN, + NET_SPEC_MAJOR, + type NetStartMeta, +} from "../../../contracts/spec/net.ts"; +import { + HTTPD_MAX_BACKLOG, + HTTPD_MAX_CONNECTIONS, + HTTPD_MAX_INFLIGHT, + HTTPD_MAX_REQUEST_QUEUE_BYTES, + HTTPD_MAX_SEND_QUEUE_BYTES, + HTTPD_MAX_TIMEOUT_MS, + HTTPD_SEND_ACCEPTED, + HTTPD_SEND_BACKPRESSURE, + HTTPD_SEND_INVALID, + HTTPD_SEND_INVALID_REQUEST, + HTTPD_SPEC_MAJOR, + type HttpdListenMeta, + type HttpdRespondMeta, +} from "../../../contracts/spec/httpd.ts"; +import { stringToUtf8 } from "../bytes.ts"; +import { AbortController, AbortSignal, isAbortSignalLike, type AbortSignalLike } from "./abort.ts"; +import { + BaseBody, + MemoryBody, + NativeBody, + bodyFromInput, + teeBody, + type BodyStream, + type NetworkData, +} from "./body.ts"; +import { createBinding, integerOption, limitNumber, type EventRecord } from "./binding.ts"; +import { NetworkError, errorFromLastError, normalizeErrorCode } from "./errors.ts"; +import { URL } from "./url.ts"; +import type { TlsOptions } from "./types.ts"; + +export type { BodyStream, BodyReadResult, NetworkData } from "./body.ts"; + +const PROTOCOL = "http" as const; + +// --------------------------------------------------------------------------- +// Headers +// --------------------------------------------------------------------------- + +export type HeadersInit = Headers | Record | Iterable; + +type HeadersGuard = "none" | "request" | "response" | "immutable"; + +const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; + +/** Request headers the core owns (framing, connection control, upgrade). An + * app cannot set them; the Fetch request guard is otherwise not applied so + * explicit `Cookie`, `Origin`, `User-Agent` etc. work on every host. */ +const CORE_OWNED_REQUEST_HEADERS = new Set([ + "host", + "connection", + "content-length", + "transfer-encoding", + "trailer", + "te", + "upgrade", + "keep-alive", + "expect", + "proxy-connection", +]); + +function normalizeHeaderValue(value: string): string { + // HTTP whitespace: tab, LF, CR, space. + return String(value).replace(/^[\t\n\r ]+|[\t\n\r ]+$/g, ""); +} + +function invalidHeader(message: string): NetworkError { + return new NetworkError(NET_ERROR.invalidRequest, message, { operation: "headers", protocol: PROTOCOL }); +} + +export class Headers { + private readonly map = new Map(); + private guard: HeadersGuard = "none"; + + constructor(init?: HeadersInit) { + this.fill(init); + } + + /** @internal Append every pair of `init` under the current guard. */ + fill(init: HeadersInit | undefined | null): this { + if (init === undefined || init === null) return this; + if (init instanceof Headers) { + for (const [name, values] of init.map) for (const v of values) this.append(name, v); + return this; + } + if (typeof init === "object" && Symbol.iterator in init) { + for (const pair of init as Iterable) { + if (!pair || typeof pair !== "object" || (pair as readonly string[]).length !== 2) { + throw invalidHeader("header pairs must have exactly two items"); + } + this.append(pair[0], pair[1]); + } + return this; + } + if (typeof init === "object") { + for (const name of Object.keys(init as Record)) { + this.append(name, (init as Record)[name]); + } + return this; + } + throw invalidHeader("unsupported HeadersInit"); + } + + /** @internal */ + __setGuard(guard: HeadersGuard): this { + this.guard = guard; + return this; + } + + /** @internal */ + __guard(): HeadersGuard { + return this.guard; + } + + private checkMutable(): void { + if (this.guard === "immutable") throw new TypeError("Headers are immutable"); + } + + private accept(name: string): boolean { + return this.guard !== "request" || !CORE_OWNED_REQUEST_HEADERS.has(name); + } + + private static validate(rawName: string, rawValue: string): [string, string] { + const name = String(rawName).toLowerCase(); + if (!TOKEN.test(name)) throw invalidHeader(`invalid header name "${rawName}"`); + const value = normalizeHeaderValue(rawValue); + if (/[\0\r\n]/.test(value)) throw invalidHeader(`invalid header value for "${rawName}"`); + return [name, value]; + } + + append(rawName: string, rawValue: string): void { + this.checkMutable(); + const [name, value] = Headers.validate(rawName, rawValue); + if (!this.accept(name)) return; + const list = this.map.get(name); + if (list) list.push(value); + else this.map.set(name, [value]); + } + + set(rawName: string, rawValue: string): void { + this.checkMutable(); + const [name, value] = Headers.validate(rawName, rawValue); + if (!this.accept(name)) return; + this.map.set(name, [value]); + } + + delete(rawName: string): void { + this.checkMutable(); + const [name] = Headers.validate(rawName, ""); + if (!this.accept(name)) return; + this.map.delete(name); + } + + get(rawName: string): string | null { + const [name] = Headers.validate(rawName, ""); + const list = this.map.get(name); + if (!list) return null; + return list.join(", "); + } + + has(rawName: string): boolean { + const [name] = Headers.validate(rawName, ""); + return this.map.has(name); + } + + getSetCookie(): string[] { + return [...(this.map.get("set-cookie") ?? [])]; + } + + private sortedEntries(): [string, string][] { + const names = [...this.map.keys()].sort(); + const out: [string, string][] = []; + for (const name of names) { + const values = this.map.get(name)!; + if (name === "set-cookie") for (const v of values) out.push([name, v]); + else out.push([name, values.join(", ")]); + } + return out; + } + + *entries(): IterableIterator<[string, string]> { + yield* this.sortedEntries(); + } + *keys(): IterableIterator { + for (const [k] of this.sortedEntries()) yield k; + } + *values(): IterableIterator { + for (const [, v] of this.sortedEntries()) yield v; + } + [Symbol.iterator](): IterableIterator<[string, string]> { + return this.entries(); + } + forEach(callback: (value: string, name: string, headers: Headers) => void, thisArg?: unknown): void { + for (const [name, value] of this.sortedEntries()) callback.call(thisArg, value, name, this); + } + + /** @internal Wire form: one value per name (repeats combined), set-cookie + * combined with ", " as well because request meta is a flat object. */ + __toRecord(): Record { + const out: Record = {}; + for (const [name, values] of this.map) out[name] = values.join(", "); + return out; + } + + /** @internal Approximate encoded size for the limits check. */ + __byteSize(): { count: number; bytes: number } { + let count = 0; + let bytes = 0; + for (const [name, values] of this.map) { + count++; + bytes += stringToUtf8(name).length + stringToUtf8(values.join(", ")).length + 4; + } + return { count, bytes }; + } + + /** @internal */ + static __fromRecord(record: Record, guard: HeadersGuard): Headers { + const h = new Headers(); + for (const name of Object.keys(record)) { + const value = record[name]; + if (Array.isArray(value)) { + for (const v of value) h.appendUnchecked(name, String(v)); + } else { + h.appendUnchecked(name, String(value)); + } + } + return h.__setGuard(guard); + } + + private appendUnchecked(name: string, value: string): void { + const key = name.toLowerCase(); + if (!TOKEN.test(key)) return; + const list = this.map.get(key); + if (list) list.push(value); + else this.map.set(key, [value]); + } +} + +// --------------------------------------------------------------------------- +// Request +// --------------------------------------------------------------------------- + +export type RequestRedirect = "follow" | "manual" | "error"; +export type BodyInit = NetworkData | BodyStream | AsyncIterable | null; + +export interface RequestTimeouts { + connectMs?: number; + headersMs?: number; + idleMs?: number; + totalMs?: number; +} + +export interface RequestLimits { + /** Native receive queue (backpressure window) for the response body. */ + queueBytes?: number; + /** Total response body cap; exceeding it fails with response_too_large. */ + maxBodyBytes?: number; + /** Cap for text()/json()/arrayBuffer() on the response. */ + aggregateBytes?: number; +} + +export interface RequestInit { + method?: string; + headers?: HeadersInit; + body?: BodyInit; + signal?: AbortSignal | AbortSignalLike | null; + redirect?: RequestRedirect; + timeouts?: RequestTimeouts; + maxRedirects?: number; + tls?: TlsOptions; + limits?: RequestLimits; +} + +const STANDARD_METHODS = new Set(["DELETE", "GET", "HEAD", "OPTIONS", "POST", "PUT"]); + +function normalizeMethod(raw: unknown): string { + const method = String(raw ?? "GET"); + if (!TOKEN.test(method)) { + throw new NetworkError(NET_ERROR.invalidRequest, `invalid method "${method}"`, { + operation: "fetch", + protocol: PROTOCOL, + }); + } + const upper = method.toUpperCase(); + if ((NET_METHODS_FORBIDDEN as readonly string[]).includes(upper) || upper === "TRACK") { + throw new NetworkError(NET_ERROR.invalidRequest, `method ${upper} is not allowed`, { + operation: "fetch", + protocol: PROTOCOL, + }); + } + return STANDARD_METHODS.has(upper) ? upper : method; +} + +function parseAbsoluteUrl(input: string | URL, operation: string): URL { + try { + const url = input instanceof URL ? new URL(input.href) : new URL(String(input)); + if (url.username || url.password) { + throw new NetworkError(NET_ERROR.invalidRequest, "URL must not carry credentials", { + operation, + protocol: PROTOCOL, + }); + } + return url; + } catch (error) { + if (error instanceof NetworkError) throw error; + throw new NetworkError(NET_ERROR.invalidRequest, `invalid URL: ${String(input)}`, { + operation, + protocol: PROTOCOL, + }); + } +} + +export class Request { + readonly method: string; + readonly url: string; + readonly headers: Headers; + readonly signal: AbortSignal | AbortSignalLike; + readonly redirect: RequestRedirect; + readonly timeouts: Readonly; + readonly maxRedirects: number; + readonly tls: Readonly | undefined; + readonly limits: Readonly; + private _body: BaseBody | AsyncIterable | null; + private streamUsed = false; + + constructor(input: string | URL | Request, init: RequestInit = {}) { + let url: URL; + let method = "GET"; + let headers: Headers | undefined; + let body: BaseBody | AsyncIterable | null = null; + let signal: AbortSignal | AbortSignalLike | undefined; + let redirect: RequestRedirect = "follow"; + let timeouts: RequestTimeouts = {}; + let maxRedirects = NET_MAX_REDIRECTS; + let tls: TlsOptions | undefined; + let limits: RequestLimits = {}; + + if (input instanceof Request) { + url = new URL(input.url); + method = input.method; + headers = new Headers().__setGuard("request").fill(input.headers); + signal = input.signal; + redirect = input.redirect; + timeouts = { ...input.timeouts }; + maxRedirects = input.maxRedirects; + tls = input.tls ? { ...input.tls } : undefined; + limits = { ...input.limits }; + if (init.body === undefined && input._body) { + if (input.bodyUsed) { + throw new NetworkError(NET_ERROR.invalidState, "input request body is already used", { + operation: "Request", + protocol: PROTOCOL, + }); + } + body = input._body; + input.streamUsed = true; + } + } else { + url = parseAbsoluteUrl(input, "Request"); + } + + if (init.method !== undefined) method = normalizeMethod(init.method); + if (init.headers !== undefined) { + // Wire headers delivered by the server core arrive immutable and are + // adopted as-is; anything app-supplied goes through the request guard. + headers = + init.headers instanceof Headers && init.headers.__guard() === "immutable" + ? init.headers + : new Headers().__setGuard("request").fill(init.headers); + } + if (init.signal !== undefined && init.signal !== null) { + if (!isAbortSignalLike(init.signal)) { + throw new NetworkError(NET_ERROR.invalidRequest, "signal must be an AbortSignal", { + operation: "Request", + protocol: PROTOCOL, + }); + } + signal = init.signal; + } + if (init.redirect !== undefined) { + if (init.redirect !== "follow" && init.redirect !== "manual" && init.redirect !== "error") { + throw new NetworkError(NET_ERROR.invalidRequest, "redirect must be follow, manual or error", { + operation: "Request", + protocol: PROTOCOL, + }); + } + redirect = init.redirect; + } + if (init.timeouts !== undefined) { + timeouts = {}; + for (const key of ["connectMs", "headersMs", "idleMs", "totalMs"] as const) { + const v = init.timeouts[key]; + if (v !== undefined) timeouts[key] = integerOption(v, `timeouts.${key}`, 1, NET_MAX_TIMEOUT_MS, "Request", PROTOCOL); + } + } + if (init.maxRedirects !== undefined) { + maxRedirects = integerOption(init.maxRedirects, "maxRedirects", 0, NET_MAX_REDIRECTS, "Request", PROTOCOL); + } + if (init.tls !== undefined) tls = { ...init.tls }; + if (init.limits !== undefined) { + limits = {}; + if (init.limits.queueBytes !== undefined) { + limits.queueBytes = integerOption(init.limits.queueBytes, "limits.queueBytes", 1, NET_MAX_QUEUE_BYTES, "Request", PROTOCOL); + } + if (init.limits.maxBodyBytes !== undefined) { + limits.maxBodyBytes = integerOption(init.limits.maxBodyBytes, "limits.maxBodyBytes", 0, 2 ** 31 - 1, "Request", PROTOCOL); + } + if (init.limits.aggregateBytes !== undefined) { + limits.aggregateBytes = integerOption(init.limits.aggregateBytes, "limits.aggregateBytes", 1, NET_MAX_AGGREGATE_BYTES, "Request", PROTOCOL); + } + } + if (init.body !== undefined) body = bodyFromInput(init.body, "Request", PROTOCOL); + if (body !== null && (method === "GET" || method === "HEAD")) { + throw new NetworkError(NET_ERROR.invalidRequest, `${method} cannot have a body`, { + operation: "Request", + protocol: PROTOCOL, + }); + } + + this.url = url.href; + this.method = method; + this.headers = headers ?? new Headers().__setGuard("request"); + this.signal = signal ?? new AbortSignal(); + this.redirect = redirect; + this.timeouts = Object.freeze(timeouts); + this.maxRedirects = maxRedirects; + this.tls = tls ? Object.freeze(tls) : undefined; + this.limits = Object.freeze(limits); + this._body = body; + } + + get body(): BodyStream | null { + if (this._body === null) return null; + if (this._body instanceof BaseBody) return this._body; + // Async iterables are exposed as-is (they carry no lock state). + return this._body as unknown as BodyStream; + } + + get bodyUsed(): boolean { + if (this._body instanceof BaseBody) return this._body.bodyUsed; + return this.streamUsed; + } + + /** @internal */ + get __bodySource(): BaseBody | AsyncIterable | null { + return this._body; + } + + clone(): Request { + if (this.bodyUsed) { + throw new NetworkError(NET_ERROR.invalidState, "cannot clone a used request", { + operation: "clone", + protocol: PROTOCOL, + }); + } + let bodyForCopy: BodyInit | undefined; + if (this._body instanceof MemoryBody) bodyForCopy = this._body.fork() as unknown as BodyStream; + else if (this._body instanceof BaseBody) { + const [a, b] = teeBody(this._body, PROTOCOL, aggregateLimit(this.limits)); + this._body = a; + bodyForCopy = b; + } else if (this._body !== null) { + throw new NetworkError(NET_ERROR.invalidState, "cannot clone a request with an iterator body", { + operation: "clone", + protocol: PROTOCOL, + }); + } + return new Request(this, bodyForCopy === undefined ? {} : { body: bodyForCopy }); + } + + private aggregate(): BaseBody { + if (this._body instanceof BaseBody) return this._body; + if (this._body === null) return new MemoryBody(new Uint8Array(0), PROTOCOL); + throw new NetworkError(NET_ERROR.invalidState, "iterator bodies cannot be aggregated", { + operation: "arrayBuffer", + protocol: PROTOCOL, + }); + } + + async arrayBuffer(): Promise { + const bytes = await this.aggregate().collect(aggregateLimit(this.limits), "arrayBuffer"); + return bytes.slice().buffer as ArrayBuffer; + } + + async text(): Promise { + return this.aggregate().collectText(aggregateLimit(this.limits), "text"); + } + + async json(): Promise { + return JSON.parse(await this.text()) as T; + } +} + +function aggregateLimit(limits: RequestLimits | undefined): number { + return limits?.aggregateBytes ?? Math.min(NET_DEFAULT_AGGREGATE_BYTES, hostAggregateDefault()); +} + +// --------------------------------------------------------------------------- +// Response +// --------------------------------------------------------------------------- + +export interface ResponseInit { + status?: number; + statusText?: string; + headers?: HeadersInit; +} + +const REASON_PHRASES: Record = { + 100: "Continue", + 101: "Switching Protocols", + 200: "OK", + 201: "Created", + 202: "Accepted", + 204: "No Content", + 206: "Partial Content", + 301: "Moved Permanently", + 302: "Found", + 303: "See Other", + 304: "Not Modified", + 307: "Temporary Redirect", + 308: "Permanent Redirect", + 400: "Bad Request", + 401: "Unauthorized", + 403: "Forbidden", + 404: "Not Found", + 405: "Method Not Allowed", + 408: "Request Timeout", + 409: "Conflict", + 413: "Content Too Large", + 414: "URI Too Long", + 415: "Unsupported Media Type", + 429: "Too Many Requests", + 431: "Request Header Fields Too Large", + 500: "Internal Server Error", + 501: "Not Implemented", + 502: "Bad Gateway", + 503: "Service Unavailable", + 504: "Gateway Timeout", +}; + +const NULL_BODY_STATUS = new Set([101, 103, 204, 205, 304]); + +interface ResponseInternal { + url: string; + redirected: boolean; + aggregateBytes: number; +} + +export class Response { + readonly status: number; + readonly statusText: string; + readonly headers: Headers; + readonly url: string; + readonly redirected: boolean; + private _body: BaseBody | AsyncIterable | null; + private readonly aggregateBytes: number; + private streamUsed = false; + + constructor(body: BodyInit = null, init: ResponseInit = {}, internal?: ResponseInternal) { + const status = init.status ?? 200; + if (!Number.isInteger(status) || status < 200 || status > 599) { + // The constructor is the app-facing one; network responses use the + // internal path which accepts the full 1xx-5xx range. + if (!internal || !Number.isInteger(status) || status < 100 || status > 599) { + throw new NetworkError(NET_ERROR.invalidRequest, "status must be an integer from 200 through 599", { + operation: "Response", + protocol: PROTOCOL, + }); + } + } + const statusText = init.statusText === undefined ? "" : String(init.statusText); + if (/[\r\n\0]/.test(statusText)) { + throw new NetworkError(NET_ERROR.invalidRequest, "invalid statusText", { + operation: "Response", + protocol: PROTOCOL, + }); + } + this.status = status; + this.statusText = statusText; + this.headers = init.headers instanceof Headers && internal ? init.headers : new Headers(init.headers); + this.headers.__setGuard(internal ? "immutable" : "response"); + this.url = internal?.url ?? ""; + this.redirected = internal?.redirected ?? false; + this.aggregateBytes = internal?.aggregateBytes ?? aggregateLimit(undefined); + let source = bodyFromInput(body, "Response", PROTOCOL); + if (source !== null && NULL_BODY_STATUS.has(status)) { + throw new NetworkError(NET_ERROR.invalidRequest, `status ${status} cannot have a body`, { + operation: "Response", + protocol: PROTOCOL, + }); + } + if (source instanceof MemoryBody && !internal && typeof body === "string" && !this.headers.has("content-type")) { + this.headers.set("content-type", "text/plain;charset=UTF-8"); + } + if (source === null && !internal) source = null; + this._body = source; + } + + get ok(): boolean { + return this.status >= 200 && this.status <= 299; + } + + get body(): BodyStream | null { + if (this._body === null) return null; + if (this._body instanceof BaseBody) return this._body; + return this._body as unknown as BodyStream; + } + + get bodyUsed(): boolean { + if (this._body instanceof BaseBody) return this._body.bodyUsed; + return this.streamUsed; + } + + /** @internal */ + get __bodySource(): BaseBody | AsyncIterable | null { + return this._body; + } + + /** @internal */ + __markStreamUsed(): void { + this.streamUsed = true; + } + + clone(): Response { + if (this.bodyUsed) { + throw new NetworkError(NET_ERROR.invalidState, "cannot clone a used response", { + operation: "clone", + protocol: PROTOCOL, + }); + } + let bodyForCopy: BodyInit = null; + if (this._body instanceof MemoryBody) bodyForCopy = this._body.fork() as unknown as BodyStream; + else if (this._body instanceof BaseBody) { + const [a, b] = teeBody(this._body, PROTOCOL, this.aggregateBytes); + this._body = a; + bodyForCopy = b; + } else if (this._body !== null) { + throw new NetworkError(NET_ERROR.invalidState, "cannot clone a response with an iterator body", { + operation: "clone", + protocol: PROTOCOL, + }); + } + return new Response(bodyForCopy, { status: this.status, statusText: this.statusText, headers: new Headers(this.headers) }, { + url: this.url, + redirected: this.redirected, + aggregateBytes: this.aggregateBytes, + }); + } + + private aggregate(): BaseBody { + if (this._body instanceof BaseBody) return this._body; + if (this._body === null) return new MemoryBody(new Uint8Array(0), PROTOCOL); + throw new NetworkError(NET_ERROR.invalidState, "iterator bodies cannot be aggregated", { + operation: "arrayBuffer", + protocol: PROTOCOL, + }); + } + + async arrayBuffer(): Promise { + const bytes = await this.aggregate().collect(this.aggregateBytes, "arrayBuffer"); + return bytes.slice().buffer as ArrayBuffer; + } + + async text(): Promise { + return this.aggregate().collectText(this.aggregateBytes, "text"); + } + + async json(): Promise { + return JSON.parse(await this.text()) as T; + } + + static json(data: unknown, init: ResponseInit = {}): Response { + const headers = new Headers(init.headers); + if (!headers.has("content-type")) headers.set("content-type", "application/json"); + return new Response(JSON.stringify(data), { ...init, headers }); + } + + static redirect(url: string | URL, status = 302): Response { + if (![301, 302, 303, 307, 308].includes(status)) { + throw new NetworkError(NET_ERROR.invalidRequest, "redirect status must be 301, 302, 303, 307 or 308", { + operation: "Response.redirect", + protocol: PROTOCOL, + }); + } + const target = url instanceof URL ? url.href : String(url); + return new Response(null, { status, headers: { location: target } }); + } +} + +// --------------------------------------------------------------------------- +// HTTP Client binding (`globalThis.net`) +// --------------------------------------------------------------------------- + +export interface NetOps { + start(metaJson: string, body: ArrayBuffer | null): number; + cancel(handle: number): void; + poll(): string | undefined; + lastError(): string; + readInto(handle: number, into: ArrayBuffer, offset: number, length: number): number; + limits(): string; +} + +interface PendingFetch { + request: Request; + resolve: (response: Response) => void; + reject: (error: NetworkError) => void; + body: NativeBody | null; + settled: boolean; + aggregateBytes: number; + abortListener: (() => void) | null; +} + +const pendingFetches = new Map(); + +const net = createBinding({ + name: "net", + protocol: PROTOCOL, + specMajor: NET_SPEC_MAJOR, + requiredOps: ["start", "cancel", "poll", "lastError", "readInto", "limits"], + dispatch: dispatchNetEvent, + onProtocolFailure(ops, error) { + for (const [handle, p] of [...pendingFetches]) { + ops.cancel(handle); + failFetch(handle, p, error); + } + }, +}); + +function hostAggregateDefault(): number { + const ops = net.ops(); + if (!ops) return NET_DEFAULT_AGGREGATE_BYTES; + try { + return limitNumber(net.limits(), "defaultAggregateBytes", NET_DEFAULT_AGGREGATE_BYTES); + } catch { + return NET_DEFAULT_AGGREGATE_BYTES; + } +} + +function retireFetch(handle: number, p: PendingFetch): void { + pendingFetches.delete(handle); + if (p.abortListener) { + p.request.signal.removeEventListener?.("abort", p.abortListener); + p.abortListener = null; + } + net.release(); +} + +function failFetch(handle: number, p: PendingFetch, error: NetworkError): void { + retireFetch(handle, p); + if (!p.settled) { + p.settled = true; + p.reject(error); + } + if (p.body) p.body.onError(error); +} + +function dispatchNetEvent(event: EventRecord, ops: NetOps): void { + const handle = event.h; + if (typeof handle !== "number") return; + const p = pendingFetches.get(handle); + if (!p) return; + switch (event.t) { + case "headers": { + if (p.settled) return; + const status = event.status; + const url = typeof event.url === "string" ? event.url : p.request.url; + const headers = event.headers && typeof event.headers === "object" ? (event.headers as Record) : {}; + if (typeof status !== "number" || !Number.isInteger(status) || status < 100 || status > 599) { + ops.cancel(handle); + failFetch(handle, p, new NetworkError(NET_ERROR.protocol, "malformed headers event", { operation: "fetch", protocol: PROTOCOL })); + return; + } + const length = typeof event.length === "number" && event.length >= 0 ? event.length : -1; + const nullBody = p.request.method === "HEAD" || NULL_BODY_STATUS.has(status); + const body = nullBody + ? null + : new NativeBody( + { + pull: (dest) => ops.readInto(handle, dest.buffer as ArrayBuffer, dest.byteOffset, dest.byteLength), + cancel: () => ops.cancel(handle), + }, + PROTOCOL, + length, + ); + p.body = body; + p.settled = true; + const response = new Response(body as unknown as BodyStream, { + status, + statusText: "", + headers: Headers.__fromRecord(headers, "immutable"), + }, { + url, + redirected: event.redirected === true, + aggregateBytes: p.aggregateBytes, + }); + p.resolve(response); + return; + } + case "readable": + p.body?.onReadable(typeof event.avail === "number" ? event.avail : 0); + return; + case "end": + retireFetch(handle, p); + p.body?.onEnd(); + return; + case "error": { + const error = new NetworkError( + normalizeErrorCode(event.code), + typeof event.message === "string" && event.message ? event.message : String(event.code), + { + operation: "fetch", + protocol: PROTOCOL, + causeCode: typeof event.causeCode === "string" ? event.causeCode : undefined, + }, + ); + failFetch(handle, p, error); + return; + } + default: + return; + } +} + +function fetchLimits(): Record { + return net.limits(); +} + +/** The PocketJS HTTP client. */ +export function fetch(input: string | URL | Request, init?: RequestInit): Promise { + let request: Request; + let ops: NetOps; + let handle: number; + let bodyBuffer: ArrayBuffer | null = null; + try { + request = input instanceof Request && init === undefined ? input : new Request(input, init); + ops = net.require("fetch"); + const limits = fetchLimits(); + const url = new URL(request.url); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new NetworkError(NET_ERROR.invalidRequest, "url must be http: or https:", { operation: "fetch", protocol: PROTOCOL }); + } + const features = Array.isArray(limits.features) ? (limits.features as unknown[]) : []; + if (url.protocol === "https:" && !features.includes("tls")) { + throw new NetworkError(NET_ERROR.unsupported, "this host does not provide network.http.client.tls", { + operation: "fetch", + protocol: PROTOCOL, + }); + } + if (request.signal.aborted) { + throw new NetworkError(NET_ERROR.cancelled, "request was aborted", { operation: "fetch", protocol: PROTOCOL }); + } + const source = request.__bodySource; + if (source instanceof MemoryBody) { + if (source.bodyUsed) { + throw new NetworkError(NET_ERROR.invalidState, "request body is already used", { operation: "fetch", protocol: PROTOCOL }); + } + const bytes = source.peek(); + const maxRequest = limitNumber(limits, "maxRequestBytes", NET_MAX_REQUEST_BYTES); + if (bytes.length > maxRequest) { + throw new NetworkError(NET_ERROR.resourceLimit, `request body exceeds ${maxRequest} bytes`, { + operation: "fetch", + protocol: PROTOCOL, + }); + } + bodyBuffer = bytes.slice().buffer as ArrayBuffer; + void source.cancel(); // consumed by this fetch + } else if (source !== null) { + throw new NetworkError(NET_ERROR.unsupported, "streaming request bodies are not supported by this host yet", { + operation: "fetch", + protocol: PROTOCOL, + }); + } + const size = request.headers.__byteSize(); + if (size.count > limitNumber(limits, "maxHeaders", NET_MAX_HEADERS) || size.bytes > limitNumber(limits, "maxHeaderBytes", NET_MAX_HEADER_BYTES)) { + throw new NetworkError(NET_ERROR.resourceLimit, "request headers exceed the host limits", { operation: "fetch", protocol: PROTOCOL }); + } + if (request.tls) { + const v = request.tls.verification; + if (v !== undefined && v !== "full" && v !== "development-insecure") { + throw new NetworkError(NET_ERROR.invalidRequest, "tls.verification must be full or development-insecure", { + operation: "fetch", + protocol: PROTOCOL, + }); + } + for (const key of ["ca", "credential", "alpn", "minVersion", "maxVersion", "clientCertificate", "revocation", "serverName"] as const) { + if (request.tls[key] !== undefined) { + throw new NetworkError(NET_ERROR.unsupported, `tls.${key} is not supported by this host`, { operation: "fetch", protocol: PROTOCOL }); + } + } + } + const meta: NetStartMeta = { + url: request.url, + method: request.method, + headers: request.headers.__toRecord(), + queueBytes: request.limits.queueBytes ?? limitNumber(limits, "defaultQueueBytes", NET_DEFAULT_QUEUE_BYTES), + redirect: request.redirect, + maxRedirects: Math.min(request.maxRedirects, limitNumber(limits, "maxRedirects", NET_MAX_REDIRECTS)), + timeouts: { + connectMs: request.timeouts.connectMs ?? limitNumber(limits, "defaultTimeoutMs", NET_DEFAULT_TIMEOUT_MS), + headersMs: request.timeouts.headersMs ?? limitNumber(limits, "defaultTimeoutMs", NET_DEFAULT_TIMEOUT_MS), + idleMs: request.timeouts.idleMs ?? limitNumber(limits, "defaultTimeoutMs", NET_DEFAULT_TIMEOUT_MS), + totalMs: request.timeouts.totalMs ?? limitNumber(limits, "maxTimeoutMs", NET_MAX_TIMEOUT_MS), + }, + }; + if (request.limits.maxBodyBytes !== undefined) meta.maxBodyBytes = request.limits.maxBodyBytes; + if (request.tls?.verification !== undefined) meta.tls = { verification: request.tls.verification }; + handle = ops.start(JSON.stringify(meta), bodyBuffer); + if (!Number.isInteger(handle) || handle < 0) { + throw errorFromLastError(ops.lastError(), "fetch", PROTOCOL); + } + } catch (error) { + return Promise.reject( + error instanceof NetworkError + ? error + : new NetworkError(NET_ERROR.invalidRequest, String(error), { operation: "fetch", protocol: PROTOCOL }), + ); + } + return new Promise((resolve, reject) => { + const aggregateBytes = request.limits.aggregateBytes ?? Math.min(NET_DEFAULT_AGGREGATE_BYTES, hostAggregateDefault()); + const pending: PendingFetch = { request, resolve, reject, body: null, settled: false, aggregateBytes, abortListener: null }; + pendingFetches.set(handle, pending); + net.retain(); + const onAbort = (): void => { + // The terminal error{cancelled} settles the Promise at the next tick. + ops.cancel(handle); + }; + pending.abortListener = onAbort; + request.signal.addEventListener("abort", onAbort); + }); +} + +// --------------------------------------------------------------------------- +// HTTP Server binding (`globalThis.httpd`) +// --------------------------------------------------------------------------- + +export interface HttpdOps { + listen(metaJson: string): number; + stop(handle: number, graceful: boolean, timeoutMs: number): number; + respond(req: number, metaJson: string, body: ArrayBuffer | null): number; + write(req: number, chunk: ArrayBuffer): number; + endBody(req: number): number; + readInto(req: number, into: ArrayBuffer, offset: number, length: number): number; + abort(req: number): void; + poll(): string | undefined; + lastError(): string; + limits(): string; +} + +export interface HttpServeLimits { + maxConnections?: number; + maxInflight?: number; + maxHeaderBytes?: number; + maxBodyBytes?: number; + requestQueueBytes?: number; + sendQueueBytes?: number; +} + +export interface HttpServeTimeouts { + headerMs?: number; + bodyIdleMs?: number; + handlerMs?: number; + keepAliveMs?: number; + closeMs?: number; +} + +export interface HttpServer { + readonly hostname: string; + readonly port: number; + readonly url: string; + stop(options?: { graceful?: boolean; timeout?: number }): Promise; +} + +export interface HttpServeOptions { + hostname: string; + port: number; + backlog?: number; + tls?: { credential: string }; + limits?: HttpServeLimits; + timeouts?: HttpServeTimeouts; + fetch(request: Request, server: HttpServer): Response | Promise; + error?(error: unknown): Response | Promise | void; +} + +interface ServerState { + handle: number; + options: HttpServeOptions; + server: HttpServerImpl; + resolveListen: ((server: HttpServer) => void) | null; + rejectListen: ((error: NetworkError) => void) | null; + stopWaiters: { resolve: () => void; reject: (e: NetworkError) => void }[]; + secure: boolean; +} + +interface ServerRequestState { + server: ServerState; + req: number; + body: NativeBody | null; + controller: AbortController; + responded: boolean; + terminal: boolean; + drainWaiter: (() => void) | null; +} + +const servers = new Map(); +const serverRequests = new Map(); + +const httpd = createBinding({ + name: "httpd", + protocol: PROTOCOL, + specMajor: HTTPD_SPEC_MAJOR, + requiredOps: ["listen", "stop", "respond", "write", "endBody", "readInto", "abort", "poll", "lastError", "limits"], + dispatch: dispatchHttpdEvent, + onProtocolFailure(ops, error) { + for (const [req, r] of [...serverRequests]) { + ops.abort(req); + finishServerRequest(r, error); + } + for (const [handle, s] of [...servers]) { + ops.stop(handle, false, 0); + failServer(s, error); + } + }, +}); + +class HttpServerImpl implements HttpServer { + hostname = ""; + port = 0; + private readonly state: () => ServerState; + + constructor(state: () => ServerState) { + this.state = state; + } + + get url(): string { + const host = this.hostname.includes(":") ? `[${this.hostname}]` : this.hostname; + return `${this.state().secure ? "https" : "http"}://${host}:${this.port}/`; + } + + stop(options: { graceful?: boolean; timeout?: number } = {}): Promise { + const s = this.state(); + const ops = httpd.ops(); + if (!ops || !servers.has(s.handle)) return Promise.resolve(); + const graceful = options.graceful ?? true; + const timeout = options.timeout ?? 0; + const rc = ops.stop(s.handle, graceful, timeout); + if (rc < 0) return Promise.resolve(); + return new Promise((resolve, reject) => { + s.stopWaiters.push({ resolve, reject }); + }); + } +} + +function failServer(s: ServerState, error: NetworkError): void { + if (!servers.has(s.handle)) return; + servers.delete(s.handle); + httpd.release(); + if (s.rejectListen) { + const reject = s.rejectListen; + s.rejectListen = null; + s.resolveListen = null; + reject(error); + } + for (const w of s.stopWaiters.splice(0)) w.reject(error); +} + +function closeServer(s: ServerState): void { + if (!servers.has(s.handle)) return; + servers.delete(s.handle); + httpd.release(); + for (const w of s.stopWaiters.splice(0)) w.resolve(); +} + +function finishServerRequest(r: ServerRequestState, error: NetworkError | null): void { + if (r.terminal) return; + r.terminal = true; + serverRequests.delete(r.req); + if (error) { + r.body?.onError(error); + r.controller.abort(error); + } else { + r.body?.onEnd(); + } + const w = r.drainWaiter; + r.drainWaiter = null; + if (w) w(); +} + +function dispatchHttpdEvent(event: EventRecord, ops: HttpdOps): void { + if (typeof event.req === "number" && event.t !== "request") { + const r = serverRequests.get(event.req); + if (!r) return; + switch (event.t) { + case "readable": + r.body?.onReadable(typeof event.avail === "number" ? event.avail : 0); + return; + case "end": + r.body?.onEnd(); + return; + case "drain": { + const w = r.drainWaiter; + r.drainWaiter = null; + if (w) w(); + return; + } + case "aborted": { + const code = normalizeErrorCode(event.code); + finishServerRequest( + r, + new NetworkError(code, `request ${code}`, { operation: "serve", protocol: PROTOCOL }), + ); + return; + } + default: + return; + } + } + const handle = event.h; + if (typeof handle !== "number") return; + const s = servers.get(handle); + if (!s) return; + switch (event.t) { + case "listening": { + s.server.hostname = typeof event.address === "string" ? event.address : s.options.hostname; + s.server.port = typeof event.port === "number" ? event.port : s.options.port; + const resolve = s.resolveListen; + s.resolveListen = null; + s.rejectListen = null; + if (resolve) resolve(s.server); + return; + } + case "closed": + closeServer(s); + return; + case "error": { + const error = new NetworkError( + normalizeErrorCode(event.code), + typeof event.message === "string" && event.message ? event.message : String(event.code), + { operation: "serve", protocol: PROTOCOL, causeCode: typeof event.causeCode === "string" ? event.causeCode : undefined }, + ); + if (s.rejectListen) failServer(s, error); + // After listening, `closed` follows and resolves stop waiters; the + // error itself has no app-visible surface beyond stop() rejecting. + else { + for (const w of s.stopWaiters.splice(0)) w.reject(error); + } + return; + } + case "request": + deliverRequest(s, event, ops); + return; + default: + return; + } +} + +function deliverRequest(s: ServerState, event: EventRecord, ops: HttpdOps): void { + const req = event.req; + if (typeof req !== "number") return; + const method = typeof event.method === "string" ? event.method : "GET"; + const target = typeof event.target === "string" ? event.target : "/"; + const headerRecord = event.headers && typeof event.headers === "object" ? (event.headers as Record) : {}; + const headers = Headers.__fromRecord(headerRecord, "immutable"); + const length = typeof event.length === "number" && event.length >= 0 ? event.length : -1; + const hasBody = length > 0 || (length < 0 && /chunked/i.test(headers.get("transfer-encoding") ?? "")); + const secure = event.secure === true; + const hostHeader = headers.get("host"); + const authority = hostHeader && /^[A-Za-z0-9.\-:[\]_%]+$/.test(hostHeader) ? hostHeader : `${s.server.hostname}:${s.server.port}`; + let urlText = `${secure ? "https" : "http"}://${authority}${target.startsWith("/") ? target : "/" + target}`; + if (!URL.canParse(urlText)) urlText = `${secure ? "https" : "http"}://${s.server.hostname}:${s.server.port}/`; + + const controller = new AbortController(); + const state: ServerRequestState = { + server: s, + req, + body: null, + controller, + responded: false, + terminal: false, + drainWaiter: null, + }; + const body = hasBody + ? new NativeBody( + { + pull: (dest) => ops.readInto(req, dest.buffer as ArrayBuffer, dest.byteOffset, dest.byteLength), + cancel: () => { + // Cancelling the request body does not abort the exchange; the + // core drains or closes after the response completes. + }, + }, + PROTOCOL, + length, + ) + : null; + state.body = body; + serverRequests.set(req, state); + + const request = new Request(urlText, { + method, + headers, // wire headers: immutable, adopted as-is + body: body as unknown as BodyStream, + signal: controller.signal, + }); + + let result: Response | Promise; + try { + result = s.options.fetch(request, s.server); + } catch (error) { + void handleHandlerFailure(state, ops, error); + return; + } + if (result instanceof Response) { + void sendResponse(state, ops, result); + } else if (result && typeof (result as Promise).then === "function") { + (result as Promise).then( + (response) => void sendResponse(state, ops, response), + (error) => void handleHandlerFailure(state, ops, error), + ); + } else { + void handleHandlerFailure(state, ops, new TypeError("handler must return a Response")); + } +} + +async function handleHandlerFailure(state: ServerRequestState, ops: HttpdOps, error: unknown): Promise { + if (state.terminal || state.responded) { + if (!state.terminal && state.responded) ops.abort(state.req); + return; + } + const errorHandler = state.server.options.error; + if (errorHandler) { + try { + const produced = await errorHandler(error); + if (produced instanceof Response) { + await sendResponse(state, ops, produced); + return; + } + } catch { + // fall through to the fixed 500 + } + } + await sendResponse(state, ops, new Response(null, { status: 500 })); +} + +function respondMeta(response: Response, end: boolean, contentLength?: number): HttpdRespondMeta { + const meta: HttpdRespondMeta = { + status: response.status, + statusText: response.statusText, + headers: response.headers.__toRecord(), + end, + }; + if (contentLength !== undefined) meta.contentLength = contentLength; + return meta; +} + +function waitDrain(state: ServerRequestState): Promise { + return new Promise((resolve) => { + state.drainWaiter = resolve; + }); +} + +async function sendResponse(state: ServerRequestState, ops: HttpdOps, response: Response): Promise { + if (state.terminal || state.responded) return; + state.responded = true; + const source = response.__bodySource; + try { + if (source === null || source instanceof MemoryBody) { + const bytes = source ? source.peek() : new Uint8Array(0); + const rc = ops.respond(state.req, JSON.stringify(respondMeta(response, true)), bytes.length ? (bytes.slice().buffer as ArrayBuffer) : null); + if (rc === HTTPD_SEND_ACCEPTED) { + if (source) void source.cancel(); + finishServerRequest(state, null); + return; + } + if (rc === HTTPD_SEND_BACKPRESSURE) { + // Too large for one send: stream it with a known length. + const rc2 = ops.respond(state.req, JSON.stringify(respondMeta(response, false, bytes.length)), null); + if (rc2 !== HTTPD_SEND_ACCEPTED) { + finishServerRequest(state, sendError(rc2)); + return; + } + await writeAll(state, ops, bytes); + if (source) void source.cancel(); + if (!state.terminal) { + ops.endBody(state.req); + finishServerRequest(state, null); + } + return; + } + finishServerRequest(state, sendError(rc)); + return; + } + // Streaming body (BodyStream or AsyncIterable). + const known = source instanceof BaseBody ? source.knownLength() : -1; + const rc = ops.respond(state.req, JSON.stringify(respondMeta(response, false, known >= 0 ? known : undefined)), null); + if (rc !== HTTPD_SEND_ACCEPTED) { + finishServerRequest(state, sendError(rc)); + if (source instanceof BaseBody) void source.cancel(); + return; + } + response.__markStreamUsed(); + const iterable = source as AsyncIterable; + const iterator = iterable[Symbol.asyncIterator](); + try { + for (;;) { + const { value, done } = await iterator.next(); + if (done) break; + if (state.terminal) break; + if (!(value instanceof Uint8Array)) throw new TypeError("body chunks must be Uint8Array"); + await writeAll(state, ops, value); + } + } finally { + if (state.terminal) await iterator.return?.(); + } + if (!state.terminal) { + ops.endBody(state.req); + finishServerRequest(state, null); + } + } catch (error) { + if (!state.terminal) { + ops.abort(state.req); + finishServerRequest( + state, + error instanceof NetworkError ? error : new NetworkError(NET_ERROR.other, String(error), { operation: "serve", protocol: PROTOCOL }), + ); + } + } +} + +function sendError(rc: number): NetworkError { + const code = rc === HTTPD_SEND_INVALID_REQUEST ? NET_ERROR.closed : rc === HTTPD_SEND_INVALID ? NET_ERROR.invalidRequest : NET_ERROR.other; + return new NetworkError(code, `respond failed (${rc})`, { operation: "serve", protocol: PROTOCOL }); +} + +async function writeAll(state: ServerRequestState, ops: HttpdOps, bytes: Uint8Array): Promise { + const listenQueue = state.server.options.limits?.sendQueueBytes; + let chunkMax = Math.max(1, Math.min(16 * 1024, limitNumber(httpd.limits(), "sendLowWaterBytes", 16 * 1024), listenQueue ?? Infinity)); + let offset = 0; + let refusedAt = -1; + while (offset < bytes.length) { + if (state.terminal) return; + const end = Math.min(bytes.length, offset + chunkMax); + const chunk = bytes.slice(offset, end).buffer as ArrayBuffer; + const rc = ops.write(state.req, chunk); + if (rc === HTTPD_SEND_ACCEPTED) { + offset = end; + refusedAt = -1; + continue; + } + if (rc === HTTPD_SEND_BACKPRESSURE) { + // Wait for the queue to drain; a chunk refused twice in a row is + // larger than the free window, so shrink it before retrying. + if (refusedAt === offset && chunkMax > 1) chunkMax = Math.max(1, chunkMax >> 2); + refusedAt = offset; + await waitDrain(state); + continue; + } + throw sendError(rc); + } +} + +/** Start an HTTP server. Resolves once the listener is bound; rejects on any + * bind, permission or TLS credential failure. */ +export function serve(options: HttpServeOptions): Promise { + let ops: HttpdOps; + let handle: number; + const state: ServerState = { + handle: -1, + options, + server: null as unknown as HttpServerImpl, + resolveListen: null, + rejectListen: null, + stopWaiters: [], + secure: options.tls !== undefined, + }; + state.server = new HttpServerImpl(() => state); + try { + if (typeof options.fetch !== "function") { + throw new NetworkError(NET_ERROR.invalidRequest, "serve() requires a fetch handler", { operation: "serve", protocol: PROTOCOL }); + } + ops = httpd.require("serve"); + const limits = httpd.limits(); + const meta: HttpdListenMeta = { + address: String(options.hostname), + port: integerOption(options.port, "port", 0, 65535, "serve", PROTOCOL), + }; + if (options.backlog !== undefined) meta.backlog = integerOption(options.backlog, "backlog", 1, HTTPD_MAX_BACKLOG, "serve", PROTOCOL); + if (options.tls !== undefined) { + const features = Array.isArray(limits.features) ? (limits.features as unknown[]) : []; + if (!features.includes("tls")) { + throw new NetworkError(NET_ERROR.unsupported, "this host does not provide network.http.server.tls", { operation: "serve", protocol: PROTOCOL }); + } + if (typeof options.tls.credential !== "string" || !options.tls.credential) { + throw new NetworkError(NET_ERROR.invalidRequest, "tls.credential must name a host credential", { operation: "serve", protocol: PROTOCOL }); + } + meta.tls = { credential: options.tls.credential }; + } + if (options.limits) { + meta.limits = {}; + const l = options.limits; + if (l.maxConnections !== undefined) meta.limits.maxConnections = integerOption(l.maxConnections, "limits.maxConnections", 1, HTTPD_MAX_CONNECTIONS, "serve", PROTOCOL); + if (l.maxInflight !== undefined) meta.limits.maxInflight = integerOption(l.maxInflight, "limits.maxInflight", 1, HTTPD_MAX_INFLIGHT, "serve", PROTOCOL); + if (l.maxHeaderBytes !== undefined) meta.limits.maxHeaderBytes = integerOption(l.maxHeaderBytes, "limits.maxHeaderBytes", 1, 2 ** 31 - 1, "serve", PROTOCOL); + if (l.maxBodyBytes !== undefined) meta.limits.maxBodyBytes = integerOption(l.maxBodyBytes, "limits.maxBodyBytes", 0, 2 ** 31 - 1, "serve", PROTOCOL); + if (l.requestQueueBytes !== undefined) meta.limits.requestQueueBytes = integerOption(l.requestQueueBytes, "limits.requestQueueBytes", 1, HTTPD_MAX_REQUEST_QUEUE_BYTES, "serve", PROTOCOL); + if (l.sendQueueBytes !== undefined) meta.limits.sendQueueBytes = integerOption(l.sendQueueBytes, "limits.sendQueueBytes", 1, HTTPD_MAX_SEND_QUEUE_BYTES, "serve", PROTOCOL); + } + if (options.timeouts) { + meta.timeouts = {}; + for (const key of ["headerMs", "bodyIdleMs", "handlerMs", "keepAliveMs", "closeMs"] as const) { + const v = options.timeouts[key]; + if (v !== undefined) meta.timeouts[key] = integerOption(v, `timeouts.${key}`, 1, HTTPD_MAX_TIMEOUT_MS, "serve", PROTOCOL); + } + } + handle = ops.listen(JSON.stringify(meta)); + if (!Number.isInteger(handle) || handle < 0) throw errorFromLastError(ops.lastError(), "serve", PROTOCOL); + } catch (error) { + return Promise.reject( + error instanceof NetworkError ? error : new NetworkError(NET_ERROR.invalidRequest, String(error), { operation: "serve", protocol: PROTOCOL }), + ); + } + state.handle = handle; + return new Promise((resolve, reject) => { + state.resolveListen = resolve; + state.rejectListen = reject; + servers.set(handle, state); + httpd.retain(); + }); +} + +/** @internal test hooks */ +export const __http = { net, httpd, pendingFetches, servers, serverRequests }; diff --git a/framework/src/net/index.ts b/framework/src/net/index.ts new file mode 100644 index 00000000..5f1b7f82 --- /dev/null +++ b/framework/src/net/index.ts @@ -0,0 +1,42 @@ +// @pocketjs/framework/net — the network support module. It provides the +// public types, `AbortController`/`AbortSignal`, `URL`, the `NetworkError` +// class (usable with `instanceof`) and the read-only `getNetworkLimits()` +// snapshot. Importing it assembles no I/O capability; the protocol modules +// live at `@pocketjs/framework/net/http` and `@pocketjs/framework/net/websocket` +// and share these object identities. See docs/NET.md. + +import { HTTPD_SPEC_MAJOR, type HttpdLimits } from "../../../contracts/spec/httpd.ts"; +import { NET_SPEC_MAJOR, type NetLimits } from "../../../contracts/spec/net.ts"; +import { WS_SPEC_MAJOR, type WsLimits } from "../../../contracts/spec/ws.ts"; +import type { NetworkLimits } from "./types.ts"; + +export { AbortController, AbortSignal, AbortError } from "./abort.ts"; +export { NetworkError } from "./errors.ts"; +export type { NetworkErrorCategory, NetworkProtocol } from "./errors.ts"; +export { URL } from "./url.ts"; +export type { BodyStream, BodyReadResult } from "./body.ts"; +export type { NetworkAddress, NetworkData, NetworkLimits, TlsOptions } from "./types.ts"; + +/** Read one namespace's `limits()` without touching the protocol modules. */ +function readLimits(name: string, specMajor: number): Readonly | null { + const ns = (globalThis as Record)[name]; + if (!ns || typeof ns !== "object" || typeof (ns as { limits?: unknown }).limits !== "function") return null; + try { + const parsed = JSON.parse((ns as { limits(): string }).limits()) as Record; + if (!parsed || typeof parsed !== "object" || parsed.specMajor !== specMajor) return null; + return Object.freeze({ ...parsed }) as Readonly; + } catch { + return null; + } +} + +/** A frozen snapshot of the mounted modules' effective limits and features. + * This is a capability/profile query — it never negotiates anything. Modules + * the host did not mount (or mounted at another spec major) read as null. */ +export function getNetworkLimits(): NetworkLimits { + return Object.freeze({ + httpClient: readLimits("net", NET_SPEC_MAJOR), + httpServer: readLimits("httpd", HTTPD_SPEC_MAJOR), + websocketClient: readLimits("ws", WS_SPEC_MAJOR), + }); +} diff --git a/framework/src/net/types.ts b/framework/src/net/types.ts new file mode 100644 index 00000000..76db03c4 --- /dev/null +++ b/framework/src/net/types.ts @@ -0,0 +1,36 @@ +// Public support types shared by every network module. +// Values cross the boundary as +// JSON; the types keep one object identity across `@pocketjs/framework/net` +// and its protocol subpaths. + +import type { HttpdLimits } from "../../../contracts/spec/httpd.ts"; +import type { NetLimits } from "../../../contracts/spec/net.ts"; +import type { WsLimits } from "../../../contracts/spec/ws.ts"; + +export type NetworkData = string | ArrayBuffer | ArrayBufferView; + +export type NetworkAddress = { + family: "ipv4" | "ipv6"; + address: string; + port: number; +}; + +export type TlsOptions = { + serverName?: string; + minVersion?: "1.2" | "1.3"; + maxVersion?: "1.2" | "1.3"; + alpn?: readonly string[]; + ca?: Uint8Array; + credential?: string; + clientCertificate?: "none" | "optional" | "required"; + verification?: "full" | "development-insecure"; + revocation?: "host-default" | "required"; +}; + +/** The frozen `getNetworkLimits()` snapshot: one entry per mounted module, + * null where the host did not mount the namespace. */ +export type NetworkLimits = Readonly<{ + httpClient: Readonly | null; + httpServer: Readonly | null; + websocketClient: Readonly | null; +}>; diff --git a/framework/src/net/url.ts b/framework/src/net/url.ts new file mode 100644 index 0000000000000000000000000000000000000000..ecbe0bb4b370552333d11537b2ae063e7d9ddff2 GIT binary patch literal 9954 zcmbVS{Z`vX7T>>liiy&saxgZKk5Y$_dQ;M*C!5efPP0pB5b_wSu_afMfh^(dKEyuZ zKFR*>+w0f%nR3ASeL-0!csR<~>3y?tedS#HXaHK{F6v;32pWP{n*79I1;EQvq; zylX~LVbaX}`l7%0>rZs#v4dwgQ;d>nVWOpO*BoWDbYKT&8s&w}E5t=SvJ+c)hYC|n zZS={6qq3Z~4gD?J=G6T!TIMtvww{dA+WijBhwAC?hAVO|b0ZuOl z+RUcWA2Ulx4tcK!)a9W$iN-T1s>sY?YEMbkVK9l3ahdhbg4UrKWY|r!5}L`gQv=-A z*@7^s64ZX+_IKh29865)K9k&Jr>U@GV3qh&XK5+UNQ&K^?Po9huRiX)dAqmUGjDAS z;W{dnm@#d)#)9?S--YwPzPJbd)s z#`pcdKKpU|c>ucId-u$a&10LED>jX@fi*{;ODHy*l|_Ola=U<>qYESD@Vsbf15?=2 zgzxsAuWT513VK;G_h5~5rN8^^9uSg0D>#-5dVq>s=F+ruek48FiQNek(Wi@J~&6D+Mh z#>b{9I%At2mm@qaFSpz=W7zI7po&gBit=aBM86D^7GW_a8JZBAPk`iX{h_&g*K~i| zUtRe=S{e3No*$gAUwqT;Ky77Mbc%5j+b}W9W~~i^%jSNorDTlbZkebEn1V1JP%_wE z*C((A?aeGdUwFK15hts}Ner1*&%PVd_ghXkog|INvw!f;d(eQ9uBBwq+l1 zt})G$6oyCk$+P3AL_&&SXCveWt&U@m^c_#3%9x3;^0r8KgN;??**zW&9otS>o@r`z zv0cX5CBu+<#W-vpz%YrJN#J?Y(HIVgH9lCHI9okiTg5*h31Qb2;jCpgH_h5=ixmt!0DNEHaW8S{sGABi<+GVrp`_qK<@xM(^)YJ9+4BYL38!Y>Or7dTla8P=#4|Un8>-a zw(-T06$9pmK8=kQWk&v|dE2gF6At zjLf)yL<)UyP(9r>YY&~>2#sV}|>I>mIHlwr^foVuKv=2N7cxIR+?C(tS8DxWaHafBNBgmNwtrosp=MAcfI z791J?1)acr`9iSnOmJr%MW(s19n?`waY96#H93$1mWEsmOk9~XE9@Pn*8MH~(D?#V zEtkI&N#OR_%a}w7x>e>8E4Bd9CJ_f78kr|BCEdHwtXbF(PEjN!Z&;srdB3A>oAyhY_(^IsD z^)n7vZHnvO2tk;n)7tmy_EV3+S(|i7N}@#$LPv=Xtr0D(aor|i38*3Ieoz^P{miDM zDF>IJ*FJkQ41)vp142OnD2^>@5vh&b`#iPVC|`LPyiCzzCIjj$d(?#3e3GQ7Y6J6f z=j4$=kk~vLD?V{;UR1JN#8Kx#snsd7SJ|n}pP>U*=Wtf5dWBx+U|AalLEj=bzaV>-j3iOUecs5z)Jh)Bae z=A>b$S`$XS)Pq@VBe?*XAZNu2+Yam+4{&>|7;Yk|SG(Nl!@c<~v&d2HWQq$QVf1C} z?*C=Q+`iN8ZEijNVgJL24+p<}`PYH;qy$`r&P}`tHQ^{33~b6esCz#p$&#Z_+{5+Y zWUabp@lBSm07R8;l(935VBN$Rz#jA>sr>sVc&c<}RjSk&VEBu|k ziSFwAi8AL4L_(4OlW$1ESBLS!_~3l4{jh5H3HC;2BWEXICCKT7y!W;17adj4Y2mQu z(Zh%LAKpZ+dF0~VyY9jos=Ja9jVt25UNOS9`2rcV=e{A;Q^v;S z6?MU$xrZ1*ShzU~h3Pa;PUw>GQOQ79PVd!@jwmkg=*Z&oj+*ZBj)*-+Lnj-T;d#<= zd7UO7)hgYPpQDu|-+_D6)mJ!4OBCoV^H!HtF}7u5*kux&osX=$*3g!#wBU2Q#; z@X$kxBpUm4HXef}eV`MR442R15|ye^VgZYS)pm21ryc?V=(qt#7(T$DY|ZB!0aBn5Z>2~E}hm|TNKQ8{W}UW0!kc8} zYE(+WLStO?^fDk-0SM*1wr^G1{<>zwK!ie3Izg{93&mLH3Wsv0LLRR()b*;;oP*B( zBt!dr?kq_m$NjkTM;WoXt*eT>59H6`G}vQGzywrXE!mH09r~eb4*F(&dfIrp?cEfO zFqPDvca&q=u8(Y4{!1cME+*t_SS zsLjX?0N89%nM{nV(7?b~4lopHwlp>VDdyLBz z(!S%V=RRuv_>Hli_lmfGJ_tMQ#t&I(T~=Ka?GI-LD}p&=DX{h{fw9+z6S)hcs;32E{d+jdAsEjCAH=iMkdr zSJ7nV$VKP67Or-UmPLbz7W=F{jD0FIBR&`#xt+i?T~DwP4`N^jlXJ)t%$)}Shit(3 zHgvxnT*1gwpAiLWa#9KFkrK$o%rt?R8Ao9Xki?h>k1MMe!X-v zBVIbAirM+73r&0TZ>6v|18EZTfi#^|K)T=q$#q1qR0)Q>kmPMT;koofqO#iQlnM4i z#;-Q7T#AACu?Zq>p}ulmUvtR-ehcY%x+c5fcV@pM_1oV%=;pB_@atak&q#bF6HN^v zj6m-H;R<>2|KsZ8&F$bB9 zh4N2QfG<9!ZH3B5SiXJ&ZgKd{Imd<4z3Ax*;k-CR{%-;_7?bp|n3P-|^u7VAj8WE= zfclS5fE|7@`Z^m`(o+|;&?hKc`E>a0lf`E)ixJRem3qudi|UaV-L9yS^m=jV(;DIR znM;%?SvRzz+GD3W_ zqeoj_nSxQI+u;x|>; + protocols?: readonly string[]; + tls?: TlsOptions; + timeouts?: { connectMs?: number; closeMs?: number }; + limits?: { + maxMessageBytes?: number; + receiveQueueBytes?: number; + receiveQueueMessages?: number; + sendQueueBytes?: number; + }; + socket: WebSocketHandlers; +} + +export interface WsOps { + connect(metaJson: string): number; + send(handle: number, opcode: number, payload: string | ArrayBuffer | null): number; + receiveInto(handle: number, into: ArrayBuffer, offset: number, length: number): number; + close(handle: number, code?: number, reason?: string): number; + terminate(handle: number): void; + bufferedAmount(handle: number): number; + poll(): string | undefined; + lastError(): string; + limits(): string; +} + +interface SocketState { + handle: number; + socket: WebSocketImpl; + handlers: WebSocketHandlers; + resolve: ((socket: WebSocket) => void) | null; + reject: ((error: NetworkError) => void) | null; +} + +const sockets = new Map(); + +const ws = createBinding({ + name: "ws", + protocol: PROTOCOL, + specMajor: WS_SPEC_MAJOR, + requiredOps: ["connect", "send", "receiveInto", "close", "terminate", "bufferedAmount", "poll", "lastError", "limits"], + dispatch: dispatchWsEvent, + onProtocolFailure(ops, error) { + for (const [handle, s] of [...sockets]) { + ops.terminate(handle); + terminate(s, error, 1006, "", false, true); + } + }, +}); + +const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; + +export class WebSocket { + readonly url: string; + protected _protocol = ""; + protected _readyState: WebSocketReadyState = "connecting"; + protected readonly handle: number; + protected readonly ops: WsOps; + protected readonly limitMessage: number; + + /** @internal */ + constructor(url: string, handle: number, ops: WsOps, limitMessage: number) { + this.url = url; + this.handle = handle; + this.ops = ops; + this.limitMessage = limitMessage; + } + + get protocol(): string { + return this._protocol; + } + + get readyState(): WebSocketReadyState { + return this._readyState; + } + + get bufferedAmount(): number { + if (this._readyState === "closed") return 0; + const n = this.ops.bufferedAmount(this.handle); + return n < 0 ? 0 : n; + } + + private sendFrame(opcode: number, data: NetworkData | undefined, operation: string): number { + if (this._readyState !== "open") return WS_SEND_CLOSED; + let payload: string | ArrayBuffer | null = null; + if (data !== undefined) { + if (typeof data === "string") { + if (opcode !== WS_OPCODE.text) { + const bytes = stringToUtf8(data); + payload = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + } else { + payload = data; + } + } else { + const bytes = snapshotData(data, operation, PROTOCOL); + payload = bytes.buffer as ArrayBuffer; + } + } + return this.ops.send(this.handle, opcode, payload); + } + + send(data: NetworkData): WebSocketSendResult { + const opcode = typeof data === "string" ? WS_OPCODE.text : WS_OPCODE.binary; + const rc = this.sendFrame(opcode, data, "send"); + if (rc === WS_SEND_ACCEPTED) return { status: "accepted", needsDrain: false }; + if (rc === WS_SEND_ACCEPTED_HIGH_WATER) return { status: "accepted", needsDrain: true }; + if (rc === WS_SEND_BACKPRESSURE) return { status: "backpressure" }; + if (rc === WS_SEND_INVALID) { + const size = typeof data === "string" ? stringToUtf8(data).length : (data as ArrayBufferView).byteLength ?? (data as ArrayBuffer).byteLength; + throw new NetworkError( + size > this.limitMessage ? NET_ERROR.messageTooLarge : NET_ERROR.invalidRequest, + size > this.limitMessage ? `message exceeds ${this.limitMessage} bytes` : "invalid message", + { operation: "send", protocol: PROTOCOL }, + ); + } + return { status: "closed" }; + } + + ping(data?: NetworkData): boolean { + return this.control(WS_OPCODE.ping, data, "ping"); + } + + pong(data?: NetworkData): boolean { + return this.control(WS_OPCODE.pong, data, "pong"); + } + + private control(opcode: number, data: NetworkData | undefined, operation: string): boolean { + const rc = this.sendFrame(opcode, data, operation); + if (rc === WS_SEND_INVALID) { + throw new NetworkError(NET_ERROR.invalidRequest, `${operation} payload exceeds ${WS_CONTROL_PAYLOAD_MAX} bytes`, { + operation, + protocol: PROTOCOL, + }); + } + return rc === WS_SEND_ACCEPTED || rc === WS_SEND_ACCEPTED_HIGH_WATER; + } + + close(code?: number, reason?: string): void { + if (this._readyState !== "open") return; + if (code !== undefined && (!Number.isInteger(code) || (code !== 1000 && (code < 3000 || code > 4999)))) { + throw new NetworkError(NET_ERROR.invalidRequest, "close code must be 1000 or 3000-4999", { + operation: "close", + protocol: PROTOCOL, + }); + } + if (reason !== undefined && stringToUtf8(reason).length > 123) { + throw new NetworkError(NET_ERROR.invalidRequest, "close reason exceeds 123 bytes", { + operation: "close", + protocol: PROTOCOL, + }); + } + const rc = this.ops.close(this.handle, code, reason); + if (rc === 0) this._readyState = "closing"; + } + + terminate(): void { + if (this._readyState === "closed") return; + this.ops.terminate(this.handle); + // The terminal event arrives next tick; commands stop being accepted now. + if (this._readyState === "open" || this._readyState === "connecting") this._readyState = "closing"; + } +} + +class WebSocketImpl extends WebSocket { + __setOpen(protocol: string): void { + this._protocol = protocol; + this._readyState = "open"; + } + __setClosed(): void { + this._readyState = "closed"; + } +} + +function terminate( + s: SocketState, + error: NetworkError | null, + code: number, + reason: string, + clean: boolean, + callClose: boolean, +): void { + if (!sockets.has(s.handle)) return; + sockets.delete(s.handle); + ws.release(); + if (s.reject) { + // Handshake never completed: only the connect Promise observes it. + const reject = s.reject; + s.reject = null; + s.resolve = null; + s.socket.__setClosed(); + reject(error ?? new NetworkError(NET_ERROR.closed, "socket closed before open", { operation: "connect", protocol: PROTOCOL })); + return; + } + s.socket.__setClosed(); + if (error && s.handlers.error) s.handlers.error(s.socket, error); + if (callClose && s.handlers.close) s.handlers.close(s.socket, code, reason); +} + +function dispatchWsEvent(event: EventRecord, ops: WsOps): void { + const handle = event.h; + if (typeof handle !== "number") return; + const s = sockets.get(handle); + if (!s) return; + switch (event.t) { + case "open": { + const protocol = typeof event.protocol === "string" ? event.protocol : ""; + s.socket.__setOpen(protocol); + const resolve = s.resolve; + s.resolve = null; + s.reject = null; + if (s.handlers.open) s.handlers.open(s.socket); + if (resolve) resolve(s.socket); + return; + } + case "message": { + if (!s.handlers.message) { + // Still dequeue binary payloads so the native queue drains. + if (event.kind === "binary" && typeof event.bytes === "number") { + const scratch = new ArrayBuffer(Math.max(0, event.bytes)); + ops.receiveInto(handle, scratch, 0, scratch.byteLength); + } + return; + } + if (event.kind === "text") { + s.handlers.message(s.socket, typeof event.text === "string" ? event.text : ""); + return; + } + if (event.kind === "binary" && typeof event.bytes === "number" && event.bytes >= 0) { + const bytes = new Uint8Array(event.bytes); + const got = ops.receiveInto(handle, bytes.buffer as ArrayBuffer, 0, bytes.length); + if (got !== bytes.length) { + ops.terminate(handle); + terminate( + s, + new NetworkError(NET_ERROR.protocol, "binary message transfer failed", { operation: "message", protocol: PROTOCOL }), + 1006, + "", + false, + true, + ); + return; + } + s.handlers.message(s.socket, bytes); + } + return; + } + case "ping": + case "pong": { + const handler = event.t === "ping" ? s.handlers.ping : s.handlers.pong; + if (!handler) return; + const payload = event.payload as Record | undefined; + const b64 = payload && typeof payload === "object" ? payload[WS_BLOB_KEY] : undefined; + handler(s.socket, typeof b64 === "string" ? base64ToBytes(b64) : new Uint8Array(0)); + return; + } + case "drain": + if (s.handlers.drain) s.handlers.drain(s.socket); + return; + case "error": { + const error = new NetworkError( + normalizeErrorCode(event.code), + typeof event.message === "string" && event.message ? event.message : String(event.code), + { + operation: s.reject ? "connect" : "socket", + protocol: PROTOCOL, + causeCode: typeof event.causeCode === "string" ? event.causeCode : undefined, + reasonCode: typeof event.status === "number" ? event.status : undefined, + }, + ); + if (s.reject) { + terminate(s, error, 1006, "", false, false); + return; + } + // After open, `close` follows; report the error now, close on arrival. + if (s.handlers.error) s.handlers.error(s.socket, error); + s.handlers = { ...s.handlers, error: undefined }; + return; + } + case "close": { + const code = typeof event.code === "number" ? event.code : 1005; + const reason = typeof event.reason === "string" ? event.reason : ""; + terminate(s, null, code, reason, event.clean === true, true); + return; + } + default: + return; + } +} + +/** Open a WebSocket. Resolves with the socket after the handshake; failures + * before `open` only reject the Promise. */ +export function connect(url: string | URL, options: WebSocketConnectOptions): Promise { + let ops: WsOps; + let handle: number; + let href: string; + let maxMessage = WS_MAX_MESSAGE_BYTES; + try { + if (!options || typeof options !== "object" || !options.socket || typeof options.socket !== "object") { + throw new NetworkError(NET_ERROR.invalidRequest, "connect() requires socket handlers", { operation: "connect", protocol: PROTOCOL }); + } + ops = ws.require("connect"); + const limits = ws.limits(); + let parsed: URL; + try { + parsed = url instanceof URL ? new URL(url.href) : new URL(String(url)); + } catch { + throw new NetworkError(NET_ERROR.invalidRequest, `invalid URL: ${String(url)}`, { operation: "connect", protocol: PROTOCOL }); + } + if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") { + throw new NetworkError(NET_ERROR.invalidRequest, "url must be ws: or wss:", { operation: "connect", protocol: PROTOCOL }); + } + if (parsed.hash) { + throw new NetworkError(NET_ERROR.invalidRequest, "WebSocket URLs cannot carry a fragment", { operation: "connect", protocol: PROTOCOL }); + } + if (parsed.username || parsed.password) { + throw new NetworkError(NET_ERROR.invalidRequest, "URL must not carry credentials", { operation: "connect", protocol: PROTOCOL }); + } + const features = Array.isArray(limits.features) ? (limits.features as unknown[]) : []; + if (parsed.protocol === "wss:" && !features.includes("tls")) { + throw new NetworkError(NET_ERROR.unsupported, "this host does not provide network.websocket.client.tls", { + operation: "connect", + protocol: PROTOCOL, + }); + } + href = parsed.href; + const meta: WsConnectMeta = { url: href }; + if (options.protocols !== undefined) { + const seen = new Set(); + const list: string[] = []; + for (const p of options.protocols) { + if (typeof p !== "string" || !TOKEN.test(p) || seen.has(p)) { + throw new NetworkError(NET_ERROR.invalidRequest, `invalid subprotocol "${String(p)}"`, { operation: "connect", protocol: PROTOCOL }); + } + seen.add(p); + list.push(p); + } + if (list.length) meta.protocols = list; + } + if (options.headers !== undefined) { + const headers: Record = {}; + for (const rawName of Object.keys(options.headers)) { + const name = rawName.toLowerCase(); + const value = String(options.headers[rawName]).replace(/^[\t\n\r ]+|[\t\n\r ]+$/g, ""); + if (!TOKEN.test(name) || /[\0\r\n]/.test(value)) { + throw new NetworkError(NET_ERROR.invalidRequest, `invalid header ${rawName}`, { operation: "connect", protocol: PROTOCOL }); + } + if ((WS_FORBIDDEN_HEADERS as readonly string[]).includes(name)) { + throw new NetworkError(NET_ERROR.invalidRequest, `header ${rawName} is owned by the WebSocket core`, { + operation: "connect", + protocol: PROTOCOL, + }); + } + headers[name] = value; + } + meta.headers = headers; + } + if (options.timeouts !== undefined) { + meta.timeouts = {}; + if (options.timeouts.connectMs !== undefined) { + meta.timeouts.connectMs = integerOption(options.timeouts.connectMs, "timeouts.connectMs", 1, WS_MAX_CONNECT_MS, "connect", PROTOCOL); + } + if (options.timeouts.closeMs !== undefined) { + meta.timeouts.closeMs = integerOption(options.timeouts.closeMs, "timeouts.closeMs", 1, WS_MAX_CONNECT_MS, "connect", PROTOCOL); + } + } + maxMessage = limitNumber(limits, "maxMessageBytes", WS_MAX_MESSAGE_BYTES); + if (options.limits !== undefined) { + meta.limits = {}; + const l = options.limits; + if (l.maxMessageBytes !== undefined) { + meta.limits.maxMessageBytes = integerOption(l.maxMessageBytes, "limits.maxMessageBytes", 1, maxMessage, "connect", PROTOCOL); + maxMessage = meta.limits.maxMessageBytes; + } + if (l.receiveQueueBytes !== undefined) { + meta.limits.receiveQueueBytes = integerOption(l.receiveQueueBytes, "limits.receiveQueueBytes", 1, limitNumber(limits, "maxReceiveQueueBytes", WS_MAX_RECEIVE_QUEUE_BYTES), "connect", PROTOCOL); + } + if (l.receiveQueueMessages !== undefined) { + meta.limits.receiveQueueMessages = integerOption(l.receiveQueueMessages, "limits.receiveQueueMessages", 1, limitNumber(limits, "maxReceiveQueueMessages", WS_MAX_RECEIVE_QUEUE_MESSAGES), "connect", PROTOCOL); + } + if (l.sendQueueBytes !== undefined) { + meta.limits.sendQueueBytes = integerOption(l.sendQueueBytes, "limits.sendQueueBytes", 1, limitNumber(limits, "maxSendQueueBytes", WS_MAX_SEND_QUEUE_BYTES), "connect", PROTOCOL); + } + } + if (options.tls !== undefined) { + const v = options.tls.verification; + if (v !== undefined && v !== "full" && v !== "development-insecure") { + throw new NetworkError(NET_ERROR.invalidRequest, "tls.verification must be full or development-insecure", { operation: "connect", protocol: PROTOCOL }); + } + for (const key of ["ca", "credential", "alpn", "minVersion", "maxVersion", "clientCertificate", "revocation", "serverName"] as const) { + if (options.tls[key] !== undefined) { + throw new NetworkError(NET_ERROR.unsupported, `tls.${key} is not supported by this host`, { operation: "connect", protocol: PROTOCOL }); + } + } + if (v !== undefined) meta.tls = { verification: v }; + } + handle = ops.connect(JSON.stringify(meta)); + if (!Number.isInteger(handle) || handle < 0) throw errorFromLastError(ops.lastError(), "connect", PROTOCOL); + } catch (error) { + return Promise.reject( + error instanceof NetworkError ? error : new NetworkError(NET_ERROR.invalidRequest, String(error), { operation: "connect", protocol: PROTOCOL }), + ); + } + return new Promise((resolve, reject) => { + const socket = new WebSocketImpl(href, handle, ops, maxMessage); + sockets.set(handle, { handle, socket, handlers: options.socket, resolve, reject }); + ws.retain(); + }); +} + +/** @internal test hooks */ +export const __websocket = { ws, sockets }; diff --git a/hosts/sim/httpd.ts b/hosts/sim/httpd.ts new file mode 100644 index 00000000..e4c1b313 --- /dev/null +++ b/hosts/sim/httpd.ts @@ -0,0 +1,416 @@ +// Deterministic virtual-clock HTTP Server module (`globalThis.httpd`, spec v2) +// for conformance tests. No socket is opened: a test injects requests with +// `host.inject(...)`, the listener/request events become visible at the next +// tick(), and everything the app answers through respond/write/endBody lands +// on the injected request record. Inject via bootWorld's extraGlobals: +// `{ httpd: host.ns }`. + +import { + HTTPD_DEFAULT_BODY_IDLE_MS, + HTTPD_DEFAULT_CLOSE_MS, + HTTPD_DEFAULT_HANDLER_MS, + HTTPD_DEFAULT_HEADER_MS, + HTTPD_DEFAULT_KEEP_ALIVE_MS, + HTTPD_DEFAULT_REQUEST_QUEUE_BYTES, + HTTPD_MAX_CONNECTIONS, + HTTPD_MAX_EVENTS_PER_TICK, + HTTPD_MAX_HEADERS, + HTTPD_MAX_HEADER_BYTES, + HTTPD_MAX_INFLIGHT, + HTTPD_MAX_REQUEST_QUEUE_BYTES, + HTTPD_MAX_SEND_QUEUE_BYTES, + HTTPD_MAX_SERVERS, + HTTPD_MAX_TARGET_BYTES, + HTTPD_MAX_TICK_BYTES, + HTTPD_MAX_TIMEOUT_MS, + HTTPD_SEND_ACCEPTED, + HTTPD_SEND_BACKPRESSURE, + HTTPD_SEND_HIGH_WATER_BYTES, + HTTPD_SEND_INVALID, + HTTPD_SEND_INVALID_REQUEST, + HTTPD_SEND_LOW_WATER_BYTES, + HTTPD_SPEC_MAJOR, + HTTPD_SPEC_MINOR, + type HttpdLimits, + type HttpdListenMeta, + type HttpdRespondMeta, +} from "../../contracts/spec/httpd.ts"; +import { NET_ERROR, NET_TLS_MIN_VERSION } from "../../contracts/spec/net.ts"; +import { stringToUtf8 } from "../../framework/src/bytes.ts"; +import type { HttpdOps } from "../../framework/src/net/http.ts"; + +export interface SimInjectOptions { + method?: string; + target?: string; + headers?: Readonly>; + body?: string | Uint8Array | readonly (string | Uint8Array)[]; + /** Ticks between body chunks (default 0: all with the request). */ + chunkTicks?: number; + remote?: { address: string; port: number }; + /** Announce a Content-Length (default: total body bytes; null = chunked). */ + length?: number | null; +} + +export interface SimInjectedRequest { + readonly req: number; + status: number; + statusText: string; + headers: Record; + contentLength: number | undefined; + readonly chunks: Uint8Array[]; + responded: boolean; + /** true once respond(end=true) or endBody landed. */ + complete: boolean; + aborted: string | null; + /** Concatenated response body. */ + body(): Uint8Array; + text(): string; + /** Simulate the peer disconnecting; the app sees aborted{closed}. */ + disconnect(): void; +} + +interface Server { + handle: number; + meta: HttpdListenMeta; + listeningTick: number; + listening: boolean; + stopping: boolean; + closeTick: number; + terminal: boolean; +} + +interface Pending { + server: Server; + record: SimInjectedRequest & { visible: Uint8Array[]; visibleBytes: number; chunks_in: Uint8Array[]; nextChunkTick: number; delivered: boolean; deliverTick: number; ended: boolean; drainArmed: boolean; disconnectRequested: boolean; terminal: boolean; options: SimInjectOptions; queued: number }; +} + +export interface SimHttpdHost { + readonly ns: HttpdOps; + tick(): void; + /** Queue a request for the server bound to `port` (or the only server). */ + inject(options?: SimInjectOptions, port?: number): SimInjectedRequest; + readonly log: string[]; + readonly live: () => number; + /** Bytes the sim send queue accepts per respond/write before -2. */ + sendQueueBytes: number; +} + +export const SIM_HTTPD_LIMITS: HttpdLimits = Object.freeze({ + specMajor: HTTPD_SPEC_MAJOR, + specMinor: HTTPD_SPEC_MINOR, + maxServers: HTTPD_MAX_SERVERS, + maxConnections: HTTPD_MAX_CONNECTIONS, + maxInflight: HTTPD_MAX_INFLIGHT, + maxTlsInflight: 0, + maxHeaders: HTTPD_MAX_HEADERS, + maxHeaderBytes: HTTPD_MAX_HEADER_BYTES, + maxTargetBytes: HTTPD_MAX_TARGET_BYTES, + defaultRequestQueueBytes: HTTPD_DEFAULT_REQUEST_QUEUE_BYTES, + maxRequestQueueBytes: HTTPD_MAX_REQUEST_QUEUE_BYTES, + maxSendQueueBytes: HTTPD_MAX_SEND_QUEUE_BYTES, + sendHighWaterBytes: HTTPD_SEND_HIGH_WATER_BYTES, + sendLowWaterBytes: HTTPD_SEND_LOW_WATER_BYTES, + maxEventsPerTick: HTTPD_MAX_EVENTS_PER_TICK, + maxTickBytes: HTTPD_MAX_TICK_BYTES, + defaultHeaderMs: HTTPD_DEFAULT_HEADER_MS, + defaultBodyIdleMs: HTTPD_DEFAULT_BODY_IDLE_MS, + defaultHandlerMs: HTTPD_DEFAULT_HANDLER_MS, + defaultKeepAliveMs: HTTPD_DEFAULT_KEEP_ALIVE_MS, + defaultCloseMs: HTTPD_DEFAULT_CLOSE_MS, + maxTimeoutMs: HTTPD_MAX_TIMEOUT_MS, + tlsMinVersion: NET_TLS_MIN_VERSION, + features: [], +}); + +function toBytes(value: string | Uint8Array): Uint8Array { + return value instanceof Uint8Array ? value.slice() : stringToUtf8(value); +} + +export function createSimHttpdHost(): SimHttpdHost { + const servers = new Map(); + const requests = new Map(); + const events: object[] = []; + const log: string[] = []; + let nextHandle = 1; + let nextReq = 1; + let nextEphemeral = 40000; + let now = 0; + let lastError = ""; + + const refuse = (code: string, message: string): number => { + lastError = `${code}: ${message}`; + return -1; + }; + + const host: SimHttpdHost = { + sendQueueBytes: HTTPD_MAX_SEND_QUEUE_BYTES, + ns: { + listen(metaJson) { + let meta: HttpdListenMeta; + try { + meta = JSON.parse(metaJson) as HttpdListenMeta; + } catch { + return refuse(NET_ERROR.invalidRequest, "malformed listen metadata"); + } + if (typeof meta.address !== "string" || !Number.isInteger(meta.port)) { + return refuse(NET_ERROR.invalidRequest, "address/port required"); + } + if (meta.tls) return refuse(NET_ERROR.unsupported, "tls not provided"); + if (servers.size >= HTTPD_MAX_SERVERS) return refuse(NET_ERROR.resourceLimit, "too many servers"); + for (const s of servers.values()) { + if (s.meta.port === meta.port && meta.port !== 0 && !s.terminal) { + // Bind conflicts surface asynchronously like a native bind(). + } + } + const handle = nextHandle++; + servers.set(handle, { handle, meta, listeningTick: now + 1, listening: false, stopping: false, closeTick: 0, terminal: false }); + log.push(`listen ${handle} ${meta.address}:${meta.port}`); + return handle; + }, + stop(handle, graceful, timeoutMs) { + const s = servers.get(handle); + if (!s || s.terminal || s.stopping) return -1; + s.stopping = true; + s.closeTick = now + 1; + log.push(`stop ${handle} ${graceful} ${timeoutMs}`); + return 0; + }, + respond(req, metaJson, body) { + const p = requests.get(req); + if (!p || p.record.responded || p.record.terminal) return HTTPD_SEND_INVALID_REQUEST; + let meta: HttpdRespondMeta; + try { + meta = JSON.parse(metaJson) as HttpdRespondMeta; + } catch { + return HTTPD_SEND_INVALID; + } + if (!Number.isInteger(meta.status) || meta.status < 200 || meta.status > 599) return HTTPD_SEND_INVALID; + const bytes = body ? new Uint8Array(body.slice(0)) : new Uint8Array(0); + const end = meta.end !== false; + if (end && p.record.queued + bytes.length > host.sendQueueBytes) { + p.record.drainArmed = true; + return HTTPD_SEND_BACKPRESSURE; + } + p.record.queued += bytes.length; + if (meta.contentLength !== undefined && end && meta.contentLength !== bytes.length) return HTTPD_SEND_INVALID; + p.record.responded = true; + p.record.status = meta.status; + p.record.statusText = meta.statusText ?? ""; + p.record.headers = { ...(meta.headers ?? {}) }; + p.record.contentLength = meta.contentLength; + if (bytes.length) p.record.chunks.push(bytes); + if (end) { + p.record.complete = true; + finish(p); + } + log.push(`respond ${req} ${meta.status} end=${end} ${bytes.length}`); + return HTTPD_SEND_ACCEPTED; + }, + write(req, chunk) { + const p = requests.get(req); + if (!p || !p.record.responded || p.record.complete || p.record.terminal) return HTTPD_SEND_INVALID_REQUEST; + const bytes = new Uint8Array(chunk.slice(0)); + if (bytes.length > HTTPD_MAX_SEND_QUEUE_BYTES) return HTTPD_SEND_INVALID; + if (p.record.queued + bytes.length > host.sendQueueBytes) { + p.record.drainArmed = true; + return HTTPD_SEND_BACKPRESSURE; + } + p.record.queued += bytes.length; + p.record.chunks.push(bytes); + log.push(`write ${req} ${bytes.length}`); + return HTTPD_SEND_ACCEPTED; + }, + endBody(req) { + const p = requests.get(req); + if (!p || !p.record.responded || p.record.complete || p.record.terminal) return -1; + p.record.complete = true; + finish(p); + log.push(`endBody ${req}`); + return 0; + }, + readInto(req, into, offset, length) { + const p = requests.get(req); + if (!p || !p.record.delivered) return -1; + const dest = new Uint8Array(into, offset, length); + let copied = 0; + while (p.record.visible.length && copied < dest.length) { + const head = p.record.visible[0]; + const n = Math.min(head.length, dest.length - copied); + dest.set(head.subarray(0, n), copied); + copied += n; + if (n === head.length) p.record.visible.shift(); + else p.record.visible[0] = head.subarray(n); + } + p.record.visibleBytes -= copied; + return copied; + }, + abort(req) { + const p = requests.get(req); + if (!p || p.record.terminal) return; + p.record.aborted = NET_ERROR.cancelled; + log.push(`abort ${req}`); + }, + poll() { + return events.length ? JSON.stringify(events.splice(0)) : undefined; + }, + lastError() { + return lastError; + }, + limits() { + return JSON.stringify(SIM_HTTPD_LIMITS); + }, + }, + tick, + inject, + log, + live: () => requests.size, + }; + + function finish(p: Pending): void { + p.record.terminal = true; + requests.delete(p.record.req); + } + + function inject(options: SimInjectOptions = {}, port?: number): SimInjectedRequest { + let server: Server | undefined; + for (const s of servers.values()) { + if (s.terminal) continue; + if (port === undefined || s.meta.port === port) { + server = s; + break; + } + } + if (!server) throw new Error("sim httpd: no server to inject into"); + const req = nextReq++; + const rawBody = options.body ?? ""; + const chunksIn = Array.isArray(rawBody) + ? (rawBody as readonly (string | Uint8Array)[]).map(toBytes) + : [toBytes(rawBody as string | Uint8Array)].filter((c) => c.length > 0); + const record = { + req, + status: 0, + statusText: "", + headers: {} as Record, + contentLength: undefined as number | undefined, + chunks: [] as Uint8Array[], + responded: false, + complete: false, + aborted: null as string | null, + visible: [] as Uint8Array[], + visibleBytes: 0, + chunks_in: chunksIn, + nextChunkTick: now + 1, + delivered: false, + deliverTick: now + 1, + ended: false, + drainArmed: false, + disconnectRequested: false, + terminal: false, + options, + queued: 0, + body(): Uint8Array { + const total = record.chunks.reduce((n, c) => n + c.length, 0); + const out = new Uint8Array(total); + let o = 0; + for (const c of record.chunks) { + out.set(c, o); + o += c.length; + } + return out; + }, + text(): string { + return new TextDecoder().decode(record.body()); + }, + disconnect(): void { + record.disconnectRequested = true; + }, + }; + requests.set(req, { server, record }); + return record; + } + + function tick(): void { + now++; + for (const s of [...servers.values()]) { + if (s.terminal) continue; + if (!s.listening && now >= s.listeningTick) { + s.listening = true; + const port = s.meta.port === 0 ? nextEphemeral++ : s.meta.port; + s.meta.port = port; + events.push({ t: "listening", h: s.handle, address: s.meta.address, port }); + } + if (s.stopping && now >= s.closeTick) { + s.terminal = true; + servers.delete(s.handle); + for (const p of [...requests.values()]) { + if (p.server === s && !p.record.terminal) { + p.record.aborted = NET_ERROR.closed; + finish(p); + events.push({ t: "aborted", req: p.record.req, code: NET_ERROR.closed }); + } + } + events.push({ t: "closed", h: s.handle }); + } + } + for (const p of [...requests.values()]) { + const r = p.record; + if (r.terminal) continue; + if (r.aborted) { + const code = r.aborted; + finish(p); + events.push({ t: "aborted", req: r.req, code }); + continue; + } + if (r.disconnectRequested) { + r.aborted = NET_ERROR.closed; + finish(p); + events.push({ t: "aborted", req: r.req, code: NET_ERROR.closed }); + continue; + } + if (!p.server.listening) continue; + if (!r.delivered) { + if (now < r.deliverTick) continue; + r.delivered = true; + const total = r.chunks_in.reduce((n, c) => n + c.length, 0); + const headers: Record = { host: `${p.server.meta.address}:${p.server.meta.port}`, ...(r.options.headers ?? {}) }; + const ev: Record = { + t: "request", + h: p.server.handle, + req: r.req, + method: r.options.method ?? "GET", + target: r.options.target ?? "/", + headers, + remote: r.options.remote ?? { address: "127.0.0.1", port: 50000 + r.req }, + secure: false, + }; + if (r.options.length !== null) { + ev.length = r.options.length ?? total; + if (headers["content-length"] === undefined && (ev.length as number) > 0) headers["content-length"] = String(ev.length); + } else headers["transfer-encoding"] = "chunked"; + events.push(ev); + } + let announced = false; + while (r.chunks_in.length && now >= r.nextChunkTick) { + const next = r.chunks_in.shift()!; + r.visible.push(next); + r.visibleBytes += next.length; + announced = true; + r.nextChunkTick = now + (r.options.chunkTicks ?? 0); + if ((r.options.chunkTicks ?? 0) > 0) break; + } + if (announced) events.push({ t: "readable", req: r.req, avail: r.visibleBytes }); + if (r.chunks_in.length === 0 && !r.ended) { + r.ended = true; + events.push({ t: "end", req: r.req }); + } + // The network task wrote the queued bytes out during this tick. + r.queued = 0; + if (r.drainArmed) { + r.drainArmed = false; + events.push({ t: "drain", req: r.req }); + } + } + } + + return host; +} diff --git a/hosts/sim/net.ts b/hosts/sim/net.ts index f2214b3b..d2c15ea7 100644 --- a/hosts/sim/net.ts +++ b/hosts/sim/net.ts @@ -1,159 +1,299 @@ -// Deterministic virtual-clock NET module for conformance tests. It never uses -// ambient host networking: routes are fixtures, and completions become visible -// only after tick(), exactly like a native transport crossing a tick boundary. +// Deterministic virtual-clock HTTP Client module (`globalThis.net`, spec v2) +// for conformance tests. It never uses ambient host networking: routes are +// fixtures, response heads and body chunks become visible only after +// tick(), and bytes cross through `readInto` exactly like a native transport +// crossing a tick boundary. Inject via bootWorld's extraGlobals: `{ net: +// host.ns }`, the way a device host mounts the namespace beside `ui`. import { + NET_DEFAULT_AGGREGATE_BYTES, + NET_DEFAULT_QUEUE_BYTES, + NET_DEFAULT_TIMEOUT_MS, NET_ERROR, + NET_MAX_AGGREGATE_BYTES, + NET_MAX_EVENTS_PER_TICK, + NET_MAX_HEADER_BYTES, + NET_MAX_HEADERS, NET_MAX_INFLIGHT, - NET_MAX_RESPONSE_BYTES, + NET_MAX_QUEUE_BYTES, + NET_MAX_REDIRECTS, + NET_MAX_REQUEST_BYTES, + NET_MAX_TICK_BYTES, + NET_MAX_TIMEOUT_MS, + NET_METHODS_FORBIDDEN, + NET_SPEC_MAJOR, + NET_SPEC_MINOR, + NET_TLS_MIN_VERSION, + type NetLimits, + type NetStartMeta, } from "../../contracts/spec/net.ts"; import { stringToUtf8 } from "../../framework/src/bytes.ts"; -import type { NetOps } from "../../framework/src/net-api.ts"; +import type { NetOps } from "../../framework/src/net/http.ts"; export interface SimNetRequest { readonly url: string; readonly method: string; readonly headers: Readonly>; readonly body: Uint8Array; - readonly timeoutMs: number; - readonly maxBytes: number; + readonly meta: NetStartMeta; } export interface SimNetResponse { readonly status?: number; + /** Final URL (defaults to the request URL). */ readonly url?: string; + readonly redirected?: boolean; readonly headers?: Readonly>; - readonly body?: string | Uint8Array; - /** Virtual ticks after start before the completion is visible. Default 1. */ + /** Body as one value or as chunks that become visible one per `chunkTicks`. */ + readonly body?: string | Uint8Array | readonly (string | Uint8Array)[]; + /** Announce a Content-Length (default: total body bytes; null = unknown). */ + readonly length?: number | null; + /** Virtual ticks after start before the head is visible. Default 1. */ readonly delayTicks?: number; - readonly error?: { readonly code: string; readonly message: string }; + /** Virtual ticks between body chunks. Default 0 (all with the head). */ + readonly chunkTicks?: number; + /** Fail instead of answering; `afterHeaders` fails the body stream. */ + readonly error?: { readonly code: string; readonly message: string; readonly afterHeaders?: boolean }; } export type SimNetRoute = SimNetResponse | ((request: SimNetRequest) => SimNetResponse); -interface PendingRequest { +interface Pending { readonly handle: number; - readonly readyTick: number; readonly request: SimNetRequest; readonly response: SimNetResponse; + readonly queueBytes: number; + readonly maxBodyBytes: number; + headTick: number; + headSent: boolean; + chunks: Uint8Array[]; + nextChunkTick: number; + /** Bytes visible to readInto (already announced). */ + visible: Uint8Array[]; + visibleBytes: number; + totalDelivered: number; + ended: boolean; + endSent: boolean; + cancelled: boolean; + terminal: boolean; } export interface SimNetHost { readonly ns: NetOps; + /** Advance one virtual tick: the sim's `begin_tick`. */ tick(): void; readonly log: string[]; readonly pollCalls: () => number; + /** Live handles (for leak assertions). */ + readonly live: () => number; } -function bytes(value: string | Uint8Array | undefined): Uint8Array { - if (value instanceof Uint8Array) return value.slice(); - return stringToUtf8(value ?? ""); +function toBytes(value: string | Uint8Array): Uint8Array { + return value instanceof Uint8Array ? value.slice() : stringToUtf8(value); } +export const SIM_NET_LIMITS: NetLimits = Object.freeze({ + specMajor: NET_SPEC_MAJOR, + specMinor: NET_SPEC_MINOR, + maxInflight: NET_MAX_INFLIGHT, + maxTlsInflight: 0, + maxRequestBytes: NET_MAX_REQUEST_BYTES, + defaultQueueBytes: NET_DEFAULT_QUEUE_BYTES, + maxQueueBytes: NET_MAX_QUEUE_BYTES, + defaultAggregateBytes: NET_DEFAULT_AGGREGATE_BYTES, + maxAggregateBytes: NET_MAX_AGGREGATE_BYTES, + maxEventsPerTick: NET_MAX_EVENTS_PER_TICK, + maxTickBytes: NET_MAX_TICK_BYTES, + maxHeaders: NET_MAX_HEADERS, + maxHeaderBytes: NET_MAX_HEADER_BYTES, + defaultTimeoutMs: NET_DEFAULT_TIMEOUT_MS, + maxTimeoutMs: NET_MAX_TIMEOUT_MS, + maxRedirects: NET_MAX_REDIRECTS, + tlsMinVersion: NET_TLS_MIN_VERSION, + features: [], +}); + export function createSimNetHost(routes: Readonly>): SimNetHost { - const pending = new Map(); - const bodies = new Map(); - const visible: object[] = []; + const pending = new Map(); + /** Handles that sent `end` but still hold visible unread bytes. */ + const drained = new Map(); + const events: object[] = []; const log: string[] = []; let nextHandle = 1; let now = 0; let lastError = ""; let polls = 0; + const refuse = (code: string, message: string): number => { + lastError = `${code}: ${message}`; + return -1; + }; + const ns: NetOps = { - start(metaJson: string, bodyBuffer: ArrayBuffer): number { - let meta: Omit; + start(metaJson, bodyBuffer) { + let meta: NetStartMeta; try { - meta = JSON.parse(metaJson) as typeof meta; + meta = JSON.parse(metaJson) as NetStartMeta; } catch { - lastError = `${NET_ERROR.invalidRequest}: malformed metadata`; - return -1; + return refuse(NET_ERROR.invalidRequest, "malformed request metadata"); } - if (pending.size >= NET_MAX_INFLIGHT) { - lastError = `${NET_ERROR.busy}: at most ${NET_MAX_INFLIGHT} requests may be in flight`; - return -1; + if (typeof meta.url !== "string" || !/^https?:\/\//.test(meta.url)) { + return refuse(NET_ERROR.invalidRequest, "url must be absolute http:// or https://"); } - const route = routes[meta.url]; - if (!route) { - lastError = `${NET_ERROR.invalidRequest}: no deterministic route for ${meta.url}`; - return -1; + if (meta.url.startsWith("https://")) return refuse(NET_ERROR.unsupported, "tls not provided"); + if (typeof meta.method !== "string" || (NET_METHODS_FORBIDDEN as readonly string[]).includes(meta.method)) { + return refuse(NET_ERROR.invalidRequest, "method not allowed"); } - const request: SimNetRequest = { ...meta, body: new Uint8Array(bodyBuffer).slice() }; + if (pending.size >= NET_MAX_INFLIGHT) return refuse(NET_ERROR.resourceLimit, "too many requests in flight"); + const body = bodyBuffer ? new Uint8Array(bodyBuffer.slice(0)) : new Uint8Array(0); + if (body.length > NET_MAX_REQUEST_BYTES) return refuse(NET_ERROR.resourceLimit, "request body too large"); + const route = routes[meta.url]; + if (!route) return refuse(NET_ERROR.permissionDenied, `no route for ${meta.url}`); + const request: SimNetRequest = { url: meta.url, method: meta.method, headers: meta.headers ?? {}, body, meta }; const response = typeof route === "function" ? route(request) : route; const handle = nextHandle++; - const delay = Math.max(1, Math.floor(response.delayTicks ?? 1)); - pending.set(handle, { handle, request, response, readyTick: now + delay }); - log.push(`start ${handle} ${request.method} ${request.url} ${request.body.byteLength}`); + const rawBody = response.body ?? ""; + const chunks = Array.isArray(rawBody) + ? (rawBody as readonly (string | Uint8Array)[]).map(toBytes) + : [toBytes(rawBody as string | Uint8Array)].filter((c) => c.length > 0); + const headTick = now + Math.max(1, response.delayTicks ?? 1); + pending.set(handle, { + handle, + request, + response, + queueBytes: meta.queueBytes ?? NET_DEFAULT_QUEUE_BYTES, + maxBodyBytes: meta.maxBodyBytes ?? Number.POSITIVE_INFINITY, + headTick, + headSent: false, + chunks, + nextChunkTick: headTick, + visible: [], + visibleBytes: 0, + totalDelivered: 0, + ended: false, + endSent: false, + cancelled: false, + terminal: false, + }); + log.push(`start ${handle} ${meta.method} ${meta.url} ${body.length}`); return handle; }, - take(handle: number, into: ArrayBuffer): number { - const body = bodies.get(handle); - if (!body || into.byteLength !== body.byteLength) return -1; - bodies.delete(handle); - log.push(`take ${handle} ${body.byteLength}`); - new Uint8Array(into).set(body); - return body.byteLength; - }, - cancel(handle: number): void { - pending.delete(handle); - bodies.delete(handle); - for (let i = visible.length - 1; i >= 0; i--) { - if ((visible[i] as { h?: number }).h === handle) visible.splice(i, 1); + cancel(handle) { + const p = pending.get(handle); + if (!p || p.terminal) { + // A handle that already ended keeps unread bytes until the guest + // releases them; cancel frees them without another event. + if (drained.delete(handle)) log.push(`cancel ${handle}`); + return; } + p.cancelled = true; log.push(`cancel ${handle}`); }, - poll(): string | undefined { + poll() { polls++; - if (visible.length === 0) return undefined; - const batch = JSON.stringify(visible.splice(0)); - log.push(`poll ${batch}`); - return batch; + return events.length ? JSON.stringify(events.splice(0)) : undefined; }, - lastError(): string { + lastError() { return lastError; }, + readInto(handle, into, offset, length) { + const p = pending.get(handle) ?? drained.get(handle); + if (!p || !p.headSent) return -1; + if (p.terminal && !drained.has(handle)) return -1; + const dest = new Uint8Array(into, offset, length); + let copied = 0; + while (p.visible.length && copied < dest.length) { + const head = p.visible[0]; + const n = Math.min(head.length, dest.length - copied); + dest.set(head.subarray(0, n), copied); + copied += n; + if (n === head.length) p.visible.shift(); + else p.visible[0] = head.subarray(n); + } + p.visibleBytes -= copied; + if (p.visible.length === 0) drained.delete(handle); + return copied; + }, + limits() { + return JSON.stringify(SIM_NET_LIMITS); + }, }; - return { - ns, - tick(): void { - now++; - for (const [handle, item] of [...pending]) { - if (item.readyTick > now) continue; - pending.delete(handle); - const response = item.response; - if (response.error) { - visible.push({ - t: "error", - h: handle, - code: response.error.code, - message: response.error.message, - }); + function tick(): void { + now++; + for (const p of [...pending.values()]) { + if (p.terminal) continue; + if (p.cancelled) { + p.terminal = true; + pending.delete(p.handle); + events.push({ t: "error", h: p.handle, code: NET_ERROR.cancelled, message: "cancelled" }); + continue; + } + if (!p.headSent) { + if (now < p.headTick) continue; + if (p.response.error && !p.response.error.afterHeaders) { + p.terminal = true; + pending.delete(p.handle); + events.push({ t: "error", h: p.handle, code: p.response.error.code, message: p.response.error.message }); continue; } - const body = bytes(response.body); - const limit = Math.min(item.request.maxBytes, NET_MAX_RESPONSE_BYTES); - if (body.byteLength > limit) { - visible.push({ - t: "error", - h: handle, - code: NET_ERROR.responseTooLarge, - message: `response exceeded ${limit} bytes`, - }); + p.headSent = true; + const total = p.chunks.reduce((n, c) => n + c.length, 0); + const head: Record = { + t: "headers", + h: p.handle, + status: p.response.status ?? 200, + url: p.response.url ?? p.request.url, + headers: p.response.headers ?? {}, + redirected: p.response.redirected ?? false, + }; + if (p.response.length !== null) head.length = p.response.length ?? total; + events.push(head); + } + // Body chunks: each becomes visible when its tick arrives and the + // queue has room (queueBytes is the backpressure window). + let announced = false; + while (p.chunks.length && now >= p.nextChunkTick) { + const next = p.chunks[0]; + if (p.visibleBytes + next.length > p.queueBytes && p.visibleBytes > 0) break; + if (p.totalDelivered + next.length > p.maxBodyBytes) { + p.terminal = true; + pending.delete(p.handle); + events.push({ t: "error", h: p.handle, code: NET_ERROR.responseTooLarge, message: "body exceeds maxBodyBytes" }); + break; + } + p.chunks.shift(); + p.visible.push(next); + p.visibleBytes += next.length; + p.totalDelivered += next.length; + announced = true; + p.nextChunkTick = now + (p.response.chunkTicks ?? 0); + if ((p.response.chunkTicks ?? 0) > 0) break; + } + if (p.terminal) continue; + if (announced) events.push({ t: "readable", h: p.handle, avail: p.visibleBytes }); + if (p.chunks.length === 0 && !p.endSent) { + if (p.response.error?.afterHeaders) { + p.terminal = true; + pending.delete(p.handle); + events.push({ t: "error", h: p.handle, code: p.response.error.code, message: p.response.error.message }); continue; } - bodies.set(handle, body); - visible.push({ - t: "done", - h: handle, - status: response.status ?? 200, - url: response.url ?? item.request.url, - headers: response.headers ?? {}, - bytes: body.byteLength, - }); + p.endSent = true; + p.terminal = true; + pending.delete(p.handle); + events.push({ t: "end", h: p.handle }); + // Visible bytes stay readable after `end`; the SDK drains them. + if (p.visibleBytes > 0) drained.set(p.handle, p); } - }, + } + } + + return { + ns, + tick, log, pollCalls: () => polls, + live: () => pending.size, }; } diff --git a/hosts/sim/sim.ts b/hosts/sim/sim.ts index f05e6e45..2133f5e3 100644 --- a/hosts/sim/sim.ts +++ b/hosts/sim/sim.ts @@ -243,6 +243,9 @@ export async function bootWorld( g.audio = undefined; // audio module namespace: absent unless extraGlobals mounts one g.db = undefined; // db module namespace: absent unless extraGlobals mounts one g.fs = undefined; // fs module namespace: absent unless extraGlobals mounts one + g.net = undefined; // HTTP Client module namespace (hosts/sim/net.ts): absent unless mounted + g.ws = undefined; // WebSocket Client module namespace (hosts/sim/ws.ts): absent unless mounted + g.httpd = undefined; // HTTP Server module namespace (hosts/sim/httpd.ts): absent unless mounted g.__pocketApp = app; g.__simHz = hz; g.__pocketEffectTrace = (e: EffectEvent) => effects.push(e); diff --git a/hosts/sim/ws.ts b/hosts/sim/ws.ts new file mode 100644 index 00000000..db3760e1 --- /dev/null +++ b/hosts/sim/ws.ts @@ -0,0 +1,308 @@ +// Deterministic virtual-clock WebSocket Client module (`globalThis.ws`, +// spec v2) for conformance tests. Peers are fixtures keyed by URL: they +// answer the handshake, echo or script messages, and every event becomes +// visible at the next tick(). Inject via bootWorld's extraGlobals: +// `{ ws: host.ns }`. + +import { NET_ERROR, NET_TLS_MIN_VERSION } from "../../contracts/spec/net.ts"; +import { + WS_BLOB_KEY, + WS_CONTROL_PAYLOAD_MAX, + WS_DEFAULT_CLOSE_MS, + WS_DEFAULT_CONNECT_MS, + WS_MAX_CONNECT_MS, + WS_MAX_EVENTS_PER_TICK, + WS_MAX_HANDSHAKE_HEADERS, + WS_MAX_HANDSHAKE_HEADER_BYTES, + WS_MAX_MESSAGE_BYTES, + WS_MAX_RECEIVE_QUEUE_BYTES, + WS_MAX_RECEIVE_QUEUE_MESSAGES, + WS_MAX_SEND_QUEUE_BYTES, + WS_MAX_SOCKETS, + WS_MAX_TICK_BYTES, + WS_OPCODE, + WS_SEND_ACCEPTED, + WS_SEND_ACCEPTED_HIGH_WATER, + WS_SEND_BACKPRESSURE, + WS_SEND_CLOSED, + WS_SEND_HIGH_WATER_BYTES, + WS_SEND_INVALID, + WS_SEND_LOW_WATER_BYTES, + WS_SPEC_MAJOR, + WS_SPEC_MINOR, + type WsConnectMeta, + type WsLimits, +} from "../../contracts/spec/ws.ts"; +import { bytesToBase64, stringToUtf8, utf8ToString } from "../../framework/src/bytes.ts"; +import type { WsOps } from "../../framework/src/net/websocket.ts"; + +export interface SimWsPeer { + /** Subprotocol the peer selects (default: first requested or ""). */ + protocol?: string; + /** Ticks after connect before `open` (default 1). */ + delayTicks?: number; + /** Fail the handshake instead of opening. */ + error?: { code: string; message: string; status?: number }; + /** Called for each text/binary message the app sends; the returned value + * (if any) is sent back next tick. Default: echo. */ + onMessage?: (data: string | Uint8Array, peer: SimWsPeerControl) => string | Uint8Array | void; + /** Bytes the peer's send window accepts before `send` reports backpressure + * (default: the spec send queue). */ + sendWindowBytes?: number; +} + +export interface SimWsPeerControl { + /** Send a message to the app (visible next tick). */ + send(data: string | Uint8Array): void; + ping(payload?: Uint8Array): void; + /** Peer-initiated close handshake. */ + close(code?: number, reason?: string): void; + /** Transport loss without a Close frame. */ + drop(): void; +} + +interface Socket { + handle: number; + meta: WsConnectMeta; + peer: SimWsPeer; + openTick: number; + open: boolean; + closing: boolean; + /** Queued events for this socket, released in order at tick(). */ + inbox: object[]; + outbound: Uint8Array[]; // messages awaiting receiveInto + buffered: number; + drainArmed: boolean; + terminate: boolean; + closeRequested: { code: number; reason: string } | null; + closeTick: number; + terminal: boolean; + control: SimWsPeerControl; +} + +export interface SimWsHost { + readonly ns: WsOps; + tick(): void; + readonly log: string[]; + readonly live: () => number; + /** Peer control for an open socket URL (first match). */ + peer(url: string): SimWsPeerControl; +} + +export const SIM_WS_LIMITS: WsLimits = Object.freeze({ + specMajor: WS_SPEC_MAJOR, + specMinor: WS_SPEC_MINOR, + maxSockets: WS_MAX_SOCKETS, + maxTlsInflight: 0, + maxMessageBytes: WS_MAX_MESSAGE_BYTES, + maxReceiveQueueBytes: WS_MAX_RECEIVE_QUEUE_BYTES, + maxReceiveQueueMessages: WS_MAX_RECEIVE_QUEUE_MESSAGES, + maxSendQueueBytes: WS_MAX_SEND_QUEUE_BYTES, + sendHighWaterBytes: WS_SEND_HIGH_WATER_BYTES, + sendLowWaterBytes: WS_SEND_LOW_WATER_BYTES, + maxHandshakeHeaders: WS_MAX_HANDSHAKE_HEADERS, + maxHandshakeHeaderBytes: WS_MAX_HANDSHAKE_HEADER_BYTES, + maxEventsPerTick: WS_MAX_EVENTS_PER_TICK, + maxTickBytes: WS_MAX_TICK_BYTES, + defaultConnectMs: WS_DEFAULT_CONNECT_MS, + maxConnectMs: WS_MAX_CONNECT_MS, + defaultCloseMs: WS_DEFAULT_CLOSE_MS, + tlsMinVersion: NET_TLS_MIN_VERSION, + features: [], +}); + +export function createSimWsHost(peers: Readonly>): SimWsHost { + const sockets = new Map(); + const events: object[] = []; + const log: string[] = []; + let nextHandle = 1; + let now = 0; + let lastError = ""; + + const refuse = (code: string, message: string): number => { + lastError = `${code}: ${message}`; + return -1; + }; + + function queueMessage(s: Socket, data: string | Uint8Array): void { + if (typeof data === "string") s.inbox.push({ t: "message", h: s.handle, kind: "text", text: data }); + else { + s.outbound.push(data.slice()); + s.inbox.push({ t: "message", h: s.handle, kind: "binary", bytes: data.length }); + } + } + + const ns: WsOps = { + connect(metaJson) { + let meta: WsConnectMeta; + try { + meta = JSON.parse(metaJson) as WsConnectMeta; + } catch { + return refuse(NET_ERROR.invalidRequest, "malformed connect metadata"); + } + if (typeof meta.url !== "string" || !/^wss?:\/\//.test(meta.url)) { + return refuse(NET_ERROR.invalidRequest, "url must be ws:// or wss://"); + } + if (meta.url.startsWith("wss://")) return refuse(NET_ERROR.unsupported, "tls not provided"); + if (sockets.size >= WS_MAX_SOCKETS) return refuse(NET_ERROR.resourceLimit, "too many sockets"); + const peer = peers[meta.url]; + if (!peer) return refuse(NET_ERROR.permissionDenied, `no peer for ${meta.url}`); + const handle = nextHandle++; + const socket: Socket = { + handle, + meta, + peer, + openTick: now + Math.max(1, peer.delayTicks ?? 1), + open: false, + closing: false, + inbox: [], + outbound: [], + buffered: 0, + drainArmed: false, + terminate: false, + closeRequested: null, + closeTick: 0, + terminal: false, + control: null as unknown as SimWsPeerControl, + }; + socket.control = { + send: (data) => queueMessage(socket, data), + ping: (payload) => { + socket.inbox.push({ t: "ping", h: handle, payload: { [WS_BLOB_KEY]: bytesToBase64(payload ?? new Uint8Array(0)) } }); + }, + close: (code = 1000, reason = "") => { + if (socket.terminal) return; + socket.inbox.push({ t: "close", h: handle, code, reason, clean: true, local: false }); + socket.terminal = true; + }, + drop: () => { + if (socket.terminal) return; + socket.inbox.push({ t: "error", h: handle, code: NET_ERROR.closed, message: "connection lost" }); + socket.inbox.push({ t: "close", h: handle, code: 1006, reason: "", clean: false, local: false }); + socket.terminal = true; + }, + }; + sockets.set(handle, socket); + log.push(`connect ${handle} ${meta.url}`); + return handle; + }, + send(handle, opcode, payload) { + const s = sockets.get(handle); + if (!s || !s.open || s.closing || s.terminal) return WS_SEND_CLOSED; + const bytes = payload === null ? new Uint8Array(0) : typeof payload === "string" ? stringToUtf8(payload) : new Uint8Array(payload.slice(0)); + if (opcode === WS_OPCODE.ping || opcode === WS_OPCODE.pong) { + if (bytes.length > WS_CONTROL_PAYLOAD_MAX) return WS_SEND_INVALID; + if (opcode === WS_OPCODE.ping) s.inbox.push({ t: "pong", h: handle, payload: { [WS_BLOB_KEY]: bytesToBase64(bytes) } }); + log.push(`${opcode === WS_OPCODE.ping ? "ping" : "pong"} ${handle} ${bytes.length}`); + return WS_SEND_ACCEPTED; + } + if (opcode !== WS_OPCODE.text && opcode !== WS_OPCODE.binary) return WS_SEND_INVALID; + const maxMessage = s.meta.limits?.maxMessageBytes ?? WS_MAX_MESSAGE_BYTES; + if (bytes.length > maxMessage) return WS_SEND_INVALID; + const window = s.peer.sendWindowBytes ?? (s.meta.limits?.sendQueueBytes ?? WS_MAX_SEND_QUEUE_BYTES); + if (s.buffered + bytes.length > window) { + s.drainArmed = true; + return WS_SEND_BACKPRESSURE; + } + s.buffered += bytes.length; + const data = opcode === WS_OPCODE.text ? utf8ToString(bytes) : bytes; + log.push(`send ${handle} ${opcode === WS_OPCODE.text ? "text" : "binary"} ${bytes.length}`); + const reply = s.peer.onMessage ? s.peer.onMessage(data, s.control) : data; + if (reply !== undefined) queueMessage(s, reply); + const high = WS_SEND_HIGH_WATER_BYTES; + const rc = s.buffered > high ? WS_SEND_ACCEPTED_HIGH_WATER : WS_SEND_ACCEPTED; + if (rc === WS_SEND_ACCEPTED_HIGH_WATER) s.drainArmed = true; + return rc; + }, + receiveInto(handle, into, offset, length) { + const s = sockets.get(handle); + if (!s || !s.outbound.length) return -1; + const head = s.outbound[0]; + if (length < head.length) return -1; + new Uint8Array(into, offset, length).set(head); + s.outbound.shift(); + return head.length; + }, + close(handle, code, reason) { + const s = sockets.get(handle); + if (!s || !s.open || s.closing || s.terminal) return -1; + if (code !== undefined && code !== 1000 && (code < 3000 || code > 4999)) return WS_SEND_INVALID; + if (reason !== undefined && stringToUtf8(reason).length > 123) return WS_SEND_INVALID; + s.closing = true; + s.closeRequested = { code: code ?? 1005, reason: reason ?? "" }; + s.closeTick = now + 1; + log.push(`close ${handle} ${code ?? ""}`); + return 0; + }, + terminate(handle) { + const s = sockets.get(handle); + if (!s || s.terminal) return; + s.terminate = true; + log.push(`terminate ${handle}`); + }, + bufferedAmount(handle) { + const s = sockets.get(handle); + return s ? s.buffered : -1; + }, + poll() { + return events.length ? JSON.stringify(events.splice(0)) : undefined; + }, + lastError() { + return lastError; + }, + limits() { + return JSON.stringify(SIM_WS_LIMITS); + }, + }; + + function tick(): void { + now++; + for (const s of [...sockets.values()]) { + if (s.terminate) { + sockets.delete(s.handle); + if (!s.open) events.push({ t: "error", h: s.handle, code: NET_ERROR.cancelled, message: "terminated" }); + else events.push({ t: "close", h: s.handle, code: 1006, reason: "", clean: false, local: true }); + continue; + } + if (!s.open) { + if (now < s.openTick) continue; + if (s.peer.error) { + sockets.delete(s.handle); + events.push({ t: "error", h: s.handle, code: s.peer.error.code, message: s.peer.error.message, status: s.peer.error.status }); + continue; + } + s.open = true; + const protocol = s.peer.protocol ?? (s.meta.protocols?.[0] ?? ""); + events.push({ t: "open", h: s.handle, protocol }); + } + // The peer "consumed" what the app sent: release the send window. + if (s.buffered > 0) { + s.buffered = 0; + if (s.drainArmed) { + s.drainArmed = false; + s.inbox.push({ t: "drain", h: s.handle }); + } + } + if (s.inbox.length) events.push(...s.inbox.splice(0)); + if (s.terminal) { + sockets.delete(s.handle); + continue; + } + if (s.closing && s.closeRequested && now >= s.closeTick) { + sockets.delete(s.handle); + events.push({ t: "close", h: s.handle, code: s.closeRequested.code === 1005 ? 1005 : s.closeRequested.code, reason: s.closeRequested.reason, clean: true, local: true }); + } + } + } + + return { + ns, + tick, + log, + live: () => sockets.size, + peer(url) { + for (const s of sockets.values()) if (s.meta.url === url) return s.control; + throw new Error(`sim ws: no live socket for ${url}`); + }, + }; +} diff --git a/hosts/web/net.js b/hosts/web/net.js index 3eefe75f..713ae084 100644 --- a/hosts/web/net.js +++ b/hosts/web/net.js @@ -1,15 +1,57 @@ -// Browser dev host for the PocketJS NET module. Browser fetch is the physical -// transport; this adapter supplies the bounded contract and tick batching from -// contracts/spec/net.ts without exposing browser globals as the guest API. +// Browser dev host for the PocketJS HTTP Client module (`globalThis.net`, +// contracts/spec/net.ts v2). Browser fetch is the physical transport; this +// adapter supplies the spec-shaped ops, the bounded receive queue and the +// tick batching without exposing browser globals as the guest API. +// +// Delivery contract: fetch +// callbacks only ever append to `completed`; `beginFrame()` (the host's tick +// boundary) freezes each handle's readable watermark and moves the facts into +// `visible`; `poll()` reads `visible` alone. Bytes read from the response +// stream stay in a per-handle queue until the guest copies them out with +// `readInto`; the reader stops pulling while the queue is at capacity. +// +// Browser profile deviations: credentials "omit", cache "no-store", +// redirect "manual" — a redirect the browser hides ends the request with +// `unsupported`; TLS is the browser's, so "tls" is advertised. -const MAX_INFLIGHT = 2; -const MAX_REQUEST_BYTES = 64 * 1024; -const MAX_RESPONSE_BYTES = 256 * 1024; -const MAX_HEADERS = 32; -const MAX_HEADER_BYTES = 8 * 1024; +const SPEC_MAJOR = 2; +const SPEC_MINOR = 0; +const MAX_INFLIGHT = 8; +const MAX_REQUEST_BYTES = 256 * 1024; +const DEFAULT_QUEUE_BYTES = 32 * 1024; +const MAX_QUEUE_BYTES = 256 * 1024; +const DEFAULT_AGGREGATE_BYTES = 1024 * 1024; +const MAX_AGGREGATE_BYTES = 8 * 1024 * 1024; +const MAX_EVENTS_PER_TICK = 128; +const MAX_TICK_BYTES = 256 * 1024; +const MAX_HEADERS = 64; +const MAX_HEADER_BYTES = 16 * 1024; +const DEFAULT_TIMEOUT_MS = 30_000; const MAX_TIMEOUT_MS = 120_000; -const MAX_REDIRECTS = 3; -const METHODS = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]); +const MAX_REDIRECTS = 5; +const FORBIDDEN_METHODS = new Set(["CONNECT", "TRACE", "TRACK"]); +const NULL_BODY_STATUS = new Set([101, 103, 204, 205, 304]); + +const LIMITS = Object.freeze({ + specMajor: SPEC_MAJOR, + specMinor: SPEC_MINOR, + maxInflight: MAX_INFLIGHT, + maxTlsInflight: MAX_INFLIGHT, + maxRequestBytes: MAX_REQUEST_BYTES, + defaultQueueBytes: DEFAULT_QUEUE_BYTES, + maxQueueBytes: MAX_QUEUE_BYTES, + defaultAggregateBytes: DEFAULT_AGGREGATE_BYTES, + maxAggregateBytes: MAX_AGGREGATE_BYTES, + maxEventsPerTick: MAX_EVENTS_PER_TICK, + maxTickBytes: MAX_TICK_BYTES, + maxHeaders: MAX_HEADERS, + maxHeaderBytes: MAX_HEADER_BYTES, + defaultTimeoutMs: DEFAULT_TIMEOUT_MS, + maxTimeoutMs: MAX_TIMEOUT_MS, + maxRedirects: MAX_REDIRECTS, + tlsMinVersion: "1.2", + features: ["tls"], +}); function headerBytes(headers) { let bytes = 0; @@ -31,77 +73,16 @@ function validHeaders(headers) { ); } -function failure(error, timedOut) { - if (timedOut) return { code: "timeout", message: "request timed out" }; - const message = error instanceof Error ? error.message : String(error); - return { code: "connect", message }; -} - -async function readBounded(response, maxBytes) { - if (!response.body) { - const body = new Uint8Array(await response.arrayBuffer()); - if (body.byteLength > maxBytes) throw new Error("response_too_large"); - return body; - } - const reader = response.body.getReader(); - const chunks = []; - let size = 0; - try { - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - size += value.byteLength; - if (size > maxBytes) { - await reader.cancel(); - throw new Error("response_too_large"); - } - chunks.push(value); - } - } finally { - reader.releaseLock(); - } - const body = new Uint8Array(size); - let offset = 0; - for (const chunk of chunks) { - body.set(chunk, offset); - offset += chunk.byteLength; - } - return body; -} - -async function followBounded(nativeFetch, request, signal) { - let url = request.url; - let method = request.method; - let body = request.body.byteLength ? request.body : undefined; - for (let redirects = 0; ; redirects++) { - const response = await nativeFetch(url, { - method, - headers: request.headers, - body: method === "GET" || method === "HEAD" ? undefined : body, - credentials: "omit", - cache: "no-store", - redirect: "manual", - signal, - }); - if (response.type === "opaqueredirect") throw new Error("redirect_opaque"); - if (![301, 302, 303, 307, 308].includes(response.status)) return response; - await response.body?.cancel(); - if (redirects >= MAX_REDIRECTS) throw new Error("redirect_limit"); - const location = response.headers.get("location"); - if (!location) throw new Error("redirect_location"); - url = new URL(location, url).href; - if (response.status === 303 || ((response.status === 301 || response.status === 302) && method === "POST")) { - method = "GET"; - body = undefined; - } - } +function timeoutValue(value, fallback) { + if (value === undefined) return fallback; + if (!Number.isInteger(value) || value < 1 || value > MAX_TIMEOUT_MS) return null; + return value; } export function createNetHost(nativeFetch = globalThis.fetch.bind(globalThis)) { let nextHandle = 1; let lastError = ""; - const pending = new Map(); // handle -> AbortController - const bodies = new Map(); // handle -> Uint8Array + const states = new Map(); // handle -> state (until retired) const completed = []; // async transport facts, not guest-visible yet const visible = []; // facts frozen at beginFrame() @@ -110,6 +91,134 @@ export function createNetHost(nativeFetch = globalThis.fetch.bind(globalThis)) { return -1; } + function inflight() { + let n = 0; + for (const s of states.values()) if (!s.terminal) n++; + return n; + } + + function stopTimers(state) { + for (const t of state.timers) clearTimeout(t); + state.timers.length = 0; + } + + /** Terminal failure: one error event, native resources released. */ + function fail(state, code, message) { + if (state.terminal) return; + state.terminal = true; + stopTimers(state); + state.controller.abort(); + state.reader?.cancel().catch(() => {}); + state.chunks.length = 0; + state.queued = 0; + states.delete(state.handle); + completed.push({ t: "error", h: state.handle, code, message }); + } + + /** Terminal EOF: `end` event; unread bytes stay readable until drained. */ + function end(state) { + if (state.terminal) return; + state.terminal = true; + state.ended = true; + stopTimers(state); + completed.push({ t: "end", h: state.handle }); + if (state.queued === 0) states.delete(state.handle); + } + + async function run(state, meta, body) { + let response; + try { + response = await nativeFetch(meta.url, { + method: meta.method, + headers: meta.headers, + body: meta.method === "GET" || meta.method === "HEAD" || body.byteLength === 0 ? undefined : body, + credentials: "omit", + cache: "no-store", + redirect: "manual", + signal: state.controller.signal, + }); + } catch (error) { + if (!state.terminal) fail(state, state.timedOut ? "timeout" : "connect", error instanceof Error ? error.message : String(error)); + return; + } + if (state.terminal) { + await response.body?.cancel().catch(() => {}); + return; + } + clearTimeout(state.headersTimer); + if (response.type === "opaqueredirect") { + fail(state, "unsupported", "the browser hides redirect targets"); + return; + } + if ([301, 302, 303, 307, 308].includes(response.status) && meta.redirect === "error") { + fail(state, "redirect", `redirect ${response.status} refused`); + await response.body?.cancel().catch(() => {}); + return; + } + const headers = Object.create(null); + response.headers.forEach((value, name) => { + headers[name.toLowerCase()] = value; + }); + if (!validHeaders(headers)) { + fail(state, "protocol", "response headers exceed limits"); + await response.body?.cancel().catch(() => {}); + return; + } + const head = { + t: "headers", + h: state.handle, + status: response.status, + url: response.url || meta.url, + headers, + redirected: response.redirected === true, + }; + const lengthHeader = response.headers.get("content-length"); + if (lengthHeader !== null && /^\d+$/.test(lengthHeader)) head.length = Number(lengthHeader); + completed.push(head); + state.headSent = true; + if (!response.body || meta.method === "HEAD" || NULL_BODY_STATUS.has(response.status)) { + await response.body?.cancel().catch(() => {}); + end(state); + return; + } + const reader = response.body.getReader(); + state.reader = reader; + try { + for (;;) { + // Backpressure: never pull past the queue capacity. + while (state.queued >= state.queueBytes && !state.terminal) { + await new Promise((resolve) => { + state.wake = resolve; + }); + } + if (state.terminal) break; + state.armIdle(); + const { done, value } = await reader.read(); + if (state.terminal) break; + if (done) { + end(state); + break; + } + state.total += value.byteLength; + if (state.total > state.maxBodyBytes) { + fail(state, "response_too_large", `body exceeds ${state.maxBodyBytes} bytes`); + break; + } + state.chunks.push(value); + state.queued += value.byteLength; + state.dirty = true; // new bytes: announce `readable` at the next tick + } + } catch (error) { + if (!state.terminal) fail(state, state.timedOut ? "timeout" : "closed", error instanceof Error ? error.message : String(error)); + } finally { + try { + reader.releaseLock(); + } catch { + // already released + } + } + } + const ns = { start(metaJson, bodyBuffer) { let meta; @@ -118,88 +227,92 @@ export function createNetHost(nativeFetch = globalThis.fetch.bind(globalThis)) { } catch { return refuse("invalid_request", "malformed request metadata"); } - if (!meta || typeof meta !== "object" || !(bodyBuffer instanceof ArrayBuffer)) { + if (!meta || typeof meta !== "object" || (bodyBuffer !== null && !(bodyBuffer instanceof ArrayBuffer))) { return refuse("invalid_request", "malformed request metadata or body"); } - const body = new Uint8Array(bodyBuffer).slice(); - if (pending.size >= MAX_INFLIGHT) return refuse("busy", "at most 2 requests may be in flight"); + const body = bodyBuffer ? new Uint8Array(bodyBuffer).slice() : new Uint8Array(0); + if (inflight() >= MAX_INFLIGHT) return refuse("resource_limit", `at most ${MAX_INFLIGHT} requests may be in flight`); if (typeof meta.url !== "string" || !/^https?:\/\/[^\s/]+(?:\/|$)/.test(meta.url)) { return refuse("invalid_request", "url must be absolute HTTP(S)"); } - if (!METHODS.has(meta.method)) return refuse("invalid_request", "unsupported method"); + if (typeof meta.method !== "string" || !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(meta.method) || FORBIDDEN_METHODS.has(meta.method.toUpperCase())) { + return refuse("invalid_request", "unsupported method"); + } if ((meta.method === "GET" || meta.method === "HEAD") && body.byteLength) { return refuse("invalid_request", `${meta.method} cannot have a body`); } - if (body.byteLength > MAX_REQUEST_BYTES) return refuse("invalid_request", "request body too large"); - if (!Number.isInteger(meta.timeoutMs) || meta.timeoutMs < 1 || meta.timeoutMs > MAX_TIMEOUT_MS) { - return refuse("invalid_request", "invalid timeoutMs"); - } - if (!Number.isInteger(meta.maxBytes) || meta.maxBytes < 1 || meta.maxBytes > MAX_RESPONSE_BYTES) { - return refuse("invalid_request", "invalid maxBytes"); - } + if (body.byteLength > MAX_REQUEST_BYTES) return refuse("resource_limit", "request body too large"); if (!meta.headers || typeof meta.headers !== "object" || !validHeaders(meta.headers)) { return refuse("invalid_request", "invalid headers"); } - + const timeouts = meta.timeouts && typeof meta.timeouts === "object" ? meta.timeouts : {}; + const connectMs = timeoutValue(timeouts.connectMs, DEFAULT_TIMEOUT_MS); + const headersMs = timeoutValue(timeouts.headersMs, DEFAULT_TIMEOUT_MS); + const idleMs = timeoutValue(timeouts.idleMs, DEFAULT_TIMEOUT_MS); + const totalMs = timeoutValue(timeouts.totalMs, MAX_TIMEOUT_MS); + if (connectMs === null || headersMs === null || idleMs === null || totalMs === null) { + return refuse("invalid_request", "invalid timeouts"); + } + const queueBytes = meta.queueBytes === undefined ? DEFAULT_QUEUE_BYTES : meta.queueBytes; + if (!Number.isInteger(queueBytes) || queueBytes < 1 || queueBytes > MAX_QUEUE_BYTES) { + return refuse("invalid_request", "invalid queueBytes"); + } + if (meta.tls && meta.tls.verification === "development-insecure") { + return refuse("unsupported", "the browser owns TLS verification"); + } const handle = nextHandle++; - const controller = new AbortController(); - pending.set(handle, controller); - let timedOut = false; - const timer = setTimeout(() => { - timedOut = true; - controller.abort(); - }, meta.timeoutMs); - const request = { ...meta, body }; - void followBounded(nativeFetch, request, controller.signal) - .then(async (response) => { - const headers = Object.create(null); - response.headers.forEach((value, name) => { - headers[name.toLowerCase()] = value; - }); - if (!validHeaders(headers)) throw new Error("response_headers"); - const responseBody = await readBounded(response, meta.maxBytes); - if (!pending.has(handle)) return; - bodies.set(handle, responseBody); - completed.push({ - t: "done", - h: handle, - status: response.status, - url: response.url || meta.url, - headers, - bytes: responseBody.byteLength, - }); - }) - .catch((error) => { - if (!pending.has(handle)) return; - const message = error instanceof Error ? error.message : String(error); - const mapped = message === "response_too_large" - ? { code: "response_too_large", message: `response exceeded ${meta.maxBytes} bytes` } - : message.startsWith("redirect_") - ? { code: "redirect", message } - : message === "response_headers" - ? { code: "protocol", message: "response headers exceed limits" } - : failure(error, timedOut); - completed.push({ t: "error", h: handle, ...mapped }); - }) - .finally(() => { - clearTimeout(timer); - pending.delete(handle); - }); + const state = { + handle, + controller: new AbortController(), + timedOut: false, + terminal: false, + ended: false, + headSent: false, + queueBytes, + maxBodyBytes: Number.isInteger(meta.maxBodyBytes) ? meta.maxBodyBytes : Number.POSITIVE_INFINITY, + chunks: [], + queued: 0, + visibleBytes: 0, + total: 0, + dirty: false, + wake: null, + reader: null, + timers: [], + headersTimer: null, + idleTimer: null, + armIdle() { + clearTimeout(this.idleTimer); + this.idleTimer = setTimeout(() => { + this.timedOut = true; + fail(this, "timeout", "body idle timeout"); + }, idleMs); + this.timers.push(this.idleTimer); + }, + }; + state.headersTimer = setTimeout(() => { + state.timedOut = true; + fail(state, "timeout", "response headers timeout"); + }, Math.min(connectMs + headersMs, totalMs)); + state.timers.push(state.headersTimer); + state.timers.push( + setTimeout(() => { + state.timedOut = true; + fail(state, "timeout", "total timeout"); + }, totalMs), + ); + states.set(handle, state); + void run(state, meta, body); return handle; }, - take(handle, into) { - const body = bodies.get(handle); - if (!body || into.byteLength !== body.byteLength) return -1; - new Uint8Array(into).set(body); - bodies.delete(handle); - return body.byteLength; - }, cancel(handle) { - pending.get(handle)?.abort(); - pending.delete(handle); - bodies.delete(handle); - for (let i = completed.length - 1; i >= 0; i--) if (completed[i].h === handle) completed.splice(i, 1); - for (let i = visible.length - 1; i >= 0; i--) if (visible[i].h === handle) visible.splice(i, 1); + const state = states.get(handle); + if (!state) return; + if (state.ended) { + // Ended handle with unread bytes: release them, no further event. + states.delete(handle); + return; + } + fail(state, "cancelled", "cancelled"); }, poll() { return visible.length ? JSON.stringify(visible.splice(0)) : undefined; @@ -207,16 +320,60 @@ export function createNetHost(nativeFetch = globalThis.fetch.bind(globalThis)) { lastError() { return lastError; }, + readInto(handle, into, offset, length) { + const state = states.get(handle); + if (!state || !state.headSent) return -1; + const dest = new Uint8Array(into, offset, length); + let copied = 0; + const budget = Math.min(dest.byteLength, state.visibleBytes); + while (state.chunks.length && copied < budget) { + const head = state.chunks[0]; + const n = Math.min(head.byteLength, budget - copied); + dest.set(head.subarray(0, n), copied); + copied += n; + if (n === head.byteLength) state.chunks.shift(); + else state.chunks[0] = head.subarray(n); + } + state.visibleBytes -= copied; + state.queued -= copied; + if (state.wake && state.queued < state.queueBytes) { + const wake = state.wake; + state.wake = null; + wake(); + } + if (state.ended && state.queued === 0) states.delete(handle); + return copied; + }, + limits() { + return JSON.stringify(LIMITS); + }, }; return { ns, beginFrame() { - visible.push(...completed.splice(0)); + // Freeze the readable watermark of every handle with new bytes. + for (const state of states.values()) { + if (state.dirty && state.headSent) { + state.dirty = false; + state.visibleBytes = state.queued; + completed.push({ t: "readable", h: state.handle, avail: state.visibleBytes }); + } + } + // `end` must follow the readable that announced the final bytes: the + // reader loop pushed `end` before beginFrame() ran, so hoist readable + // events ahead of their handle's `end`. + const ends = completed.filter((e) => e.t === "end"); + const rest = completed.filter((e) => e.t !== "end"); + visible.push(...rest, ...ends); + completed.length = 0; }, reset() { - for (const handle of [...pending.keys()]) ns.cancel(handle); - bodies.clear(); + for (const handle of [...states.keys()]) { + const state = states.get(handle); + if (state && !state.terminal) fail(state, "cancelled", "reset"); + states.delete(handle); + } completed.length = 0; visible.length = 0; }, diff --git a/package.json b/package.json index 6841920b..1abbf8a6 100644 --- a/package.json +++ b/package.json @@ -148,7 +148,10 @@ "./kinetics": "./framework/src/kinetics.ts", "./launcher": "./framework/src/launcher.ts", "./manifest": "./framework/src/manifest/index.ts", - "./net": "./framework/src/net-api.ts", + "./headless": "./framework/src/headless.ts", + "./net": "./framework/src/net/index.ts", + "./net/http": "./framework/src/net/http.ts", + "./net/websocket": "./framework/src/net/websocket.ts", "./osk": "./framework/src/osk.tsx", "./package": "./contracts/spec/pocket-package.ts", "./platform": "./framework/src/platform.ts", @@ -170,7 +173,10 @@ "./vue-vapor/fs": "./framework/src/fs-api.ts", "./vue-vapor/lifecycle": "./framework/src/lifecycle-vue-vapor.ts", "./vue-vapor/input": "./framework/src/input-api.ts", - "./vue-vapor/net": "./framework/src/net-api.ts", + "./vue-vapor/headless": "./framework/src/headless.ts", + "./vue-vapor/net": "./framework/src/net/index.ts", + "./vue-vapor/net/http": "./framework/src/net/http.ts", + "./vue-vapor/net/websocket": "./framework/src/net/websocket.ts", "./vue-vapor/renderer": "./framework/src/renderer-vue-vapor.ts", "./octane": "./framework/src/index-octane.ts", "./octane/animation": "./framework/src/animation.ts", @@ -182,7 +188,10 @@ "./octane/fs": "./framework/src/fs-api.ts", "./octane/lifecycle": "./framework/src/lifecycle-octane.ts", "./octane/input": "./framework/src/input-api.ts", - "./octane/net": "./framework/src/net-api.ts", + "./octane/headless": "./framework/src/headless.ts", + "./octane/net": "./framework/src/net/index.ts", + "./octane/net/http": "./framework/src/net/http.ts", + "./octane/net/websocket": "./framework/src/net/websocket.ts", "./octane/renderer": "./framework/src/renderer-octane.ts" }, "description": "High-performance JSX UI outside the browser, with native rendering, standard Vue Vapor and Solid support, a Tailwind design system, and 60 FPS animation under an 8 MB memory budget.", @@ -234,7 +243,7 @@ "devtools:psp": "bun tools/devtools-psp.ts", "test:tailwind": "bun test tests/tailwind.test.ts", "contract": "bun tests/contract.ts", - "gen": "bun contracts/spec/gen-rust.ts && bun tools/gen-exports.ts", + "gen": "bun contracts/spec/gen-rust.ts && bun contracts/spec/gen-c.ts && bun tools/gen-exports.ts", "vapor:gb": "bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx --target gb", "vapor:nes": "bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx --target nes", "vapor:esp32": "bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx --target esp32", diff --git a/site/content/docs/concepts.md b/site/content/docs/concepts.md index f5525799..7b4736d0 100644 --- a/site/content/docs/concepts.md +++ b/site/content/docs/concepts.md @@ -16,7 +16,7 @@ Runtime = Host + mounted Modules + Guest ┌────────────────────────── Runtime ──────────────────────────┐ │ Guest product code (QuickJS bundle / wasm host eval) │ │ ───────── one namespace per mounted module ───────────── │ -│ Modules ui · audio · db · fs · net · strike │ +│ Modules ui · audio · db · fs · net/ws/httpd · strike │ │ core+spec, one per module │ │ Substrate pocket3d · platform drivers (no guest API) │ │ Host PSP EBOOT · Vita · browser · headless sim │ @@ -46,8 +46,9 @@ The **core** owns the domain's state and its clock; per-entity, per-frame work happens only there, and the core never calls into the guest. The **SDK** is ordinary guest code shaped for its domain — JSX components for `ui`, `decodeWav` and a `WavPlayer` for `audio`, a `Database` with prepared -statements for `db`, `file()` and the node:fs sync subset for `fs`, `fetch` -and buffered responses for `net`, a mod API for OpenStrike's `strike`. The +statements for `db`, `file()` and the node:fs sync subset for `fs`, `fetch`, +`serve` and `connect` over streaming bodies for the network modules, a mod +API for OpenStrike's `strike`. The two sides can be replaced independently because the **spec** between them does not move: swap Solid for Vue Vapor, or rewrite the layout engine, and the other side cannot tell. @@ -55,8 +56,9 @@ the other side cannot tell. `ui` (pocketjs-core + the `ui.*` ops + the JSX SDK) was the first module. `strike` was the second. `audio` — credit-based PCM streaming — is the third, and the first written spec-first: the protocol existed before any -host implemented it. `net` applies the same shape to bounded HTTP while -leaving sockets, TLS, and the concrete client library in each host. +host implemented it. The network modules (`net`, `httpd`, `ws`) apply the +same shape to HTTP and WebSocket: one spec per role, a core that owns the +wire and the limits, and hosts that mount only the roles they admit. ## Spec @@ -109,7 +111,7 @@ assembly: | PSP UI runtime | PSP EBOOT | `ui` + `audio` | any PocketJS app | | Music demo in the browser | browser dev host | `ui` + `audio` | `apps/music` | | OpenStrike | its own Rust bin | `strike` + `ui` (HUD) | round rules, weapons, bots — all JS | -| Headless CI | Bun sim | `ui` + virtual `audio` + fixture `net` | the same bundles, byte-for-byte | +| Headless CI | Bun sim | `ui` + virtual `audio` + fixture `net`/`httpd`/`ws` | the same bundles, byte-for-byte | ## The three laws @@ -160,5 +162,5 @@ framework, and the app did not change. A new domain — networking, haptics, a camera — lands the same way: write the spec, build the core against it, mount it in a host, ship the SDK with a headless test. -The NET module is the networking instance of this rule. Its API and host -adapter boundary are documented in [NET module](/docs/net/). +The network modules are the networking instance of this rule. Their APIs +and host boundaries are documented in [Networking](/docs/net/). diff --git a/site/content/docs/net.md b/site/content/docs/net.md index 17097780..67bbc36c 100644 --- a/site/content/docs/net.md +++ b/site/content/docs/net.md @@ -1,90 +1,114 @@ # Networking -PocketJS provides a small, bounded HTTP client through the NET module. It is -fetch-shaped without importing the browser's complete networking stack. +PocketJS networking is a set of explicitly imported modules: an HTTP client +and server in `@pocketjs/framework/net/http`, a WebSocket client in +`@pocketjs/framework/net/websocket`, and the shared support types in +`@pocketjs/framework/net`. Each module sits on its own spec-pinned guest +boundary (`globalThis.net`, `globalThis.httpd`, `globalThis.ws`) that a host +mounts only when it ships the capability. ```ts -import { fetch } from "@pocketjs/framework/net"; +import { fetch, serve, Response } from "@pocketjs/framework/net/http"; +import { connect } from "@pocketjs/framework/net/websocket"; +import { AbortController, NetworkError, URL } from "@pocketjs/framework/net"; -const response = await fetch("https://api.example.com/items", { +const controller = new AbortController(); +const response = await fetch("http://api.example.test/items", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "Pocket" }), - timeoutMs: 5_000, - maxBytes: 64 * 1024, + timeouts: { headersMs: 5_000 }, + signal: controller.signal, }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); -const data = await response.json(); -``` - -The public surface includes common application methods, string and byte -request bodies, headers, a timeout, a response-size budget, and buffered -`text()`, `json()`, `bytes()` and `arrayBuffer()` reads. It deliberately omits -streams, cookies, cache, `Request`, `Headers`, `AbortSignal`, WebSocket, -servers, and raw sockets. - -## Why responses are buffered - -The first version resolves `fetch` only after the body is complete. The -native transport still reads chunks and stops as soon as `maxBytes` is -exceeded; the transport-neutral core checks the final size again. This keeps -the JS API and every embedded adapter small without allowing an unbounded -response into memory. - -The default body budget is 128 KiB and the absolute maximum is 256 KiB. A -request body is limited to 64 KiB. Media downloads and other payloads that -fundamentally require streaming are not NET v1 use cases. - -## Tick delivery and polling +const items = await response.json(); -Network work may happen on native threads, but those threads never call the -guest. The host drains completions at a tick boundary, then the framework -settles fetch Promises in the guest's normal turn. - -There is no idle native poll. The first pending fetch registers a small -framework-neutral service pump and the final completion removes it. While -requests are pending the SDK calls `net.poll()` once per guest tick; that one -call returns the entire visible completion batch. - -## What belongs where - -| Layer | Artifact | Responsibility | -| --- | --- | --- | -| SDK | `framework/src/net-api.ts` | fetch-shaped guest API and Promise delivery | -| Spec | `contracts/spec/net.ts` | ops, events, limits, errors, ownership and tick contract | -| Core | `engine/crates/pocket-net` | handles, validation, bodies and a transport interface | -| Sim | `hosts/sim/net.ts` | deterministic fixture routes | -| Browser host | `hosts/web/net.js` | bounded adapter over browser fetch | -| Host adapter | the owning runtime | DNS, TLS, HTTP client library, workers and credentials | +const server = await serve({ + hostname: "0.0.0.0", + port: 8080, + fetch: (request) => Response.json({ path: new URL(request.url).pathname }), +}); -PocketJS does not force one HTTP library on every platform. A desktop host can -adapt `ureq`, an ESP host can adapt `esp_http_client`, and an Apple host can -adapt `URLSession`. A product-specific runtime keeps that adapter in its own -repository. Only adapters for hosts owned and tested by PocketJS belong under -this repository's `hosts/` directory. +const socket = await connect("ws://broker.example.test/telemetry", { + protocols: ["telemetry.v1"], + socket: { + message(socket, data) { socket.send(data); }, + close(_socket, code) { console.log("closed", code); }, + }, +}); +``` -The Rust boundary is deliberately only `start`, `cancel`, and non-blocking -`drain`. The reference core supplies every portable rule around it, so -changing an HTTP library cannot change what guest code observes. +The objects follow the WHATWG Fetch shapes (`Headers`, `Request`, +`Response`, `RequestInit` with `method/headers/body/signal/redirect` plus the +PocketJS `timeouts/maxRedirects/tls/limits`) with two deliberate deviations: +body locking, repeat consumption and detached input fail with a +`NetworkError`, and every network, permission, timeout and resource failure +is a `NetworkError` too. HTTP status codes do not reject: a 404 resolves with +`ok === false`. + +## Streaming bodies + +`fetch()` resolves when the response head is visible; the body streams +through `response.body`, a `BodyStream` that supports `for await`, +`readInto(destination)` and `cancel()`. `text()`, `json()` and +`arrayBuffer()` aggregate the same stream and reject with +`response_too_large` past their cap. Bytes wait in a bounded native queue +until the application reads them; **when the queue is full the host stops +reading the socket and TCP flow control holds the peer**, so a slow reader +never grows memory. `clone()` creates a bounded tee — cancel the branch you +do not read. + +## When results arrive + +Network completions reach the guest only at frame boundaries. The host +freezes the visible set before each `frame()`, the framework's service pump +polls each module once inside `frame()`, and Promise reactions run in the +same tick's job drain. **A network round trip therefore reaches application +code within one frame period** (16.7 ms at 60 Hz), and the order of events +is the same on every host and in a replay. + +## Capabilities and permissions + +Importing a module grants nothing. Capabilities are split by protocol, role +and TLS — `network.http.client`, `network.http.client.tls`, +`network.http.server`, `network.websocket.client`, … — and the host holds an +immutable policy of allowed endpoints (`connect` rules with host, port and +protocol; `listen` rules with address and port; `insecureTransport`; +`localNetwork`) that every command is checked against. No stock target +advertises a network capability yet; a target advertises one only when its +native host ships and tests the module. + +## Errors + +`NetworkError` carries a stable `code` and a derived `category`: + +| Category | Codes | +| --- | --- | +| runtime | `cancelled` `timeout` `closed` `invalid_request` `invalid_state` `busy` `resource_limit` `unsupported` `permission_denied` `unavailable` | +| resolver | `dns` | +| transport | `connect` `address_in_use` | +| tls | `tls_certificate_invalid` `tls_hostname_mismatch` `tls_handshake_failed` `tls_clock_untrusted` | +| protocol | `redirect` `response_too_large` `protocol` `websocket_handshake_failed` `websocket_protocol_error` `message_too_large` | ## Limits -| Resource | V1 limit | -| --- | ---: | -| Concurrent requests | 2 | -| Request body | 64 KiB | -| Response body | 128 KiB default, 256 KiB maximum | -| Headers | 32 fields / 8 KiB | -| Timeout | 30 s default, 120 s maximum | -| Redirects | 3 | - -Supported methods are `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`, and -`OPTIONS`. `CONNECT` and `TRACE` have tunnel, proxy, and security semantics -that do not belong in an application fetch primitive. A closed method set also -means every host can make the same guarantee. - -Transport failures reject with `NetError` and a portable `code` such as -`dns`, `connect`, `tls`, `timeout`, or `response_too_large`. HTTP status codes -do not reject: a 404 response resolves with `ok === false`, like browser -fetch. +`getNetworkLimits()` returns a frozen snapshot of the mounted modules' +effective limits (spec ceilings tightened by the host): concurrent handles, +request-body cap, receive-queue defaults and maxima, aggregate caps, +per-tick event/byte budgets, header limits, timeouts, redirects and TLS +features. Applications choose chunk and queue sizes from it; they cannot +raise a limit. + +## Where the pieces live + +| Layer | Artifact | +| --- | --- | +| SDK | `framework/src/net/*` | +| Specs | `contracts/spec/net.ts`, `contracts/spec/ws.ts`, `contracts/spec/httpd.ts` | +| Reference cores | `engine/net` (portable C: HTTP client/server, WebSocket client, BSD/lwIP driver), `engine/crates/pocket-net` (Rust HTTP client core over `HttpClientBackend`) | +| Deterministic hosts | `hosts/sim/net.ts`, `hosts/sim/httpd.ts`, `hosts/sim/ws.ts` | +| Browser host | `hosts/web/net.js` | +| ESP-IDF host | `hosts/esp-idf` (AtomS3R, Tab5) | + +The pinned boundaries are `contracts/spec/net.ts`, `contracts/spec/ws.ts` +and `contracts/spec/httpd.ts`; the engineering summary is `docs/NET.md`. diff --git a/tests/contract.ts b/tests/contract.ts index d133b93d..7473cdcd 100644 --- a/tests/contract.ts +++ b/tests/contract.ts @@ -9,6 +9,7 @@ // (framework/compiler/subpaths.ts) and byte-compares: the npm surface // can never drift from the one declaration. Fix = `bun tools/gen-exports.ts`. +import { generateC } from "../contracts/spec/gen-c.ts"; import { generateRust } from "../contracts/spec/gen-rust.ts"; import { withGeneratedExports } from "../tools/gen-exports.ts"; import { @@ -47,6 +48,16 @@ check( "run `bun contracts/spec/gen-rust.ts` and commit the result", ); +// ---- (a2) generated network spec.h is in sync ------------------------------ + +const specHPath = new URL("../engine/net/include/pocketjs/net/spec.h", import.meta.url).pathname; +const committedH = await Bun.file(specHPath).text().catch(() => null); +check( + committedH !== null && committedH === generateC(), + "engine/net/include/pocketjs/net/spec.h matches contracts/spec/{net,ws,httpd}.ts", + "run `bun contracts/spec/gen-c.ts` and commit the result", +); + // ---- (c) package.json exports match the subpath registry --------------------- const pkgPath = new URL("../package.json", import.meta.url).pathname; diff --git a/tests/net-httpd.test.ts b/tests/net-httpd.test.ts new file mode 100644 index 00000000..93dfc2ed --- /dev/null +++ b/tests/net-httpd.test.ts @@ -0,0 +1,208 @@ +// HTTP Server SDK (`serve()` in `@pocketjs/framework/net/http`) + deterministic +// sim host (hosts/sim/httpd.ts): listen/stop lifecycle, request delivery in +// the service pump, streaming request bodies, one-shot and streamed responses +// through respond/write/endBody with drain, error handler fallbacks and +// aborted requests. + +import { afterEach, describe, expect, test } from "bun:test"; + +import { NET_ERROR } from "../contracts/spec/net.ts"; +import { Response, serve, type HttpdOps } from "../framework/src/net/http.ts"; +import { NetworkError } from "../framework/src/net/index.ts"; +import { runServicePumps } from "../framework/src/services.ts"; +import { createSimHttpdHost } from "../hosts/sim/httpd.ts"; + +function mount(ns: HttpdOps): void { + (globalThis as { httpd?: HttpdOps }).httpd = ns; +} + +afterEach(() => { + delete (globalThis as { httpd?: HttpdOps }).httpd; +}); + +async function ticks(host: { tick(): void }, n = 1): Promise { + for (let i = 0; i < n; i++) { + host.tick(); + runServicePumps(); + for (let j = 0; j < 12; j++) await Promise.resolve(); + } +} + +describe("httpd SDK + deterministic sim host", () => { + test("serve resolves on listening; handlers answer in the same tick", async () => { + const host = createSimHttpdHost(); + mount(host.ns); + const seen: string[] = []; + const listening = serve({ + hostname: "0.0.0.0", + port: 8080, + fetch(request) { + seen.push(`${request.method} ${new URL(request.url).pathname}`); + return new Response("hello", { headers: { "x-served": "1" } }); + }, + }); + let settled = false; + listening.then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + await ticks(host); + const server = await listening; + expect(server.port).toBe(8080); + expect(server.url).toBe("http://0.0.0.0:8080/"); + + const injected = host.inject({ method: "GET", target: "/hello?x=1" }); + await ticks(host); + expect(seen).toEqual(["GET /hello"]); + expect(injected.responded).toBe(true); + expect(injected.complete).toBe(true); + expect(injected.status).toBe(200); + expect(injected.headers["x-served"]).toBe("1"); + expect(injected.headers["content-type"]).toBe("text/plain;charset=UTF-8"); + expect(injected.text()).toBe("hello"); + expect(host.live()).toBe(0); + + const stopped = server.stop({ graceful: true, timeout: 100 }); + await ticks(host); + await stopped; + expect(host.log.at(-1)).toBe("stop 1 true 100"); + }); + + test("request bodies stream through readInto; async handlers respond later", async () => { + const host = createSimHttpdHost(); + mount(host.ns); + const listening = serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + const text = await request.text(); + return Response.json({ echo: text, len: request.headers.get("content-length") }); + }, + }); + await ticks(host); + const server = await listening; + expect(server.port).toBeGreaterThanOrEqual(40000); + const injected = host.inject({ method: "POST", target: "/echo", body: ["ab", "cd", "ef"], chunkTicks: 1 }); + await ticks(host, 5); + expect(injected.complete).toBe(true); + expect(JSON.parse(injected.text())).toEqual({ echo: "abcdef", len: "6" }); + }); + + test("streamed responses use respond(end=false) + write + endBody and honour drain", async () => { + const host = createSimHttpdHost(); + host.sendQueueBytes = 4; + mount(host.ns); + async function* chunks(): AsyncGenerator { + yield new Uint8Array([1, 2, 3]); + yield new Uint8Array([4, 5, 6, 7]); + } + const listening = serve({ + hostname: "127.0.0.1", + port: 9000, + fetch() { + return new Response(chunks() as unknown as AsyncIterable, { status: 200 }); + }, + }); + await ticks(host); + await listening; + const injected = host.inject({ target: "/stream" }); + await ticks(host, 6); + expect(injected.complete).toBe(true); + expect([...injected.body()]).toEqual([1, 2, 3, 4, 5, 6, 7]); + expect(host.log.filter((l) => l.startsWith("write")).length).toBe(2); + expect(host.log.some((l) => l.startsWith("endBody"))).toBe(true); + }); + + test("a one-shot body that does not fit the send queue falls back to streaming", async () => { + const host = createSimHttpdHost(); + host.sendQueueBytes = 5; + mount(host.ns); + const listening = serve({ + hostname: "127.0.0.1", + port: 9001, + fetch() { + return new Response("0123456789"); + }, + }); + await ticks(host); + await listening; + const injected = host.inject({ target: "/big" }); + await ticks(host, 16); + expect(injected.complete).toBe(true); + expect(injected.contentLength).toBe(10); + expect(injected.text()).toBe("0123456789"); + }); + + test("handler failures go through error(); its failure yields a fixed 500", async () => { + const host = createSimHttpdHost(); + mount(host.ns); + let mode: "handled" | "unhandled" = "handled"; + const listening = serve({ + hostname: "127.0.0.1", + port: 9002, + fetch() { + throw new Error("boom"); + }, + error(error) { + if (mode === "unhandled") throw error; + return new Response("recovered", { status: 502 }); + }, + }); + await ticks(host); + await listening; + const first = host.inject({ target: "/a" }); + await ticks(host, 2); + expect(first.status).toBe(502); + expect(first.text()).toBe("recovered"); + mode = "unhandled"; + const second = host.inject({ target: "/b" }); + await ticks(host, 2); + expect(second.status).toBe(500); + expect(second.text()).toBe(""); + }); + + test("peer disconnect aborts the request signal; late responses are dropped", async () => { + const host = createSimHttpdHost(); + mount(host.ns); + let resolveLater: ((r: Response) => void) | null = null; + let aborted = false; + const listening = serve({ + hostname: "127.0.0.1", + port: 9003, + fetch(request) { + request.signal.addEventListener("abort", () => { + aborted = true; + }); + return new Promise((resolve) => { + resolveLater = resolve; + }); + }, + }); + await ticks(host); + await listening; + const injected = host.inject({ target: "/slow" }); + await ticks(host); + expect(resolveLater).not.toBeNull(); + injected.disconnect(); + await ticks(host); + expect(aborted).toBe(true); + expect(injected.aborted).toBe(NET_ERROR.closed); + resolveLater!(new Response("too late")); + await ticks(host); + expect(injected.responded).toBe(false); + expect(host.live()).toBe(0); + }); + + test("synchronous refusals and a missing namespace reject", async () => { + await expect(serve({ hostname: "127.0.0.1", port: 1, fetch: () => new Response("x") })).rejects.toMatchObject({ + code: NET_ERROR.unavailable, + }); + const host = createSimHttpdHost(); + mount(host.ns); + await expect( + serve({ hostname: "127.0.0.1", port: 443, tls: { credential: "c" }, fetch: () => new Response("x") }), + ).rejects.toMatchObject({ code: NET_ERROR.unsupported }); + await expect(serve({ hostname: "127.0.0.1", port: 70000, fetch: () => new Response("x") })).rejects.toBeInstanceOf(NetworkError); + }); +}); diff --git a/tests/net-web.test.js b/tests/net-web.test.js index d8c259b7..b16f37be 100644 --- a/tests/net-web.test.js +++ b/tests/net-web.test.js @@ -1,9 +1,13 @@ import { expect, test } from "bun:test"; -import { fetch as pocketFetch } from "../framework/src/net-api.ts"; +import { fetch as pocketFetch } from "../framework/src/net/http.ts"; import { runServicePumps } from "../framework/src/services.ts"; import { createNetHost } from "../hosts/web/net.js"; +async function settle() { + for (let i = 0; i < 8; i++) await Promise.resolve(); +} + test("browser net adapter uses native fetch but delivers only at beginFrame", async () => { const calls = []; const host = createNetHost(async (url, options) => { @@ -18,12 +22,11 @@ test("browser net adapter uses native fetch but delivers only at beginFrame", as let settled = false; const promise = pocketFetch("https://example.test/web", { headers: { "x-test": "1" }, - maxBytes: 64, }).then((response) => { settled = true; return response; }); - await Bun.sleep(0); + await Bun.sleep(5); runServicePumps(); await Promise.resolve(); expect(settled).toBe(false); @@ -31,25 +34,60 @@ test("browser net adapter uses native fetch but delivers only at beginFrame", as host.beginFrame(); runServicePumps(); const response = await promise; - expect(await response.text()).toBe("web transport"); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/plain"); + const text = response.text(); + await Bun.sleep(5); + host.beginFrame(); + runServicePumps(); + await settle(); + host.beginFrame(); + runServicePumps(); + expect(await text).toBe("web transport"); expect(calls).toHaveLength(1); expect(calls[0].options.credentials).toBe("omit"); expect(calls[0].options.redirect).toBe("manual"); + expect(calls[0].options.headers["x-test"]).toBe("1"); } finally { host.reset(); delete globalThis.net; } }); -test("browser net adapter enforces response maxBytes while reading", async () => { +test("browser net adapter enforces maxBodyBytes while reading", async () => { const host = createNetHost(async () => new Response("12345")); globalThis.net = host.ns; try { - const promise = pocketFetch("https://example.test/large", { maxBytes: 4 }); - await Bun.sleep(0); + const promise = pocketFetch("https://example.test/large", { limits: { maxBodyBytes: 4 } }); + await Bun.sleep(5); + host.beginFrame(); + runServicePumps(); + const response = await promise; + const outcome = response.text().catch((error) => error); + await Bun.sleep(5); + host.beginFrame(); + runServicePumps(); + await settle(); + expect(await outcome).toMatchObject({ code: "response_too_large" }); + } finally { + host.reset(); + delete globalThis.net; + } +}); + +test("browser net adapter maps hidden redirects to unsupported", async () => { + const host = createNetHost(async () => { + const response = new Response(null, { status: 302, headers: { location: "https://elsewhere.test/" } }); + Object.defineProperty(response, "type", { value: "opaqueredirect" }); + return response; + }); + globalThis.net = host.ns; + try { + const promise = pocketFetch("https://example.test/redirect"); + await Bun.sleep(5); host.beginFrame(); runServicePumps(); - await expect(promise).rejects.toMatchObject({ code: "response_too_large" }); + await expect(promise).rejects.toMatchObject({ code: "unsupported" }); } finally { host.reset(); delete globalThis.net; diff --git a/tests/net-websocket.test.ts b/tests/net-websocket.test.ts new file mode 100644 index 00000000..f1097041 --- /dev/null +++ b/tests/net-websocket.test.ts @@ -0,0 +1,232 @@ +// WebSocket Client SDK (`@pocketjs/framework/net/websocket`) + deterministic +// sim host (hosts/sim/ws.ts): handshake delivery order, text/binary messages, +// control frames, backpressure/drain, close handshake, terminate, handshake +// failures and synchronous refusals. + +import { afterEach, describe, expect, test } from "bun:test"; + +import { NET_ERROR } from "../contracts/spec/net.ts"; +import { NetworkError } from "../framework/src/net/index.ts"; +import { connect, type WebSocket, type WsOps } from "../framework/src/net/websocket.ts"; +import { runServicePumps } from "../framework/src/services.ts"; +import { createSimWsHost } from "../hosts/sim/ws.ts"; + +function mount(ns: WsOps): void { + (globalThis as { ws?: WsOps }).ws = ns; +} + +afterEach(() => { + delete (globalThis as { ws?: WsOps }).ws; +}); + +async function ticks(host: { tick(): void }, n = 1): Promise { + for (let i = 0; i < n; i++) { + host.tick(); + runServicePumps(); + for (let j = 0; j < 8; j++) await Promise.resolve(); + } +} + +describe("websocket SDK + deterministic sim host", () => { + test("open runs readyState → open handler → resolve, in that order", async () => { + const host = createSimWsHost({ "ws://echo.test/socket": { protocol: "telemetry.v1" } }); + mount(host.ns); + const order: string[] = []; + let opened: WebSocket | null = null; + const promise = connect("ws://echo.test/socket", { + protocols: ["telemetry.v1", "other"], + socket: { + open(socket) { + order.push(`open:${socket.readyState}:${socket.protocol}`); + opened = socket; + }, + }, + }).then((socket) => { + order.push("resolved"); + return socket; + }); + await Promise.resolve(); + expect(order).toEqual([]); + await ticks(host); + const socket = await promise; + expect(socket).toBe(opened!); + expect(order).toEqual(["open:open:telemetry.v1", "resolved"]); + expect(socket.url).toBe("ws://echo.test/socket"); + expect(socket.readyState).toBe("open"); + expect(socket.bufferedAmount).toBe(0); + }); + + test("text and binary messages round-trip; binary arrives as an owned Uint8Array", async () => { + const host = createSimWsHost({ "ws://echo.test/socket": {} }); + mount(host.ns); + const received: (string | Uint8Array)[] = []; + const promise = connect("ws://echo.test/socket", { + socket: { + message(_socket, data) { + received.push(data); + }, + }, + }); + await ticks(host); + const socket = await promise; + expect(socket.send("héllo")).toEqual({ status: "accepted", needsDrain: false }); + const payload = new Uint8Array([1, 2, 3]); + expect(socket.send(payload)).toEqual({ status: "accepted", needsDrain: false }); + payload[0] = 9; // snapshot at send() + await ticks(host); + expect(received.length).toBe(2); + expect(received[0]).toBe("héllo"); + expect([...(received[1] as Uint8Array)]).toEqual([1, 2, 3]); + expect(host.log).toContain("send 1 text 6"); + }); + + test("ping/pong control frames and the 125-byte cap", async () => { + const host = createSimWsHost({ "ws://echo.test/socket": {} }); + mount(host.ns); + const pongs: number[] = []; + const pings: number[] = []; + const promise = connect("ws://echo.test/socket", { + socket: { + pong(_s, data) { + pongs.push(data.length); + }, + ping(_s, data) { + pings.push(data.length); + }, + }, + }); + await ticks(host); + const socket = await promise; + expect(socket.ping(new Uint8Array(3))).toBe(true); + expect(() => socket.ping(new Uint8Array(126))).toThrow(NetworkError); + host.peer("ws://echo.test/socket").ping(new Uint8Array(2)); + await ticks(host); + expect(pongs).toEqual([3]); + expect(pings).toEqual([2]); + }); + + test("backpressure returns without accepting; drain fires once", async () => { + const host = createSimWsHost({ "ws://echo.test/socket": { sendWindowBytes: 4, onMessage: () => undefined } }); + mount(host.ns); + let drains = 0; + const promise = connect("ws://echo.test/socket", { + socket: { + drain() { + drains++; + }, + }, + }); + await ticks(host); + const socket = await promise; + expect(socket.send("abc")).toEqual({ status: "accepted", needsDrain: false }); + expect(socket.bufferedAmount).toBe(3); + expect(socket.send("de")).toEqual({ status: "backpressure" }); + await ticks(host); + expect(drains).toBe(1); + expect(socket.bufferedAmount).toBe(0); + expect(socket.send("de")).toEqual({ status: "accepted", needsDrain: false }); + await ticks(host); + expect(drains).toBe(1); // not re-armed + }); + + test("close handshake: closing → close handler with the peer's code", async () => { + const host = createSimWsHost({ "ws://echo.test/socket": {} }); + mount(host.ns); + const closes: [number, string][] = []; + const promise = connect("ws://echo.test/socket", { + socket: { + close(_s, code, reason) { + closes.push([code, reason]); + }, + }, + }); + await ticks(host); + const socket = await promise; + expect(() => socket.close(1001)).toThrow(NetworkError); + socket.close(4000, "bye"); + expect(socket.readyState).toBe("closing"); + expect(socket.send("x")).toEqual({ status: "closed" }); + await ticks(host); + expect(socket.readyState).toBe("closed"); + expect(closes).toEqual([[4000, "bye"]]); + expect(host.live()).toBe(0); + }); + + test("peer close and transport loss report error then close", async () => { + const host = createSimWsHost({ "ws://a.test/": {}, "ws://b.test/": {} }); + mount(host.ns); + const events: string[] = []; + const handlers = (tag: string) => ({ + error(_s: WebSocket, error: NetworkError) { + events.push(`${tag}:error:${error.code}`); + }, + close(_s: WebSocket, code: number) { + events.push(`${tag}:close:${code}`); + }, + }); + const a = connect("ws://a.test/", { socket: handlers("a") }); + const b = connect("ws://b.test/", { socket: handlers("b") }); + await ticks(host); + await a; + await b; + host.peer("ws://a.test/").close(1000, "done"); + host.peer("ws://b.test/").drop(); + await ticks(host); + expect(events).toEqual(["a:close:1000", "b:error:closed", "b:close:1006"]); + expect(host.live()).toBe(0); + }); + + test("terminate aborts without a Close frame", async () => { + const host = createSimWsHost({ "ws://echo.test/socket": {} }); + mount(host.ns); + const closes: [number, string][] = []; + const promise = connect("ws://echo.test/socket", { + socket: { + close(_s, code, reason) { + closes.push([code, reason]); + }, + }, + }); + await ticks(host); + const socket = await promise; + socket.terminate(); + await ticks(host); + expect(closes).toEqual([[1006, ""]]); + expect(socket.readyState).toBe("closed"); + }); + + test("handshake failure rejects connect and calls no handler", async () => { + const host = createSimWsHost({ + "ws://deny.test/": { error: { code: "websocket_handshake_failed", message: "403", status: 403 } }, + }); + mount(host.ns); + let handlerCalls = 0; + const promise = connect("ws://deny.test/", { + socket: { + error() { + handlerCalls++; + }, + close() { + handlerCalls++; + }, + }, + }); + await ticks(host); + const error = await promise.catch((e: unknown) => e); + expect(error).toMatchObject({ code: "websocket_handshake_failed", category: "protocol", reasonCode: 403 }); + expect(handlerCalls).toBe(0); + expect(host.live()).toBe(0); + }); + + test("synchronous refusals", async () => { + const host = createSimWsHost({ "ws://echo.test/socket": {} }); + mount(host.ns); + await expect(connect("wss://echo.test/socket", { socket: {} })).rejects.toMatchObject({ code: NET_ERROR.unsupported }); + await expect(connect("http://echo.test/socket", { socket: {} })).rejects.toMatchObject({ code: NET_ERROR.invalidRequest }); + await expect(connect("ws://echo.test/socket#frag", { socket: {} })).rejects.toMatchObject({ code: NET_ERROR.invalidRequest }); + await expect(connect("ws://echo.test/socket", { protocols: ["a", "a"], socket: {} })).rejects.toMatchObject({ code: NET_ERROR.invalidRequest }); + await expect(connect("ws://echo.test/socket", { headers: { Host: "x" }, socket: {} })).rejects.toMatchObject({ code: NET_ERROR.invalidRequest }); + await expect(connect("ws://other.test/", { socket: {} })).rejects.toMatchObject({ code: NET_ERROR.permissionDenied }); + expect(host.live()).toBe(0); + }); +}); diff --git a/tests/net.test.ts b/tests/net.test.ts index 882ab2b6..49b4e93b 100644 --- a/tests/net.test.ts +++ b/tests/net.test.ts @@ -1,11 +1,13 @@ +// HTTP Client SDK (`@pocketjs/framework/net/http` fetch) + deterministic sim +// host (hosts/sim/net.ts): tick-boundary delivery, streaming bodies through +// readInto, aggregate helpers, cancellation, error mapping and the support +// module (URL, Headers, AbortController, NetworkError). + import { afterEach, describe, expect, test } from "bun:test"; import { NET_ERROR } from "../contracts/spec/net.ts"; -import { - fetch as pocketFetch, - NetError, - type NetOps, -} from "../framework/src/net-api.ts"; +import { fetch as pocketFetch, Headers, Request, Response, type NetOps } from "../framework/src/net/http.ts"; +import { AbortController, NetworkError, URL, getNetworkLimits } from "../framework/src/net/index.ts"; import { runServicePumps } from "../framework/src/services.ts"; import { createSimNetHost } from "../hosts/sim/net.ts"; @@ -17,10 +19,21 @@ afterEach(() => { delete (globalThis as { net?: NetOps }).net; }); +/** Run `n` host ticks, each followed by the framework service pump and a + * microtask drain (the job drain of that tick). */ +async function ticks(host: { tick(): void }, n = 1): Promise { + for (let i = 0; i < n; i++) { + host.tick(); + runServicePumps(); + // Promise reactions of this tick's deliveries. + for (let j = 0; j < 8; j++) await Promise.resolve(); + } +} + describe("net SDK + deterministic sim host", () => { test("fetch resolves only after a tick boundary and keeps polling lazy", async () => { const host = createSimNetHost({ - "https://example.test/message": { + "http://example.test/message": { status: 200, headers: { "content-type": "application/json" }, body: '{"message":"你好"}', @@ -29,10 +42,10 @@ describe("net SDK + deterministic sim host", () => { mount(host.ns); runServicePumps(); - expect(host.pollCalls()).toBe(0); // no pending Promise: no native poll + expect(host.pollCalls()).toBe(0); // no pending handle: no native poll let settled = false; - const promise = pocketFetch("https://example.test/message").then((response) => { + const promise = pocketFetch("http://example.test/message").then((response) => { settled = true; return response; }); @@ -41,13 +54,17 @@ describe("net SDK + deterministic sim host", () => { expect(settled).toBe(false); // transport has not crossed a tick boundary expect(host.pollCalls()).toBe(1); - host.tick(); - runServicePumps(); + await ticks(host); const response = await promise; expect(response.status).toBe(200); expect(response.ok).toBe(true); - expect(response.headers["content-type"]).toBe("application/json"); - expect(await response.json<{ message: string }>()).toEqual({ message: "你好" }); + expect(response.headers.get("content-type")).toBe("application/json"); + expect(response.url).toBe("http://example.test/message"); + const json = response.json<{ message: string }>(); + await ticks(host); + expect(await json).toEqual({ message: "你好" }); + expect(response.bodyUsed).toBe(true); + expect(host.live()).toBe(0); const pollsAfterSettle = host.pollCalls(); runServicePumps(); @@ -55,96 +72,270 @@ describe("net SDK + deterministic sim host", () => { expect(host.pollCalls()).toBe(pollsAfterSettle); // pump unregistered itself }); - test("one poll drains every completion visible in the tick", async () => { + test("bodies stream through readInto across ticks with backpressure", async () => { const host = createSimNetHost({ - "https://example.test/a": { body: "a" }, - "https://example.test/b": { body: "b" }, + "http://example.test/stream": { + body: ["abc", "def", "ghi", "jkl"], + chunkTicks: 1, + length: null, + }, }); mount(host.ns); - const a = pocketFetch("https://example.test/a"); - const b = pocketFetch("https://example.test/b"); - host.tick(); - runServicePumps(); + const promise = pocketFetch("http://example.test/stream"); + await ticks(host); // head + first chunk + const response = await promise; + expect(response.headers.has("content-length")).toBe(false); + const seen: string[] = []; + const reader = (async () => { + for await (const chunk of response.body!) seen.push(new TextDecoder().decode(chunk)); + })(); + for (let i = 0; i < 6; i++) await ticks(host); + await reader; + expect(seen).toEqual(["abc", "def", "ghi", "jkl"]); + expect(host.live()).toBe(0); + }); - expect(host.pollCalls()).toBe(1); - expect(await (await a).text()).toBe("a"); - expect(await (await b).text()).toBe("b"); - expect(host.log.filter((line) => line.startsWith("poll "))).toHaveLength(1); + test("readInto: one pending read, empty destination rejected, EOF only as {0,true}", async () => { + const host = createSimNetHost({ "http://example.test/two": { body: ["12", "34"], chunkTicks: 1 } }); + mount(host.ns); + const promise = pocketFetch("http://example.test/two"); + await ticks(host); + const response = await promise; + const body = response.body!; + await expect(body.readInto(new Uint8Array(0))).rejects.toMatchObject({ code: NET_ERROR.invalidRequest }); + const buf = new Uint8Array(8); + const first = await body.readInto(buf); + expect(first).toEqual({ bytes: 2, done: false }); + const second = body.readInto(buf.subarray(2)); + await expect(body.readInto(new Uint8Array(1))).rejects.toMatchObject({ code: NET_ERROR.busy }); + await ticks(host); + // The last bytes and `end` land in the same batch: the read that took + // the bytes reports done only once EOF was observed, so the next read + // is the {0,true} EOF marker. + expect((await second).bytes).toBe(2); + expect(new TextDecoder().decode(buf.subarray(0, 4))).toBe("1234"); + expect(await body.readInto(buf)).toEqual({ bytes: 0, done: true }); + // The stream is locked: text() must fail with invalid_state. + await expect(response.text()).rejects.toMatchObject({ code: NET_ERROR.invalidState }); }); - test("request metadata and body cross as owned bounded data", async () => { + test("aggregate helpers cancel past their limit with response_too_large", async () => { const host = createSimNetHost({ - "https://example.test/items": (request) => { - expect(request.method).toBe("POST"); - expect(request.headers).toEqual({ "content-type": "application/json", "x-id": "42" }); - expect(new TextDecoder().decode(request.body)).toBe('{"name":"pocket"}'); - expect(request.timeoutMs).toBe(2500); - expect(request.maxBytes).toBe(1024); - return { status: 201, body: "created" }; - }, + "http://example.test/big": { body: "x".repeat(2048), length: null, chunkTicks: 0 }, }); mount(host.ns); - const promise = pocketFetch("https://example.test/items", { - method: "POST", - headers: { "Content-Type": "application/json", "X-ID": "42" }, - body: '{"name":"pocket"}', - timeoutMs: 2500, - maxBytes: 1024, - }); - host.tick(); - runServicePumps(); + const promise = pocketFetch("http://example.test/big", { limits: { aggregateBytes: 1024 } }); + await ticks(host); + const response = await promise; + const text = response.text(); + await ticks(host, 2); + await expect(text).rejects.toMatchObject({ code: NET_ERROR.responseTooLarge }); + expect(host.log.some((l) => l.startsWith("cancel"))).toBe(true); + await ticks(host); + expect(host.live()).toBe(0); + }); + + test("known Content-Length above the limit fails before reading", async () => { + const host = createSimNetHost({ "http://example.test/len": { body: "x".repeat(4096) } }); + mount(host.ns); + const promise = pocketFetch("http://example.test/len", { limits: { aggregateBytes: 100 } }); + await ticks(host); const response = await promise; - expect(response.status).toBe(201); - expect(await response.text()).toBe("created"); - expect(await response.text()).toBe("created"); // buffered response can be reread + const bytes = response.arrayBuffer(); + await ticks(host, 2); + await expect(bytes).rejects.toBeInstanceOf(NetworkError); + }); + + test("clone tees the body; both branches read the same bytes", async () => { + const host = createSimNetHost({ "http://example.test/clone": { body: ["hello ", "world"], chunkTicks: 1 } }); + mount(host.ns); + const promise = pocketFetch("http://example.test/clone"); + await ticks(host); + const original = await promise; + const copy = original.clone(); + const a = original.text(); + const b = copy.text(); + await ticks(host, 4); + expect(await a).toBe("hello world"); + expect(await b).toBe("hello world"); + expect(() => original.clone()).toThrow(NetworkError); }); - test("whole-response cap rejects before oversized data reaches guest", async () => { + test("HEAD and 204 responses have a null body and retire on end", async () => { const host = createSimNetHost({ - "https://example.test/large": { body: new Uint8Array(5) }, + "http://example.test/head": { status: 200, headers: { "content-length": "42" }, length: 42, body: "" }, + "http://example.test/nocontent": { status: 204, body: "" }, }); mount(host.ns); - const promise = pocketFetch("https://example.test/large", { maxBytes: 4 }); - host.tick(); - runServicePumps(); - await expect(promise).rejects.toMatchObject({ code: NET_ERROR.responseTooLarge }); - expect(host.log.some((line) => line.startsWith("take "))).toBe(false); + const head = pocketFetch("http://example.test/head", { method: "HEAD" }); + const none = pocketFetch("http://example.test/nocontent"); + await ticks(host); + expect((await head).body).toBeNull(); + expect((await none).body).toBeNull(); + expect((await none).status).toBe(204); + expect(await (await head).text()).toBe(""); + expect(host.live()).toBe(0); }); - test("the third concurrent request is refused with busy", async () => { + test("errors map onto NetworkError with the stable code and category", async () => { const host = createSimNetHost({ - "https://example.test/a": { body: "a", delayTicks: 2 }, - "https://example.test/b": { body: "b", delayTicks: 2 }, - "https://example.test/c": { body: "c", delayTicks: 2 }, + "http://example.test/dns": { error: { code: "dns", message: "no such host" } }, + "http://example.test/late": { body: ["ab", "cd"], chunkTicks: 1, error: { code: "closed", message: "peer reset", afterHeaders: true } }, }); mount(host.ns); - const a = pocketFetch("https://example.test/a"); - const b = pocketFetch("https://example.test/b"); - await expect(pocketFetch("https://example.test/c")).rejects.toMatchObject({ - code: NET_ERROR.busy, - }); - host.tick(); - host.tick(); + const failing = pocketFetch("http://example.test/dns"); + await ticks(host); + const error = await failing.catch((e: unknown) => e); + expect(error).toBeInstanceOf(NetworkError); + expect(error).toMatchObject({ code: "dns", category: "resolver", operation: "fetch", protocol: "http", temporary: true }); + + const late = pocketFetch("http://example.test/late"); + await ticks(host); + const response = await late; + const text = response.text(); + await ticks(host, 3); + await expect(text).rejects.toMatchObject({ code: "closed", category: "runtime" }); + expect(host.live()).toBe(0); + }); + + test("synchronous refusals reject without touching the pump", async () => { + const host = createSimNetHost({}); + mount(host.ns); + await expect(pocketFetch("http://example.test/none")).rejects.toMatchObject({ code: NET_ERROR.permissionDenied }); + await expect(pocketFetch("https://example.test/tls")).rejects.toMatchObject({ code: NET_ERROR.unsupported }); + await expect(pocketFetch("ftp://example.test/x")).rejects.toMatchObject({ code: NET_ERROR.invalidRequest }); + await expect(pocketFetch("http://example.test/x", { method: "TRACE" })).rejects.toMatchObject({ code: NET_ERROR.invalidRequest }); + await expect(pocketFetch("http://example.test/x", { method: "GET", body: "nope" })).rejects.toMatchObject({ code: NET_ERROR.invalidRequest }); + await expect(pocketFetch("http://user:pw@example.test/x")).rejects.toMatchObject({ code: NET_ERROR.invalidRequest }); + expect(host.pollCalls()).toBe(0); runServicePumps(); - await Promise.all([a, b]); + expect(host.pollCalls()).toBe(0); + }); + + test("the namespace missing yields unavailable, not a crash", async () => { + await expect(pocketFetch("http://example.test/x")).rejects.toMatchObject({ code: NET_ERROR.unavailable }); + expect(getNetworkLimits().httpClient).toBeNull(); + }); + + test("AbortSignal cancels; the terminal event settles at the next tick", async () => { + const host = createSimNetHost({ "http://example.test/slow": { body: "later", delayTicks: 5 } }); + mount(host.ns); + const controller = new AbortController(); + const promise = pocketFetch("http://example.test/slow", { signal: controller.signal }); + await ticks(host); + controller.abort(); + let settled = false; + promise.catch(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); // nothing settles inside abort() + await ticks(host); + await expect(promise).rejects.toMatchObject({ code: NET_ERROR.cancelled }); + expect(host.live()).toBe(0); + // Already-aborted signals refuse synchronously. + const done = new AbortController(); + done.abort(); + await expect(pocketFetch("http://example.test/slow", { signal: done.signal })).rejects.toMatchObject({ code: NET_ERROR.cancelled }); }); - test("invalid portable requests fail without entering the host", async () => { + test("request bodies cross as one borrowed snapshot; headers reach the host lowercased", async () => { + let seen: { method: string; body: Uint8Array; headers: Record } | null = null; const host = createSimNetHost({ - "https://example.test/a": { body: "unused" }, + "http://example.test/echo": (request) => { + seen = { method: request.method, body: request.body, headers: { ...request.headers } }; + return { status: 201, body: request.body }; + }, }); mount(host.ns); - await expect( - pocketFetch("https://example.test/a", { method: "GET", body: "no" }), - ).rejects.toMatchObject({ code: NET_ERROR.invalidRequest }); - await expect(pocketFetch("file:///secret")).rejects.toBeInstanceOf(NetError); - expect(host.log).toEqual([]); + const bytes = new Uint8Array([1, 2, 3, 4]); + const promise = pocketFetch("http://example.test/echo", { + method: "post", + body: bytes, + headers: { "X-Trace": " abc ", Host: "evil.test", Cookie: "a=1" }, + }); + bytes[0] = 99; // after start(): the snapshot is unaffected + await ticks(host); + const response = await promise; + expect(seen!.method).toBe("POST"); + expect([...seen!.body]).toEqual([1, 2, 3, 4]); + expect(seen!.headers["x-trace"]).toBe("abc"); + expect(seen!.headers.host).toBeUndefined(); // core-owned header dropped by the request guard + expect(seen!.headers.cookie).toBe("a=1"); // explicit cookies are allowed + const echoed = response.arrayBuffer(); + await ticks(host); + expect([...new Uint8Array(await echoed)]).toEqual([1, 2, 3, 4]); }); - test("an unmounted module rejects explicitly", async () => { - delete (globalThis as { net?: NetOps }).net; - await expect(pocketFetch("https://example.test/a")).rejects.toMatchObject({ - code: NET_ERROR.unavailable, - }); + test("getNetworkLimits reflects the mounted module", () => { + const host = createSimNetHost({}); + mount(host.ns); + const limits = getNetworkLimits(); + expect(limits.httpClient?.specMajor).toBe(2); + expect(limits.httpClient?.features).toEqual([]); + expect(limits.websocketClient).toBeNull(); + expect(Object.isFrozen(limits)).toBe(true); + }); +}); + +describe("net support module", () => { + test("URL parses, resolves and normalizes the special schemes", () => { + const u = new URL("HTTP://Example.TEST:80/a/./b/../c?q=1#frag"); + expect(u.href).toBe("http://example.test/a/c?q=1#frag"); + expect(u.protocol).toBe("http:"); + expect(u.hostname).toBe("example.test"); + expect(u.port).toBe(""); + expect(u.effectivePort).toBe(80); + expect(u.origin).toBe("http://example.test"); + expect(new URL("https://h:8443/x").port).toBe("8443"); + expect(new URL("/other?y", "http://a.test/p/q").href).toBe("http://a.test/other?y"); + expect(new URL("rel", "http://a.test/p/q").href).toBe("http://a.test/p/rel"); + expect(new URL("//b.test/z", "http://a.test/p").href).toBe("http://b.test/z"); + expect(new URL("http://[::1]:8080/").host).toBe("[::1]:8080"); + expect(new URL("ws://h/a b").pathname).toBe("/a%20b"); + expect(URL.canParse("http://")).toBe(false); + expect(URL.canParse("nope")).toBe(false); + expect(() => new URL("http://exa mple.test/")).toThrow(TypeError); + expect(new URL("mailto:someone@x").protocol).toBe("mailto:"); + }); + + test("Headers normalizes, combines, sorts and splits Set-Cookie", () => { + const h = new Headers([ + ["Content-Type", " text/plain "], + ["set-cookie", "a=1"], + ["Set-Cookie", "b=2"], + ["accept", "x"], + ]); + h.append("Accept", "y"); + expect(h.get("accept")).toBe("x, y"); + expect(h.get("content-type")).toBe("text/plain"); + expect(h.getSetCookie()).toEqual(["a=1", "b=2"]); + expect([...h.keys()]).toEqual(["accept", "content-type", "set-cookie", "set-cookie"]); + expect(() => h.set("bad name", "v")).toThrow(NetworkError); + expect(() => h.set("x", "a\r\nb")).toThrow(NetworkError); + h.delete("accept"); + expect(h.has("accept")).toBe(false); + }); + + test("Request/Response constructors validate and lock bodies", async () => { + const request = new Request("http://a.test/x", { method: "post", body: "hi", headers: { "x-a": "1" } }); + expect(request.method).toBe("POST"); + expect(request.bodyUsed).toBe(false); + const copy = request.clone(); + expect(await request.text()).toBe("hi"); + expect(request.bodyUsed).toBe(true); + expect(await copy.text()).toBe("hi"); + await expect(request.text()).rejects.toMatchObject({ code: NET_ERROR.invalidState }); + expect(() => new Request("http://a.test/x", { redirect: "sometimes" as "follow" })).toThrow(NetworkError); + + const response = Response.json({ ok: true }, { status: 201 }); + expect(response.headers.get("content-type")).toBe("application/json"); + expect(await response.json<{ ok: boolean }>()).toEqual({ ok: true }); + expect(response.bodyUsed).toBe(true); + const redirect = Response.redirect("http://b.test/", 307); + expect(redirect.status).toBe(307); + expect(redirect.headers.get("location")).toBe("http://b.test/"); + expect(() => new Response("x", { status: 204 })).toThrow(NetworkError); + expect(() => new Response(null, { status: 199 })).toThrow(NetworkError); }); }); diff --git a/tools/test.ts b/tools/test.ts index bbdb6ee5..cba3450a 100644 --- a/tools/test.ts +++ b/tools/test.ts @@ -66,6 +66,8 @@ const SUITE: readonly Stage[] = [ "tests/db.test.ts", "tests/fs.test.ts", "tests/net.test.ts", + "tests/net-httpd.test.ts", + "tests/net-websocket.test.ts", "tests/net-web.test.js", "tests/vita-package.test.ts", "tests/psp-toolchain.test.ts", From 027cf31045f694e8b35734ebd25b46d820c83c40 Mon Sep 17 00:00:00 2001 From: HalfSweet Date: Wed, 19 Aug 2026 23:39:28 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(net):=20review=20round=20=E2=80=94=20po?= =?UTF-8?q?licy=20is=20Build=20Plan=20truth,=20spec-pinned=20semantics,=20?= =?UTF-8?q?hard=20bounds,=20one=20frame=20prelude,=20Rust=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review fixes for the contract/SDK/host side (stack A): - Network policy is a typed contract and plan truth (reviewer item 1): contracts/spec/network-policy.ts (connect / listen rules, credentials, switches; normalization into a canonical ResolvedNetworkPolicy v1; the reference matcher; canonical JSON). Manifest format 3 adds permissions.network (contracts/schema/pocket-3.json; format 2 stays valid and resolves to deny-all). ResolvedBuildPlan.network is covered by planHash; extractHostBuildInputs projects planHash, features and the canonical policy JSON (POCKETJS_NETWORK_POLICY). The sim hosts enforce an optional plan policy through the contract matcher (connect, listen, redirect re-check). contracts/spec/vectors/network-policy.json pins parse and match decisions for every implementation. - Wire semantics live in the spec (item 9): NET_METHODS_FORBIDDEN carries TRACK; HTTP_CORE_OWNED_REQUEST_HEADERS, HTTP_BODYLESS_STATUS, HTTP_NULL_BODY_STATUS, HTTP_REDIRECT_* join it; gen-c / gen-rust / the new gen-web (hosts/web/net-spec.js, drift-guarded) emit them; the SDK, sim and browser host drop their private copies; the sim method check is case-insensitive like the cores. contracts/spec/vectors/http-semantics.json runs on the SDK, the sim, the browser host, the C core (B) and the Rust core. - Hard bounds (item 8): teeBody sizes each pull to the remaining room so a clone's lagging branch never exceeds the aggregate limit; the browser dev host reads through a BYOB reader sized to the queue's free space. - runFramePrelude (item 10): one definition of clock → input latches → service pumps → effects for the Solid, Vue Vapor, Octane and headless entries. - Rust parity (item 3): the core owns the policy — NetPolicy parses the canonical document with the reference matcher (localNetwork enforced, no bare wildcard), backends receive a PolicyGate for every resolved address, redirect hop (spec rewrite table, hop budget, endpoint re-check) and TLS verification mode, the core classifies literal addresses and rejects a response from a URL the gate did not authorize; the shared vectors run in its tests. - Changelog (item 11): an Unreleased entry names the net change a breaking migration and introduces format 3. url.ts carried raw NUL/DEL bytes (git saw it as binary); they are escapes now. --- contracts/schema/pocket-3.json | 387 +++++++++++ contracts/spec/gen-c.ts | 19 + contracts/spec/gen-rust.ts | 13 + contracts/spec/gen-web.ts | 73 ++ contracts/spec/net.ts | 50 +- contracts/spec/network-policy.ts | 685 +++++++++++++++++++ contracts/spec/pocket-manifest.ts | 335 +++++---- contracts/spec/vectors/http-semantics.json | 74 ++ contracts/spec/vectors/network-policy.json | 151 ++++ docs/NET.md | 85 ++- engine/core/src/spec.rs | 9 +- engine/crates/pocket-net/src/lib.rs | 419 +++++++++--- engine/crates/pocket-net/src/policy.rs | 621 +++++++++++++++++ engine/net/include/pocketjs/net/spec.h | 17 +- framework/src/frame-prelude.ts | 40 ++ framework/src/headless.ts | 10 +- framework/src/index-octane.ts | 16 +- framework/src/index-vue-vapor.ts | 16 +- framework/src/index.ts | 16 +- framework/src/manifest/host-build-inputs.ts | 30 + framework/src/manifest/plan.ts | 6 + framework/src/manifest/resolve.ts | 23 +- framework/src/manifest/validate.ts | 37 +- framework/src/net/body.ts | 19 +- framework/src/net/http.ts | 24 +- framework/src/net/url.ts | Bin 9954 -> 9960 bytes hosts/sim/httpd.ts | 8 +- hosts/sim/net.ts | 61 +- hosts/sim/ws.ts | 11 +- hosts/web/net-spec.js | 25 + hosts/web/net.js | 79 ++- package.json | 2 +- site/build.ts | 1 + site/content/changelog.md | 40 ++ site/content/docs/net.md | 22 +- site/content/docs/platform-contracts.md | 10 + tests/contract.ts | 11 + tests/fixtures/plans/portable-psp.plan.json | 11 +- tests/fixtures/plans/portable-vita.plan.json | 11 +- tests/host-build-inputs.test.ts | 60 ++ tests/http-semantics.test.ts | 123 ++++ tests/net-policy-hosts.test.ts | 135 ++++ tests/net.test.ts | 41 ++ tests/network-policy.test.ts | 129 ++++ tests/platform-contracts.test.ts | 173 ++++- tests/symbian-package.test.ts | 2 + tests/symbian-runtime.test.ts | 2 + tools/test.ts | 3 + 48 files changed, 3776 insertions(+), 359 deletions(-) create mode 100644 contracts/schema/pocket-3.json create mode 100644 contracts/spec/gen-web.ts create mode 100644 contracts/spec/network-policy.ts create mode 100644 contracts/spec/vectors/http-semantics.json create mode 100644 contracts/spec/vectors/network-policy.json create mode 100644 engine/crates/pocket-net/src/policy.rs create mode 100644 framework/src/frame-prelude.ts create mode 100644 hosts/web/net-spec.js create mode 100644 tests/http-semantics.test.ts create mode 100644 tests/net-policy-hosts.test.ts create mode 100644 tests/network-policy.test.ts diff --git a/contracts/schema/pocket-3.json b/contracts/schema/pocket-3.json new file mode 100644 index 00000000..5be74ac6 --- /dev/null +++ b/contracts/schema/pocket-3.json @@ -0,0 +1,387 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pocketjs.dev/schema/pocket-3.json", + "title": "Pocket application manifest, format 3", + "type": "object", + "additionalProperties": false, + "required": [ + "$schema", + "pocket", + "id", + "name", + "title", + "version", + "engine", + "app" + ], + "properties": { + "$schema": { + "const": "https://pocketjs.dev/schema/pocket-3.json" + }, + "pocket": { + "const": 3 + }, + "id": { + "type": "string", + "minLength": 3, + "pattern": "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$" + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "version": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$" + }, + "execution": { + "type": "object", + "additionalProperties": false, + "required": [ + "classes" + ], + "properties": { + "classes": { + "type": "array", + "items": { + "enum": [ + "guest", + "aot" + ] + }, + "minItems": 1, + "uniqueItems": true + } + } + }, + "engine": { + "type": "object", + "additionalProperties": false, + "required": [ + "capabilities" + ], + "properties": { + "capabilities": { + "type": "object", + "additionalProperties": false, + "required": [ + "requires" + ], + "properties": { + "requires": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*(?:\\.[a-z][a-z0-9-]*)+$" + }, + "minItems": 1, + "uniqueItems": true + }, + "enhances": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*(?:\\.[a-z][a-z0-9-]*)+$" + }, + "uniqueItems": true + } + } + } + } + }, + "app": { + "type": "object", + "additionalProperties": false, + "required": [ + "entry", + "framework", + "viewport" + ], + "properties": { + "entry": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*\\\\).+\\.tsx?$" + }, + "output": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$" + }, + "framework": { + "enum": [ + "solid", + "vue-vapor", + "octane" + ] + }, + "companions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$" + }, + "uniqueItems": true + }, + "viewport": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "logical", + "presentation" + ], + "properties": { + "logical": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1 + }, + "minItems": 2, + "maxItems": 2 + }, + "presentation": { + "enum": [ + "fill", + "fit", + "integer-fit", + "native", + "stretch" + ] + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "fixed": { + "type": "object", + "additionalProperties": false, + "required": [ + "logical", + "presentation" + ], + "properties": { + "logical": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1 + }, + "minItems": 2, + "maxItems": 2 + }, + "presentation": { + "enum": [ + "fill", + "fit", + "integer-fit", + "native", + "stretch" + ] + } + } + }, + "dynamic": { + "type": "object", + "additionalProperties": false, + "required": [ + "default" + ], + "properties": { + "default": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1 + }, + "minItems": 2, + "maxItems": 2 + }, + "min": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1 + }, + "minItems": 2, + "maxItems": 2 + }, + "max": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1 + }, + "minItems": 2, + "maxItems": 2 + } + } + } + } + } + ] + } + } + }, + "permissions": { + "type": "object", + "additionalProperties": false, + "properties": { + "network": { + "type": "object", + "additionalProperties": false, + "properties": { + "connect": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "protocol", + "host", + "port" + ], + "properties": { + "protocol": { + "enum": [ + "http", + "https", + "ws", + "wss" + ] + }, + "host": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "port": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "min", + "max" + ], + "properties": { + "min": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "max": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + } + } + ] + } + } + } + }, + "listen": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "protocol", + "address", + "port" + ], + "properties": { + "protocol": { + "enum": [ + "http", + "https", + "ws", + "wss" + ] + }, + "address": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "port": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "min", + "max" + ], + "properties": { + "min": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "max": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + } + }, + { + "const": "ephemeral" + } + ] + } + } + } + }, + "credentials": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$" + }, + "uniqueItems": true + }, + "localNetwork": { + "type": "boolean" + }, + "insecureTransport": { + "type": "boolean" + }, + "allowInvalidTlsForDevelopment": { + "type": "boolean" + } + } + } + } + } + } +} diff --git a/contracts/spec/gen-c.ts b/contracts/spec/gen-c.ts index 7af0ca23..60efc695 100644 --- a/contracts/spec/gen-c.ts +++ b/contracts/spec/gen-c.ts @@ -40,6 +40,12 @@ import { HTTPD_SPEC_MINOR, } from "./httpd.ts"; import { + HTTP_BODYLESS_STATUS, + HTTP_CORE_OWNED_REQUEST_HEADERS, + HTTP_NULL_BODY_STATUS, + HTTP_REDIRECT_ANY_TO_GET_STATUS, + HTTP_REDIRECT_POST_TO_GET_STATUS, + HTTP_REDIRECT_STATUS, NET_DEFAULT_AGGREGATE_BYTES, NET_DEFAULT_QUEUE_BYTES, NET_DEFAULT_TIMEOUT_MS, @@ -138,6 +144,19 @@ export function generateC(): string { put( `#define PNET_METHODS_FORBIDDEN { ${NET_METHODS_FORBIDDEN.map(cstr).join(", ")} }`, ); + put("/* HTTP semantics shared by client, server and SDK (see net.ts). */"); + put(`#define PNET_HTTP_CORE_OWNED_REQUEST_HEADERS_COUNT ${HTTP_CORE_OWNED_REQUEST_HEADERS.length}`); + put(`#define PNET_HTTP_CORE_OWNED_REQUEST_HEADERS { ${HTTP_CORE_OWNED_REQUEST_HEADERS.map(cstr).join(", ")} }`); + put(`#define PNET_HTTP_BODYLESS_STATUS_COUNT ${HTTP_BODYLESS_STATUS.length}`); + put(`#define PNET_HTTP_BODYLESS_STATUS { ${HTTP_BODYLESS_STATUS.join(", ")} }`); + put(`#define PNET_HTTP_NULL_BODY_STATUS_COUNT ${HTTP_NULL_BODY_STATUS.length}`); + put(`#define PNET_HTTP_NULL_BODY_STATUS { ${HTTP_NULL_BODY_STATUS.join(", ")} }`); + put(`#define PNET_HTTP_REDIRECT_STATUS_COUNT ${HTTP_REDIRECT_STATUS.length}`); + put(`#define PNET_HTTP_REDIRECT_STATUS { ${HTTP_REDIRECT_STATUS.join(", ")} }`); + put(`#define PNET_HTTP_REDIRECT_POST_TO_GET_STATUS_COUNT ${HTTP_REDIRECT_POST_TO_GET_STATUS.length}`); + put(`#define PNET_HTTP_REDIRECT_POST_TO_GET_STATUS { ${HTTP_REDIRECT_POST_TO_GET_STATUS.join(", ")} }`); + put(`#define PNET_HTTP_REDIRECT_ANY_TO_GET_STATUS_COUNT ${HTTP_REDIRECT_ANY_TO_GET_STATUS.length}`); + put(`#define PNET_HTTP_REDIRECT_ANY_TO_GET_STATUS { ${HTTP_REDIRECT_ANY_TO_GET_STATUS.join(", ")} }`); for (const [name, v] of Object.entries(NET_EVENT)) { put(`#define PNET_EVENT_${screaming(name)} ${cstr(v)}`); } diff --git a/contracts/spec/gen-rust.ts b/contracts/spec/gen-rust.ts index d5ec1d36..116e7f16 100644 --- a/contracts/spec/gen-rust.ts +++ b/contracts/spec/gen-rust.ts @@ -81,6 +81,12 @@ import { NET_MAX_REQUEST_BYTES, NET_MAX_TICK_BYTES, NET_MAX_TIMEOUT_MS, + HTTP_BODYLESS_STATUS, + HTTP_CORE_OWNED_REQUEST_HEADERS, + HTTP_NULL_BODY_STATUS, + HTTP_REDIRECT_ANY_TO_GET_STATUS, + HTTP_REDIRECT_POST_TO_GET_STATUS, + HTTP_REDIRECT_STATUS, NET_METHODS_FORBIDDEN, NET_OP, NET_SPEC_MAJOR, @@ -642,6 +648,13 @@ export function generateRust(): string { put(` pub const MAX_REDIRECTS: usize = ${NET_MAX_REDIRECTS};`); put(` pub const TLS_MIN_VERSION: &str = ${JSON.stringify(NET_TLS_MIN_VERSION)};`); put(` pub const METHODS_FORBIDDEN: [&str; ${NET_METHODS_FORBIDDEN.length}] = [${NET_METHODS_FORBIDDEN.map((method) => JSON.stringify(method)).join(", ")}];`); + put(" /// HTTP semantics shared by client, server and SDK (see net.ts)."); + put(` pub const HTTP_CORE_OWNED_REQUEST_HEADERS: [&str; ${HTTP_CORE_OWNED_REQUEST_HEADERS.length}] = [${HTTP_CORE_OWNED_REQUEST_HEADERS.map((name) => JSON.stringify(name)).join(", ")}];`); + put(` pub const HTTP_BODYLESS_STATUS: [u16; ${HTTP_BODYLESS_STATUS.length}] = [${HTTP_BODYLESS_STATUS.join(", ")}];`); + put(` pub const HTTP_NULL_BODY_STATUS: [u16; ${HTTP_NULL_BODY_STATUS.length}] = [${HTTP_NULL_BODY_STATUS.join(", ")}];`); + put(` pub const HTTP_REDIRECT_STATUS: [u16; ${HTTP_REDIRECT_STATUS.length}] = [${HTTP_REDIRECT_STATUS.join(", ")}];`); + put(` pub const HTTP_REDIRECT_POST_TO_GET_STATUS: [u16; ${HTTP_REDIRECT_POST_TO_GET_STATUS.length}] = [${HTTP_REDIRECT_POST_TO_GET_STATUS.join(", ")}];`); + put(` pub const HTTP_REDIRECT_ANY_TO_GET_STATUS: [u16; ${HTTP_REDIRECT_ANY_TO_GET_STATUS.length}] = [${HTTP_REDIRECT_ANY_TO_GET_STATUS.join(", ")}];`); for (const [name, v] of Object.entries(NET_EVENT)) { put(` pub const EVENT_${screaming(name)}: &str = ${JSON.stringify(v)};`); } diff --git a/contracts/spec/gen-web.ts b/contracts/spec/gen-web.ts new file mode 100644 index 00000000..d1c1cd05 --- /dev/null +++ b/contracts/spec/gen-web.ts @@ -0,0 +1,73 @@ +// Deterministic codegen: contracts/spec/net.ts -> hosts/web/net-spec.js — the +// plain-ESM mirror of the HTTP Client boundary for the browser dev host. +// hosts/web/*.js is served to the browser as-is (no bundler, no TypeScript), +// so the host cannot import the spec directly; it imports this generated +// module instead and tests/contract.ts byte-compares it. +// +// Run from PocketJS/: bun contracts/spec/gen-web.ts (or `bun run gen`) + +import { + HTTP_CORE_OWNED_REQUEST_HEADERS, + HTTP_NULL_BODY_STATUS, + HTTP_REDIRECT_STATUS, + NET_DEFAULT_AGGREGATE_BYTES, + NET_DEFAULT_QUEUE_BYTES, + NET_DEFAULT_TIMEOUT_MS, + NET_ERROR, + NET_EVENT, + NET_MAX_AGGREGATE_BYTES, + NET_MAX_EVENTS_PER_TICK, + NET_MAX_HEADER_BYTES, + NET_MAX_HEADERS, + NET_MAX_INFLIGHT, + NET_MAX_QUEUE_BYTES, + NET_MAX_REDIRECTS, + NET_MAX_REQUEST_BYTES, + NET_MAX_TICK_BYTES, + NET_MAX_TIMEOUT_MS, + NET_METHODS_FORBIDDEN, + NET_SPEC_MAJOR, + NET_SPEC_MINOR, + NET_TLS_MIN_VERSION, +} from "./net.ts"; + +function js(value: unknown): string { + return JSON.stringify(value); +} + +export function generateWeb(): string { + const L: string[] = []; + const put = (s: string) => L.push(s); + put("// GENERATED — do not edit; run `bun contracts/spec/gen-web.ts`."); + put("// Plain-ESM mirror of contracts/spec/net.ts for the browser dev host"); + put("// (hosts/web/net.js). tests/contract.ts byte-compares this file."); + put(`export const NET_SPEC_MAJOR = ${NET_SPEC_MAJOR};`); + put(`export const NET_SPEC_MINOR = ${NET_SPEC_MINOR};`); + put(`export const NET_MAX_INFLIGHT = ${NET_MAX_INFLIGHT};`); + put(`export const NET_MAX_REQUEST_BYTES = ${NET_MAX_REQUEST_BYTES};`); + put(`export const NET_DEFAULT_QUEUE_BYTES = ${NET_DEFAULT_QUEUE_BYTES};`); + put(`export const NET_MAX_QUEUE_BYTES = ${NET_MAX_QUEUE_BYTES};`); + put(`export const NET_DEFAULT_AGGREGATE_BYTES = ${NET_DEFAULT_AGGREGATE_BYTES};`); + put(`export const NET_MAX_AGGREGATE_BYTES = ${NET_MAX_AGGREGATE_BYTES};`); + put(`export const NET_MAX_EVENTS_PER_TICK = ${NET_MAX_EVENTS_PER_TICK};`); + put(`export const NET_MAX_TICK_BYTES = ${NET_MAX_TICK_BYTES};`); + put(`export const NET_MAX_HEADERS = ${NET_MAX_HEADERS};`); + put(`export const NET_MAX_HEADER_BYTES = ${NET_MAX_HEADER_BYTES};`); + put(`export const NET_DEFAULT_TIMEOUT_MS = ${NET_DEFAULT_TIMEOUT_MS};`); + put(`export const NET_MAX_TIMEOUT_MS = ${NET_MAX_TIMEOUT_MS};`); + put(`export const NET_MAX_REDIRECTS = ${NET_MAX_REDIRECTS};`); + put(`export const NET_TLS_MIN_VERSION = ${js(NET_TLS_MIN_VERSION)};`); + put(`export const NET_METHODS_FORBIDDEN = ${js(NET_METHODS_FORBIDDEN)};`); + put(`export const HTTP_CORE_OWNED_REQUEST_HEADERS = ${js(HTTP_CORE_OWNED_REQUEST_HEADERS)};`); + put(`export const HTTP_NULL_BODY_STATUS = ${js(HTTP_NULL_BODY_STATUS)};`); + put(`export const HTTP_REDIRECT_STATUS = ${js(HTTP_REDIRECT_STATUS)};`); + put(`export const NET_EVENT = ${js(NET_EVENT)};`); + put(`export const NET_ERROR = ${js(NET_ERROR)};`); + return L.join("\n") + "\n"; +} + +if (import.meta.main) { + const out = new URL("../../hosts/web/net-spec.js", import.meta.url).pathname; + await Bun.write(out, generateWeb()); + console.log(`wrote ${out}`); +} diff --git a/contracts/spec/net.ts b/contracts/spec/net.ts index 7c24239c..395bfa9f 100644 --- a/contracts/spec/net.ts +++ b/contracts/spec/net.ts @@ -185,9 +185,53 @@ export const NET_MAX_TIMEOUT_MS = 120_000; export const NET_MAX_REDIRECTS = 5; export const NET_TLS_MIN_VERSION = "1.2"; -/** Methods that are never client-app operations; any other RFC 9110 token - * is accepted. */ -export const NET_METHODS_FORBIDDEN = ["CONNECT", "TRACE"] as const; +// --------------------------------------------------------------------------- +// HTTP semantics shared by every implementation (SDK, sim, browser host, C +// core, Rust core). These are the wire-visible rules that used to live as +// folklore in each layer; contracts/spec/vectors/http-semantics.json pins +// them and every implementation runs the same vectors. +// --------------------------------------------------------------------------- + +/** Methods that are never client-app operations: RFC 9110 CONNECT and TRACE, + * plus TRACK (the legacy Microsoft TRACE alias, refused for the same + * cross-site-tracing reason). Matching is case-insensitive; any other RFC + * 9110 token is accepted verbatim. */ +export const NET_METHODS_FORBIDDEN = ["CONNECT", "TRACE", "TRACK"] as const; + +/** Request headers the core owns (framing, connection control, upgrade). + * The SDK refuses them on a Request; a core strips them if they arrive. */ +export const HTTP_CORE_OWNED_REQUEST_HEADERS = [ + "host", + "connection", + "content-length", + "transfer-encoding", + "trailer", + "te", + "upgrade", + "keep-alive", + "expect", + "proxy-connection", +] as const; + +/** Response statuses whose message never has a body regardless of the + * framing headers (RFC 9112 §6.3 rule 1); every 1xx status and the response + * to a HEAD request are bodyless the same way. A client parses these as + * head-only and reports `length` from Content-Length when present. */ +export const HTTP_BODYLESS_STATUS = [204, 304] as const; + +/** Statuses a Response may not carry content for: the Fetch "null body + * status" set (101, 103, 204, 205, 304). The SDK Response refuses a body + * init, a server refuses to emit content, a client surfaces a null body. */ +export const HTTP_NULL_BODY_STATUS = [101, 103, 204, 205, 304] as const; + +/** Redirect statuses a client follows under `redirect: "follow"`; any other + * 3xx is an ordinary response. */ +export const HTTP_REDIRECT_STATUS = [301, 302, 303, 307, 308] as const; +/** On these statuses a POST becomes a GET and the body is dropped (RFC 9110 + * §15.4.2-3 common practice); other methods are kept. */ +export const HTTP_REDIRECT_POST_TO_GET_STATUS = [301, 302] as const; +/** On these statuses any method except HEAD becomes a GET without a body. */ +export const HTTP_REDIRECT_ANY_TO_GET_STATUS = [303] as const; // --------------------------------------------------------------------------- // Errors — the vocabulary shared by net, ws and httpd. A core diff --git a/contracts/spec/network-policy.ts b/contracts/spec/network-policy.ts new file mode 100644 index 00000000..f67f86ad --- /dev/null +++ b/contracts/spec/network-policy.ts @@ -0,0 +1,685 @@ +// PocketJS network policy — the typed contract between the application +// manifest (format 3, `permissions.network`), the Build Plan +// (`ResolvedBuildPlan.network`) and every network host. +// +// Ownership: +// manifest `permissions.network` app intent: which endpoints it may +// connect to / listen on, which host +// credential ids it may name, and the +// plaintext / local-network / dev-TLS +// switches +// resolver resolveNetworkPolicy() normalizes the intent into one canonical +// ResolvedNetworkPolicy and writes it into +// the plan (so it is covered by planHash) +// host canonicalNetworkPolicyJson(plan.network) +// is the immutable policy JSON a host hands +// to its network core at runtime creation +// (engine/net `pnet_runtime_create`, the +// Rust `NetPolicy::parse`, the sim hosts) +// +// A host never authors or widens this policy; it enforces it on every +// command (connect rule before DNS, every candidate address after DNS, +// listen rule before bind, again on redirects). The matcher below is the +// reference semantics; the C and Rust cores implement the same rules and +// the shared vectors (contracts/spec/vectors/network-policy.json) pin them. + +import type { JsonSchema } from "./pocket-manifest.ts"; + +export const NETWORK_POLICY_VERSION = 1 as const; + +/** The protocols the v1 modules speak; listen rules take the same tokens. */ +export const NETWORK_POLICY_PROTOCOLS = ["http", "https", "ws", "wss"] as const; +export type NetworkPolicyProtocol = (typeof NETWORK_POLICY_PROTOCOLS)[number]; + +/** Plaintext protocols: refused unless the policy sets `insecureTransport`. */ +export const NETWORK_PLAINTEXT_PROTOCOLS: readonly NetworkPolicyProtocol[] = ["http", "ws"]; + +export const NETWORK_DEFAULT_PORTS: Readonly> = { + http: 80, + https: 443, + ws: 80, + wss: 443, +}; + +// --------------------------------------------------------------------------- +// Manifest intent (`permissions.network`) +// --------------------------------------------------------------------------- + +/** A single port or an inclusive range. */ +export type NetworkPortRule = number | { readonly min: number; readonly max: number }; + +export interface NetworkConnectRule { + readonly protocol: NetworkPolicyProtocol; + /** DNS name (lowercase ASCII / IDNA A-label), `*.suffix` (exactly one + * label), or an IP literal. Never a bare `*`. */ + readonly host: string; + readonly port: NetworkPortRule; +} + +export interface NetworkListenRule { + readonly protocol: NetworkPolicyProtocol; + /** A bind address: IP literal only. */ + readonly address: string; + /** A port, a range, or `"ephemeral"` (bind port 0; the host checks the + * OS-assigned port against its own ephemeral range). */ + readonly port: NetworkPortRule | "ephemeral"; +} + +export interface NetworkPermissions { + readonly connect?: readonly NetworkConnectRule[]; + readonly listen?: readonly NetworkListenRule[]; + /** Host credential ids the app may reference (`TlsOptions.credential`); + * never key material. */ + readonly credentials?: readonly string[]; + /** Allow matched endpoints to resolve to loopback / link-local / private / + * CGNAT / ULA addresses. Default false: a public hostname that resolves to + * such an address is refused (`permission_denied`). */ + readonly localNetwork?: boolean; + /** Allow plaintext `http:` / `ws:` rules to be used. Default false. */ + readonly insecureTransport?: boolean; + /** Let a development build skip certificate verification when the caller + * also asks for it per request. Refused outside development builds. */ + readonly allowInvalidTlsForDevelopment?: boolean; +} + +const portRuleSchema = { + anyOf: [ + { type: "integer", minimum: 1, maximum: 65535 }, + { + type: "object", + additionalProperties: false, + required: ["min", "max"], + properties: { + min: { type: "integer", minimum: 1, maximum: 65535 }, + max: { type: "integer", minimum: 1, maximum: 65535 }, + }, + }, + ], +} as const satisfies JsonSchema; + +/** Schema fragment for `permissions.network` (format 3 manifests). Shape + * only; hostname / address / range / duplicate semantics are the resolver's + * (`resolveNetworkPolicy`). */ +export const networkPermissionsSchema = { + type: "object", + additionalProperties: false, + properties: { + connect: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["protocol", "host", "port"], + properties: { + protocol: { enum: NETWORK_POLICY_PROTOCOLS }, + host: { type: "string", minLength: 1, maxLength: 255 }, + port: portRuleSchema, + }, + }, + }, + listen: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["protocol", "address", "port"], + properties: { + protocol: { enum: NETWORK_POLICY_PROTOCOLS }, + address: { type: "string", minLength: 1, maxLength: 64 }, + port: { anyOf: [...portRuleSchema.anyOf, { const: "ephemeral" }] }, + }, + }, + }, + credentials: { + type: "array", + items: { type: "string", minLength: 1, maxLength: 64, pattern: "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$" }, + uniqueItems: true, + }, + localNetwork: { type: "boolean" }, + insecureTransport: { type: "boolean" }, + allowInvalidTlsForDevelopment: { type: "boolean" }, + }, +} as const satisfies JsonSchema; + +// --------------------------------------------------------------------------- +// Resolved policy (the plan's `network` field) +// --------------------------------------------------------------------------- + +export interface ResolvedNetworkPolicy { + readonly version: typeof NETWORK_POLICY_VERSION; + /** Sorted, deduplicated, hosts lowercase, single-port ranges collapsed. */ + readonly connect: readonly NetworkConnectRule[]; + /** Sorted, deduplicated, addresses canonical (RFC 5952 for IPv6). */ + readonly listen: readonly NetworkListenRule[]; + /** Sorted unique host credential ids. */ + readonly credentials: readonly string[]; + readonly localNetwork: boolean; + readonly insecureTransport: boolean; + readonly allowInvalidTlsForDevelopment: boolean; +} + +/** The policy of a manifest without `permissions.network` (format 2 or + * omitted): no endpoint is reachable, nothing can listen. */ +export const DENY_ALL_NETWORK_POLICY: ResolvedNetworkPolicy = Object.freeze({ + version: NETWORK_POLICY_VERSION, + connect: Object.freeze([]) as readonly NetworkConnectRule[], + listen: Object.freeze([]) as readonly NetworkListenRule[], + credentials: Object.freeze([]) as readonly string[], + localNetwork: false, + insecureTransport: false, + allowInvalidTlsForDevelopment: false, +}); + +export interface NetworkPolicyDiagnostic { + readonly code: string; + /** RFC 6901 JSON Pointer below the caller's prefix. */ + readonly path: string; + readonly message: string; +} + +export interface ResolveNetworkPolicyOptions { + /** JSON Pointer prefix of the permissions object (default + * `/permissions/network`). */ + readonly path?: string; + /** A development build admits `allowInvalidTlsForDevelopment: true`; + * production admission refuses it. Default false. */ + readonly development?: boolean; +} + +export type ResolveNetworkPolicyResult = + | { readonly ok: true; readonly policy: ResolvedNetworkPolicy } + | { readonly ok: false; readonly diagnostics: readonly NetworkPolicyDiagnostic[] }; + +// --- addresses -------------------------------------------------------------- + +export interface NetworkAddressLiteral { + readonly family: 4 | 6; + /** 4 or 16 bytes. */ + readonly bytes: Uint8Array; +} + +function parseIPv4(text: string): Uint8Array | null { + const parts = text.split("."); + if (parts.length !== 4) return null; + const out = new Uint8Array(4); + for (let i = 0; i < 4; i++) { + const part = parts[i]; + if (!/^(0|[1-9][0-9]{0,2})$/.test(part)) return null; + const value = Number(part); + if (value > 255) return null; + out[i] = value; + } + return out; +} + +function parseIPv6(text: string): Uint8Array | null { + // RFC 4291 text form: up to 8 hex groups, one `::` gap, optional dotted + // IPv4 tail (the same grammar engine/net's pnet_parse_ipv6 accepts). + if (text.length === 0) return null; + const groups: number[] = []; + let gap = -1; + let i = 0; + if (text.startsWith("::")) { + gap = 0; + i = 2; + } else if (text.startsWith(":")) { + return null; + } + while (i < text.length) { + if (groups.length >= 8) return null; + let j = i; + let dotted = false; + while (j < text.length && text[j] !== ":") { + if (text[j] === ".") dotted = true; + j++; + } + if (dotted) { + if (j !== text.length || groups.length > 6) return null; + const v4 = parseIPv4(text.slice(i)); + if (!v4) return null; + groups.push((v4[0] << 8) | v4[1], (v4[2] << 8) | v4[3]); + i = j; + break; + } + if (j === i || j - i > 4 || !/^[0-9a-fA-F]+$/.test(text.slice(i, j))) return null; + groups.push(parseInt(text.slice(i, j), 16)); + i = j; + if (i < text.length) { + i++; // ':' + if (i < text.length && text[i] === ":") { + if (gap >= 0) return null; + gap = groups.length; + i++; + if (i === text.length) break; + } else if (i === text.length) { + return null; + } + } + } + if (gap < 0 && groups.length !== 8) return null; + if (gap >= 0 && groups.length >= 8) return null; + const out = new Uint8Array(16); + const fill = 8 - groups.length; + let gi = 0; + for (let g = 0; g < 8; g++) { + if (gap >= 0 && g >= gap && g < gap + fill) continue; + out[g * 2] = groups[gi] >> 8; + out[g * 2 + 1] = groups[gi] & 0xff; + gi++; + } + return out; +} + +/** Parse an IP literal (`1.2.3.4`, `::1`, `[::1]`); null when it is not one. */ +export function parseNetworkAddress(text: string): NetworkAddressLiteral | null { + let body = text; + if (body.length >= 2 && body.startsWith("[") && body.endsWith("]")) body = body.slice(1, -1); + if (body.includes(":")) { + const bytes = parseIPv6(body); + return bytes ? { family: 6, bytes } : null; + } + const bytes = parseIPv4(body); + return bytes ? { family: 4, bytes } : null; +} + +/** Canonical text: dotted quad, or RFC 5952 IPv6 (lowercase hex, longest + * zero run of two or more groups compressed, no dotted tail). */ +export function formatNetworkAddress(addr: NetworkAddressLiteral): string { + if (addr.family === 4) return Array.from(addr.bytes).join("."); + const groups: number[] = []; + for (let i = 0; i < 8; i++) groups.push((addr.bytes[i * 2] << 8) | addr.bytes[i * 2 + 1]); + let best = -1; + let bestLen = 0; + for (let i = 0; i < 8;) { + if (groups[i] !== 0) { + i++; + continue; + } + let j = i; + while (j < 8 && groups[j] === 0) j++; + if (j - i > bestLen && j - i >= 2) { + best = i; + bestLen = j - i; + } + i = j; + } + let out = ""; + for (let i = 0; i < 8; i++) { + if (i === best) { + out += "::"; // the group before the run wrote no separator + i += bestLen - 1; + continue; + } + out += groups[i].toString(16); + if (i < 7 && i + 1 !== best) out += ":"; + } + return out; +} + +export function networkAddressIsMulticast(addr: NetworkAddressLiteral): boolean { + if (addr.family === 4) return (addr.bytes[0] & 0xf0) === 0xe0; + return addr.bytes[0] === 0xff; +} + +/** Public (globally routable unicast) classification shared with the C core + * (`pnet_addr_is_public`): false for unspecified, loopback, RFC 1918, + * link-local, CGNAT, multicast, broadcast, `::`/`::1`, fe80::/10, fc00::/7 + * and IPv4-mapped addresses whose IPv4 part is not public. */ +export function networkAddressIsPublic(addr: NetworkAddressLiteral): boolean { + const a = addr.bytes; + if (addr.family === 4) { + if (a[0] === 0) return false; + if (a[0] === 10) return false; + if (a[0] === 127) return false; + if (a[0] === 169 && a[1] === 254) return false; + if (a[0] === 172 && (a[1] & 0xf0) === 16) return false; + if (a[0] === 192 && a[1] === 168) return false; + if (a[0] === 100 && (a[1] & 0xc0) === 64) return false; + if ((a[0] & 0xf0) === 0xe0) return false; + if (a[0] === 255 && a[1] === 255 && a[2] === 255 && a[3] === 255) return false; + return true; + } + let leadingZero = true; + for (let i = 0; i < 15; i++) if (a[i] !== 0) leadingZero = false; + if (leadingZero && (a[15] === 0 || a[15] === 1)) return false; + if (a[0] === 0xfe && (a[1] & 0xc0) === 0x80) return false; + if ((a[0] & 0xfe) === 0xfc) return false; + if (a[0] === 0xff) return false; + let mapped = true; + for (let i = 0; i < 10; i++) if (a[i] !== 0) mapped = false; + if (mapped && a[10] === 0xff && a[11] === 0xff) { + return networkAddressIsPublic({ family: 4, bytes: a.subarray(12, 16) }); + } + return true; +} + +// --- hostnames -------------------------------------------------------------- + +const HOST_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; + +/** Lowercase an ASCII hostname and drop one trailing root dot; null when it + * is not a valid ASCII (A-label) hostname. */ +export function normalizeNetworkHostname(host: string): string | null { + if (!/^[\x21-\x7e]+$/.test(host)) return null; + let lower = host.toLowerCase(); + if (lower.length > 1 && lower.endsWith(".")) lower = lower.slice(0, -1); + if (lower.length === 0 || lower.length > 253) return null; + const labels = lower.split("."); + if (!labels.every((label) => HOST_LABEL.test(label))) return null; + // A name whose last label is all digits is a malformed IPv4 literal, never + // a DNS name (WHATWG URL "ends in a number"); leading-zero octets such as + // 192.168.001.020 are refused by the literal parser on purpose. + if (/^[0-9]+$/.test(labels[labels.length - 1])) return null; + return lower; +} + +/** `*.example.com` matches exactly one non-empty label (`a.example.com`), + * never the suffix itself nor `a.b.example.com`; plain names compare + * case-insensitively; IP literals compare by canonical address. */ +export function networkHostMatches(rule: string, host: string): boolean { + const ruleAddr = parseNetworkAddress(rule); + if (ruleAddr) { + const hostAddr = parseNetworkAddress(host); + return hostAddr !== null && formatNetworkAddress(hostAddr) === formatNetworkAddress(ruleAddr); + } + const target = normalizeNetworkHostname(host); + if (target === null) return false; + if (rule.startsWith("*.")) { + const suffix = rule.slice(1); // ".example.com" + if (target.length <= suffix.length || !target.endsWith(suffix)) return false; + const label = target.slice(0, target.length - suffix.length); + return label.length > 0 && !label.includes("."); + } + return rule === target; +} + +// --- ports ------------------------------------------------------------------ + +function portBounds(rule: NetworkPortRule): readonly [number, number] { + return typeof rule === "number" ? [rule, rule] : [rule.min, rule.max]; +} + +export function networkPortMatches(rule: NetworkPortRule | "ephemeral", port: number): boolean { + if (rule === "ephemeral") return port === 0; + const [min, max] = portBounds(rule); + return port >= min && port <= max; +} + +// --- resolution ------------------------------------------------------------- + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function normalizePortRule( + value: unknown, + allowEphemeral: boolean, + path: string, + diagnostics: NetworkPolicyDiagnostic[], +): NetworkPortRule | "ephemeral" | null { + if (value === "ephemeral") { + if (allowEphemeral) return "ephemeral"; + diagnostics.push({ code: "network.ephemeralConnect", path, message: "connect rules take a port or a range, not \"ephemeral\"" }); + return null; + } + if (typeof value === "number") { + if (!Number.isInteger(value) || value < 1 || value > 65535) { + diagnostics.push({ code: "network.invalidPort", path, message: "port must be an integer from 1 through 65535" }); + return null; + } + return value; + } + if (isRecord(value) && Number.isInteger(value.min) && Number.isInteger(value.max)) { + const min = value.min as number; + const max = value.max as number; + if (min < 1 || max > 65535) { + diagnostics.push({ code: "network.invalidPort", path, message: "port range must stay within 1 through 65535" }); + return null; + } + if (min > max) { + diagnostics.push({ code: "network.reversedPortRange", path, message: `port range ${min}-${max} is reversed` }); + return null; + } + return min === max ? min : { min, max }; + } + diagnostics.push({ code: "network.invalidPort", path, message: "port must be an integer or {min, max}" }); + return null; +} + +function portKey(rule: NetworkPortRule | "ephemeral"): string { + if (rule === "ephemeral") return "ephemeral"; + const [min, max] = portBounds(rule); + return `${String(min).padStart(5, "0")}-${String(max).padStart(5, "0")}`; +} + +function ruleKey(protocol: string, host: string, port: NetworkPortRule | "ephemeral"): string { + return `${protocol}${host}${portKey(port)}`; +} + +/** + * Normalize `permissions.network` into the canonical ResolvedNetworkPolicy: + * hostnames lowercase without a trailing dot, IP literals in canonical text, + * single-port ranges collapsed, rules and credentials sorted, exact + * duplicates refused, `allowInvalidTlsForDevelopment` refused outside + * development builds. `undefined` resolves to DENY_ALL_NETWORK_POLICY. + */ +export function resolveNetworkPolicy( + permissions: NetworkPermissions | undefined, + options: ResolveNetworkPolicyOptions = {}, +): ResolveNetworkPolicyResult { + if (permissions === undefined) return { ok: true, policy: DENY_ALL_NETWORK_POLICY }; + const prefix = options.path ?? "/permissions/network"; + const diagnostics: NetworkPolicyDiagnostic[] = []; + + const connect: NetworkConnectRule[] = []; + const connectKeys = new Map(); + (permissions.connect ?? []).forEach((rule, index) => { + const path = `${prefix}/connect/${index}`; + if (!NETWORK_POLICY_PROTOCOLS.includes(rule.protocol)) { + diagnostics.push({ code: "network.unknownProtocol", path: `${path}/protocol`, message: `unknown protocol ${JSON.stringify(rule.protocol)}` }); + return; + } + let host: string | null = null; + const literal = typeof rule.host === "string" ? parseNetworkAddress(rule.host) : null; + if (literal) { + host = formatNetworkAddress(literal); + } else if (typeof rule.host === "string" && rule.host.startsWith("*.")) { + const suffix = normalizeNetworkHostname(rule.host.slice(2)); + if (suffix !== null && !parseNetworkAddress(suffix)) host = `*.${suffix}`; + } else if (typeof rule.host === "string") { + host = normalizeNetworkHostname(rule.host); + } + if (host === null) { + diagnostics.push({ + code: "network.invalidHost", + path: `${path}/host`, + message: "host must be a lowercase ASCII hostname, a single-label wildcard (*.example.com) or an IP literal", + }); + return; + } + const port = normalizePortRule(rule.port, false, `${path}/port`, diagnostics); + if (port === null) return; + const key = ruleKey(rule.protocol, host, port); + const previous = connectKeys.get(key); + if (previous) { + diagnostics.push({ code: "network.duplicateRule", path, message: `rule was already declared at ${previous}` }); + return; + } + connectKeys.set(key, path); + connect.push({ protocol: rule.protocol, host, port: port as NetworkPortRule }); + }); + + const listen: NetworkListenRule[] = []; + const listenKeys = new Map(); + (permissions.listen ?? []).forEach((rule, index) => { + const path = `${prefix}/listen/${index}`; + if (!NETWORK_POLICY_PROTOCOLS.includes(rule.protocol)) { + diagnostics.push({ code: "network.unknownProtocol", path: `${path}/protocol`, message: `unknown protocol ${JSON.stringify(rule.protocol)}` }); + return; + } + const literal = typeof rule.address === "string" ? parseNetworkAddress(rule.address) : null; + if (!literal) { + diagnostics.push({ code: "network.invalidAddress", path: `${path}/address`, message: "listen address must be an IP literal" }); + return; + } + const address = formatNetworkAddress(literal); + const port = normalizePortRule(rule.port, true, `${path}/port`, diagnostics); + if (port === null) return; + const key = ruleKey(rule.protocol, address, port); + const previous = listenKeys.get(key); + if (previous) { + diagnostics.push({ code: "network.duplicateRule", path, message: `rule was already declared at ${previous}` }); + return; + } + listenKeys.set(key, path); + listen.push({ protocol: rule.protocol, address, port }); + }); + + const credentials = [...new Set(permissions.credentials ?? [])].sort(); + if (credentials.length !== (permissions.credentials ?? []).length) { + diagnostics.push({ code: "network.duplicateCredential", path: `${prefix}/credentials`, message: "credential ids must be unique" }); + } + + const allowInvalidTls = permissions.allowInvalidTlsForDevelopment === true; + if (allowInvalidTls && !options.development) { + diagnostics.push({ + code: "network.developmentOnly", + path: `${prefix}/allowInvalidTlsForDevelopment`, + message: "allowInvalidTlsForDevelopment is admitted only by development builds", + }); + } + + if (diagnostics.length > 0) return { ok: false, diagnostics }; + + const byKey = (hostOf: (rule: T) => string) => + (left: T, right: T) => { + const l = ruleKey(left.protocol, hostOf(left), left.port); + const r = ruleKey(right.protocol, hostOf(right), right.port); + return l < r ? -1 : l > r ? 1 : 0; + }; + connect.sort(byKey((rule) => rule.host)); + listen.sort(byKey((rule) => rule.address)); + + return { + ok: true, + policy: { + version: NETWORK_POLICY_VERSION, + connect, + listen, + credentials, + localNetwork: permissions.localNetwork === true, + insecureTransport: permissions.insecureTransport === true, + allowInvalidTlsForDevelopment: allowInvalidTls, + }, + }; +} + +// --- enforcement (reference semantics) --------------------------------------- + +export function networkPolicyAllowsConnect( + policy: ResolvedNetworkPolicy, + protocol: string, + host: string, + port: number, +): boolean { + if (NETWORK_PLAINTEXT_PROTOCOLS.includes(protocol as NetworkPolicyProtocol) && !policy.insecureTransport) return false; + return policy.connect.some( + (rule) => rule.protocol === protocol && networkPortMatches(rule.port, port) && networkHostMatches(rule.host, host), + ); +} + +/** A resolved candidate address is usable when it is public, or when the + * policy grants `localNetwork`; multicast never is. */ +export function networkPolicyAllowsAddress(policy: ResolvedNetworkPolicy, addr: NetworkAddressLiteral): boolean { + if (networkAddressIsMulticast(addr)) return false; + if (networkAddressIsPublic(addr)) return true; + return policy.localNetwork; +} + +export function networkPolicyAllowsListen( + policy: ResolvedNetworkPolicy, + protocol: string, + address: string, + port: number, +): boolean { + if (NETWORK_PLAINTEXT_PROTOCOLS.includes(protocol as NetworkPolicyProtocol) && !policy.insecureTransport) return false; + const addr = parseNetworkAddress(address); + if (!addr) return false; + const canonical = formatNetworkAddress(addr); + return policy.listen.some( + (rule) => rule.protocol === protocol && rule.address === canonical && networkPortMatches(rule.port, port), + ); +} + +export function networkPolicyHasCredential(policy: ResolvedNetworkPolicy, id: string): boolean { + return policy.credentials.includes(id); +} + +// --- canonical JSON (what a host hands to its core) -------------------------- + +function canonical(value: unknown): string { + if (value === null || typeof value === "boolean" || typeof value === "string") return JSON.stringify(value); + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new TypeError("network policy contains a non-finite number"); + return JSON.stringify(value); + } + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + if (typeof value === "object") { + const record = value as Record; + return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(",")}}`; + } + throw new TypeError(`network policy contains non-JSON value ${typeof value}`); +} + +/** RFC 8785-shaped canonical JSON of a resolved policy: sorted keys, no + * whitespace. Byte-identical for equal policies; this string is the native + * policy input of every host. */ +export function canonicalNetworkPolicyJson(policy: ResolvedNetworkPolicy): string { + return canonical({ + version: policy.version, + connect: policy.connect, + listen: policy.listen, + credentials: policy.credentials, + localNetwork: policy.localNetwork, + insecureTransport: policy.insecureTransport, + allowInvalidTlsForDevelopment: policy.allowInvalidTlsForDevelopment, + }); +} + +/** Parse canonical (or any) policy JSON back into a ResolvedNetworkPolicy: + * hosts that receive the JSON (sim, browser dev host) use this and then the + * matcher above. Throws on a malformed or unsupported document. */ +export function parseNetworkPolicyJson(json: string, options: ResolveNetworkPolicyOptions = {}): ResolvedNetworkPolicy { + const parsed: unknown = JSON.parse(json); + if (!isRecord(parsed)) throw new TypeError("network policy must be a JSON object"); + if (parsed.version !== undefined && parsed.version !== NETWORK_POLICY_VERSION) { + throw new TypeError(`unsupported network policy version ${String(parsed.version)}`); + } + const { version: _version, ...permissions } = parsed; + for (const key of ["connect", "listen", "credentials"] as const) { + if (permissions[key] !== undefined && !Array.isArray(permissions[key])) { + throw new TypeError(`network policy ${key} must be an array`); + } + } + for (const key of ["localNetwork", "insecureTransport", "allowInvalidTlsForDevelopment"] as const) { + if (permissions[key] !== undefined && typeof permissions[key] !== "boolean") { + throw new TypeError(`network policy ${key} must be a boolean`); + } + } + for (const key of Object.keys(permissions)) { + if (!["connect", "listen", "credentials", "localNetwork", "insecureTransport", "allowInvalidTlsForDevelopment"].includes(key)) { + throw new TypeError(`network policy has an unknown field ${JSON.stringify(key)}`); + } + } + for (const rule of [...(permissions.connect as unknown[] ?? []), ...(permissions.listen as unknown[] ?? [])]) { + if (!isRecord(rule)) throw new TypeError("network policy rules must be objects"); + } + for (const id of (permissions.credentials as unknown[] ?? [])) { + if (typeof id !== "string" || id.length === 0) throw new TypeError("network policy credential ids must be non-empty strings"); + } + const result = resolveNetworkPolicy(permissions as NetworkPermissions, { path: "", development: true, ...options }); + if (!result.ok) { + throw new TypeError(`invalid network policy: ${result.diagnostics.map((d) => `${d.path || "/"}: ${d.message}`).join("; ")}`); + } + return result.policy; +} diff --git a/contracts/spec/pocket-manifest.ts b/contracts/spec/pocket-manifest.ts index 9ef229f6..fce830d1 100644 --- a/contracts/spec/pocket-manifest.ts +++ b/contracts/spec/pocket-manifest.ts @@ -1,3 +1,4 @@ +import { networkPermissionsSchema, type NetworkPermissions } from "./network-policy.ts"; import { EXECUTION_CLASSES, PRESENTATION_MODES, @@ -6,8 +7,16 @@ import { type Viewport, } from "./platforms.ts"; +/** Format 2: the capability/viewport manifest. */ export const POCKET_MANIFEST_VERSION = 2 as const; export const POCKET_MANIFEST_SCHEMA_ID = "https://pocketjs.dev/schema/pocket-2.json"; +/** Format 3: format 2 plus the top-level `permissions` block (network + * endpoint permissions, contracts/spec/network-policy.ts). Both formats are + * accepted by the validator; the resolver produces the same plan shape for + * both (a format-2 manifest resolves to a deny-all network policy). */ +export const POCKET_MANIFEST_V3_VERSION = 3 as const; +export const POCKET_MANIFEST_V3_SCHEMA_ID = "https://pocketjs.dev/schema/pocket-3.json"; +export const POCKET_MANIFEST_VERSIONS = [POCKET_MANIFEST_VERSION, POCKET_MANIFEST_V3_VERSION] as const; export type JsonPrimitive = boolean | number | string; export type JsonValue = JsonPrimitive | null | JsonValue[] | { [key: string]: JsonValue }; @@ -77,6 +86,19 @@ export interface PocketManifestV2 { }; } +/** Format 3: format 2 fields plus `permissions`. */ +export interface PocketManifestV3 extends Omit { + readonly $schema: typeof POCKET_MANIFEST_V3_SCHEMA_ID; + readonly pocket: typeof POCKET_MANIFEST_V3_VERSION; + /** What this build may reach. Omitted blocks deny everything. */ + readonly permissions?: { + readonly network?: NetworkPermissions; + }; +} + +/** Either accepted manifest format. */ +export type PocketManifest = PocketManifestV2 | PocketManifestV3; + /** A fixed-screen viewport declaration (takeover/kiosk/embedded targets). */ export interface FixedViewportSpec { readonly logical: Viewport; @@ -108,165 +130,194 @@ const capabilityIdSchema = { pattern: "^[a-z][a-z0-9-]*(?:\\.[a-z][a-z0-9-]*)+$", } as const satisfies JsonSchema; -/** Strict format-2 application intent. Platform facts stay in target profiles. */ -export const pocketManifestV2Schema = { - $schema: "https://json-schema.org/draft/2020-12/schema", - $id: POCKET_MANIFEST_SCHEMA_ID, - title: "Pocket application manifest, format 2", - type: "object", - additionalProperties: false, - required: ["$schema", "pocket", "id", "name", "title", "version", "engine", "app"], - properties: { - $schema: { const: POCKET_MANIFEST_SCHEMA_ID }, - pocket: { const: POCKET_MANIFEST_VERSION }, - id: { - type: "string", - minLength: 3, - pattern: "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$", - }, - name: { - type: "string", - minLength: 1, - maxLength: 64, - pattern: "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$", - }, - title: { type: "string", minLength: 1, maxLength: 128 }, - version: { - type: "string", - pattern: "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$", - }, - execution: { - type: "object", - additionalProperties: false, - required: ["classes"], - properties: { - classes: { - type: "array", - items: { enum: EXECUTION_CLASSES }, - minItems: 1, - uniqueItems: true, - }, +/** Properties shared by every manifest format (identity, capabilities, app). */ +const manifestProperties = { + id: { + type: "string", + minLength: 3, + pattern: "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$", + }, + name: { + type: "string", + minLength: 1, + maxLength: 64, + pattern: "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$", + }, + title: { type: "string", minLength: 1, maxLength: 128 }, + version: { + type: "string", + pattern: "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$", + }, + execution: { + type: "object", + additionalProperties: false, + required: ["classes"], + properties: { + classes: { + type: "array", + items: { enum: EXECUTION_CLASSES }, + minItems: 1, + uniqueItems: true, }, }, - engine: { - type: "object", - additionalProperties: false, - required: ["capabilities"], - properties: { - capabilities: { - type: "object", - additionalProperties: false, - required: ["requires"], - properties: { - requires: { - type: "array", - items: capabilityIdSchema, - minItems: 1, - uniqueItems: true, - }, - enhances: { - type: "array", - items: capabilityIdSchema, - uniqueItems: true, - }, + }, + engine: { + type: "object", + additionalProperties: false, + required: ["capabilities"], + properties: { + capabilities: { + type: "object", + additionalProperties: false, + required: ["requires"], + properties: { + requires: { + type: "array", + items: capabilityIdSchema, + minItems: 1, + uniqueItems: true, + }, + enhances: { + type: "array", + items: capabilityIdSchema, + uniqueItems: true, }, }, }, }, - app: { - type: "object", - additionalProperties: false, - required: ["entry", "framework", "viewport"], - properties: { - entry: { - type: "string", - minLength: 1, - pattern: "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*\\\\).+\\.tsx?$", - }, - output: { + }, + app: { + type: "object", + additionalProperties: false, + required: ["entry", "framework", "viewport"], + properties: { + entry: { + type: "string", + minLength: 1, + pattern: "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*\\\\).+\\.tsx?$", + }, + output: { + type: "string", + minLength: 1, + maxLength: 64, + pattern: "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$", + }, + framework: { enum: ["solid", "vue-vapor", "octane"] }, + companions: { + type: "array", + items: { type: "string", minLength: 1, maxLength: 64, pattern: "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$", }, - framework: { enum: ["solid", "vue-vapor", "octane"] }, - companions: { - type: "array", - items: { - type: "string", - minLength: 1, - maxLength: 64, - pattern: "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$", - }, - uniqueItems: true, - }, - viewport: { - anyOf: [ - // Shorthand: a bare fixed viewport (format-2 compatibility). - { - type: "object", - additionalProperties: false, - required: ["logical", "presentation"], - properties: { - logical: { - type: "array", - items: { type: "integer", minimum: 1 }, - minItems: 2, - maxItems: 2, - }, - presentation: { enum: PRESENTATION_MODES }, + uniqueItems: true, + }, + viewport: { + anyOf: [ + // Shorthand: a bare fixed viewport (format-2 compatibility). + { + type: "object", + additionalProperties: false, + required: ["logical", "presentation"], + properties: { + logical: { + type: "array", + items: { type: "integer", minimum: 1 }, + minItems: 2, + maxItems: 2, }, + presentation: { enum: PRESENTATION_MODES }, }, - // Policy variants: fixed and/or dynamic. An empty object is - // schema-valid but semantically caught by the resolver - // (viewport.fixedRequired / viewport.dynamicRequired). - { - type: "object", - additionalProperties: false, - properties: { - fixed: { - type: "object", - additionalProperties: false, - required: ["logical", "presentation"], - properties: { - logical: { - type: "array", - items: { type: "integer", minimum: 1 }, - minItems: 2, - maxItems: 2, - }, - presentation: { enum: PRESENTATION_MODES }, + }, + // Policy variants: fixed and/or dynamic. An empty object is + // schema-valid but semantically caught by the resolver + // (viewport.fixedRequired / viewport.dynamicRequired). + { + type: "object", + additionalProperties: false, + properties: { + fixed: { + type: "object", + additionalProperties: false, + required: ["logical", "presentation"], + properties: { + logical: { + type: "array", + items: { type: "integer", minimum: 1 }, + minItems: 2, + maxItems: 2, }, + presentation: { enum: PRESENTATION_MODES }, }, - dynamic: { - type: "object", - additionalProperties: false, - required: ["default"], - properties: { - default: { - type: "array", - items: { type: "integer", minimum: 1 }, - minItems: 2, - maxItems: 2, - }, - min: { - type: "array", - items: { type: "integer", minimum: 1 }, - minItems: 2, - maxItems: 2, - }, - max: { - type: "array", - items: { type: "integer", minimum: 1 }, - minItems: 2, - maxItems: 2, - }, + }, + dynamic: { + type: "object", + additionalProperties: false, + required: ["default"], + properties: { + default: { + type: "array", + items: { type: "integer", minimum: 1 }, + minItems: 2, + maxItems: 2, + }, + min: { + type: "array", + items: { type: "integer", minimum: 1 }, + minItems: 2, + maxItems: 2, + }, + max: { + type: "array", + items: { type: "integer", minimum: 1 }, + minItems: 2, + maxItems: 2, }, }, }, }, - ], - }, + }, + ], + }, + }, + }, +} as const satisfies Readonly>; + +const manifestRequired = ["$schema", "pocket", "id", "name", "title", "version", "engine", "app"] as const; + +/** Strict format-2 application intent. Platform facts stay in target profiles. */ +export const pocketManifestV2Schema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: POCKET_MANIFEST_SCHEMA_ID, + title: "Pocket application manifest, format 2", + type: "object", + additionalProperties: false, + required: manifestRequired, + properties: { + $schema: { const: POCKET_MANIFEST_SCHEMA_ID }, + pocket: { const: POCKET_MANIFEST_VERSION }, + ...manifestProperties, + }, +} as const satisfies JsonSchema; + +/** Format 3: format 2 plus `permissions` (the network endpoint policy). */ +export const pocketManifestV3Schema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: POCKET_MANIFEST_V3_SCHEMA_ID, + title: "Pocket application manifest, format 3", + type: "object", + additionalProperties: false, + required: manifestRequired, + properties: { + $schema: { const: POCKET_MANIFEST_V3_SCHEMA_ID }, + pocket: { const: POCKET_MANIFEST_V3_VERSION }, + ...manifestProperties, + permissions: { + type: "object", + additionalProperties: false, + properties: { + network: networkPermissionsSchema, }, }, }, @@ -275,3 +326,7 @@ export const pocketManifestV2Schema = { export function generatePocketManifestV2Schema(): string { return JSON.stringify(pocketManifestV2Schema, null, 2) + "\n"; } + +export function generatePocketManifestV3Schema(): string { + return JSON.stringify(pocketManifestV3Schema, null, 2) + "\n"; +} diff --git a/contracts/spec/vectors/http-semantics.json b/contracts/spec/vectors/http-semantics.json new file mode 100644 index 00000000..60bc3b4f --- /dev/null +++ b/contracts/spec/vectors/http-semantics.json @@ -0,0 +1,74 @@ +{ + "$comment": "Shared conformance vectors for the HTTP semantics pinned in contracts/spec/net.ts (NET_METHODS_FORBIDDEN, HTTP_CORE_OWNED_REQUEST_HEADERS, HTTP_BODYLESS_STATUS, HTTP_NULL_BODY_STATUS, HTTP_REDIRECT_*). The SDK, the sim host, the browser host, the C core and the Rust core run the same file: `methods` are start()-time decisions (accepted = a request with this method token may start), `requestHeaders` says whether the SDK/core treats a header as core-owned (refused on a Request, stripped by a core), `status` gives the framing/null-body classification, `redirect` gives the method/body rewrite when following a redirect.", + "methods": [ + { "method": "GET", "accepted": true }, + { "method": "get", "accepted": true }, + { "method": "HEAD", "accepted": true }, + { "method": "POST", "accepted": true }, + { "method": "PUT", "accepted": true }, + { "method": "PATCH", "accepted": true }, + { "method": "DELETE", "accepted": true }, + { "method": "OPTIONS", "accepted": true }, + { "method": "PURGE", "accepted": true }, + { "method": "M-SEARCH", "accepted": true }, + { "method": "CONNECT", "accepted": false }, + { "method": "connect", "accepted": false }, + { "method": "TRACE", "accepted": false }, + { "method": "Trace", "accepted": false }, + { "method": "TRACK", "accepted": false }, + { "method": "track", "accepted": false }, + { "method": "BAD METHOD", "accepted": false }, + { "method": "GET/", "accepted": false }, + { "method": "", "accepted": false } + ], + "requestHeaders": [ + { "name": "host", "coreOwned": true }, + { "name": "Host", "coreOwned": true }, + { "name": "connection", "coreOwned": true }, + { "name": "content-length", "coreOwned": true }, + { "name": "transfer-encoding", "coreOwned": true }, + { "name": "trailer", "coreOwned": true }, + { "name": "te", "coreOwned": true }, + { "name": "upgrade", "coreOwned": true }, + { "name": "keep-alive", "coreOwned": true }, + { "name": "expect", "coreOwned": true }, + { "name": "proxy-connection", "coreOwned": true }, + { "name": "content-type", "coreOwned": false }, + { "name": "cookie", "coreOwned": false }, + { "name": "origin", "coreOwned": false }, + { "name": "user-agent", "coreOwned": false }, + { "name": "authorization", "coreOwned": false }, + { "name": "x-custom", "coreOwned": false } + ], + "status": [ + { "status": 100, "bodylessFraming": true, "nullBody": false }, + { "status": 101, "bodylessFraming": true, "nullBody": true }, + { "status": 103, "bodylessFraming": true, "nullBody": true }, + { "status": 200, "bodylessFraming": false, "nullBody": false }, + { "status": 201, "bodylessFraming": false, "nullBody": false }, + { "status": 204, "bodylessFraming": true, "nullBody": true }, + { "status": 205, "bodylessFraming": false, "nullBody": true }, + { "status": 206, "bodylessFraming": false, "nullBody": false }, + { "status": 301, "bodylessFraming": false, "nullBody": false }, + { "status": 304, "bodylessFraming": true, "nullBody": true }, + { "status": 404, "bodylessFraming": false, "nullBody": false }, + { "status": 500, "bodylessFraming": false, "nullBody": false } + ], + "redirect": [ + { "status": 301, "method": "GET", "followed": true, "nextMethod": "GET", "keepBody": true }, + { "status": 301, "method": "POST", "followed": true, "nextMethod": "GET", "keepBody": false }, + { "status": 301, "method": "PUT", "followed": true, "nextMethod": "PUT", "keepBody": true }, + { "status": 302, "method": "POST", "followed": true, "nextMethod": "GET", "keepBody": false }, + { "status": 302, "method": "DELETE", "followed": true, "nextMethod": "DELETE", "keepBody": true }, + { "status": 303, "method": "POST", "followed": true, "nextMethod": "GET", "keepBody": false }, + { "status": 303, "method": "PUT", "followed": true, "nextMethod": "GET", "keepBody": false }, + { "status": 303, "method": "GET", "followed": true, "nextMethod": "GET", "keepBody": false }, + { "status": 303, "method": "HEAD", "followed": true, "nextMethod": "HEAD", "keepBody": true }, + { "status": 307, "method": "POST", "followed": true, "nextMethod": "POST", "keepBody": true }, + { "status": 308, "method": "POST", "followed": true, "nextMethod": "POST", "keepBody": true }, + { "status": 300, "method": "GET", "followed": false }, + { "status": 304, "method": "GET", "followed": false }, + { "status": 305, "method": "GET", "followed": false }, + { "status": 306, "method": "GET", "followed": false } + ] +} diff --git a/contracts/spec/vectors/network-policy.json b/contracts/spec/vectors/network-policy.json new file mode 100644 index 00000000..ecdecda3 --- /dev/null +++ b/contracts/spec/vectors/network-policy.json @@ -0,0 +1,151 @@ +{ + "$comment": "Shared conformance vectors for the network policy (contracts/spec/network-policy.ts). Every policy parser and matcher — the TypeScript reference, engine/net (C), engine/crates/pocket-net (Rust) — must reproduce these decisions exactly. `policies` are canonical ResolvedNetworkPolicy documents; `invalid` documents must be refused by every parser; `connect`, `address` and `listen` are enforcement decisions.", + "version": 1, + "policies": { + "deny-all": { + "version": 1, + "connect": [], + "listen": [], + "credentials": [], + "localNetwork": false, + "insecureTransport": false, + "allowInvalidTlsForDevelopment": false + }, + "standard": { + "version": 1, + "connect": [ + {"protocol": "http", "host": "192.168.1.20", "port": 8080}, + {"protocol": "http", "host": "localhost", "port": {"min": 8000, "max": 8100}}, + {"protocol": "https", "host": "*.devices.example.com", "port": 443}, + {"protocol": "https", "host": "api.example.com", "port": 443}, + {"protocol": "ws", "host": "echo.example.com", "port": 80} + ], + "listen": [ + {"protocol": "http", "address": "0.0.0.0", "port": 8080}, + {"protocol": "http", "address": "127.0.0.1", "port": "ephemeral"} + ], + "credentials": ["device-cert"], + "localNetwork": true, + "insecureTransport": true, + "allowInvalidTlsForDevelopment": false + }, + "secure-only": { + "version": 1, + "connect": [ + {"protocol": "http", "host": "api.example.com", "port": 80}, + {"protocol": "https", "host": "api.example.com", "port": 443}, + {"protocol": "ws", "host": "api.example.com", "port": 80} + ], + "listen": [], + "credentials": [], + "localNetwork": false, + "insecureTransport": false, + "allowInvalidTlsForDevelopment": false + }, + "ipv6": { + "version": 1, + "connect": [ + {"protocol": "https", "host": "2001:db8::1", "port": 443} + ], + "listen": [ + {"protocol": "https", "address": "::", "port": 8443} + ], + "credentials": [], + "localNetwork": false, + "insecureTransport": false, + "allowInvalidTlsForDevelopment": false + } + }, + "invalid": [ + {"name": "bare wildcard host", "policy": {"version": 1, "connect": [{"protocol": "https", "host": "*", "port": 443}]}}, + {"name": "wildcard over an IP literal", "policy": {"version": 1, "connect": [{"protocol": "https", "host": "*.1.2.3.4", "port": 443}]}}, + {"name": "wildcard without a suffix", "policy": {"version": 1, "connect": [{"protocol": "https", "host": "*.", "port": 443}]}}, + {"name": "empty host", "policy": {"version": 1, "connect": [{"protocol": "https", "host": "", "port": 443}]}}, + {"name": "non-ASCII host", "policy": {"version": 1, "connect": [{"protocol": "https", "host": "bücher.example", "port": 443}]}}, + {"name": "host with a space", "policy": {"version": 1, "connect": [{"protocol": "https", "host": "api example.com", "port": 443}]}}, + {"name": "label starting with a hyphen", "policy": {"version": 1, "connect": [{"protocol": "https", "host": "-api.example.com", "port": 443}]}}, + {"name": "empty label", "policy": {"version": 1, "connect": [{"protocol": "https", "host": "api..example.com", "port": 443}]}}, + {"name": "IPv4 literal with leading zeros", "policy": {"version": 1, "connect": [{"protocol": "http", "host": "192.168.001.020", "port": 8080}]}}, + {"name": "hostname whose last label is numeric", "policy": {"version": 1, "connect": [{"protocol": "http", "host": "host.123", "port": 8080}]}}, + {"name": "unknown protocol", "policy": {"version": 1, "connect": [{"protocol": "ftp", "host": "files.example.com", "port": 21}]}}, + {"name": "connect port zero", "policy": {"version": 1, "connect": [{"protocol": "https", "host": "api.example.com", "port": 0}]}}, + {"name": "connect port above 65535", "policy": {"version": 1, "connect": [{"protocol": "https", "host": "api.example.com", "port": 65536}]}}, + {"name": "reversed port range", "policy": {"version": 1, "connect": [{"protocol": "https", "host": "api.example.com", "port": {"min": 9000, "max": 8000}}]}}, + {"name": "ephemeral connect port", "policy": {"version": 1, "connect": [{"protocol": "https", "host": "api.example.com", "port": "ephemeral"}]}}, + {"name": "listen on a hostname", "policy": {"version": 1, "listen": [{"protocol": "http", "address": "localhost", "port": 8080}]}}, + {"name": "listen port above 65535", "policy": {"version": 1, "listen": [{"protocol": "http", "address": "0.0.0.0", "port": 70000}]}}, + {"name": "listen port zero spelled as a number", "policy": {"version": 1, "listen": [{"protocol": "http", "address": "0.0.0.0", "port": 0}]}}, + {"name": "unsupported version", "policy": {"version": 2, "connect": []}}, + {"name": "connect is not an array", "policy": {"version": 1, "connect": {"protocol": "https", "host": "api.example.com", "port": 443}}} + ], + "connect": [ + {"policy": "standard", "protocol": "https", "host": "api.example.com", "port": 443, "allowed": true}, + {"policy": "standard", "protocol": "https", "host": "API.EXAMPLE.COM.", "port": 443, "allowed": true}, + {"policy": "standard", "protocol": "https", "host": "api.example.com", "port": 8443, "allowed": false}, + {"policy": "standard", "protocol": "http", "host": "api.example.com", "port": 443, "allowed": false}, + {"policy": "standard", "protocol": "https", "host": "a.devices.example.com", "port": 443, "allowed": true}, + {"policy": "standard", "protocol": "https", "host": "devices.example.com", "port": 443, "allowed": false}, + {"policy": "standard", "protocol": "https", "host": "a.b.devices.example.com", "port": 443, "allowed": false}, + {"policy": "standard", "protocol": "https", "host": "xdevices.example.com", "port": 443, "allowed": false}, + {"policy": "standard", "protocol": "https", "host": ".devices.example.com", "port": 443, "allowed": false}, + {"policy": "standard", "protocol": "http", "host": "192.168.1.20", "port": 8080, "allowed": true}, + {"policy": "standard", "protocol": "http", "host": "192.168.001.020", "port": 8080, "allowed": false}, + {"policy": "standard", "protocol": "http", "host": "192.168.1.20", "port": 80, "allowed": false}, + {"policy": "standard", "protocol": "http", "host": "localhost", "port": 8000, "allowed": true}, + {"policy": "standard", "protocol": "http", "host": "localhost", "port": 8050, "allowed": true}, + {"policy": "standard", "protocol": "http", "host": "LOCALHOST", "port": 8100, "allowed": true}, + {"policy": "standard", "protocol": "http", "host": "localhost", "port": 8101, "allowed": false}, + {"policy": "standard", "protocol": "http", "host": "localhost", "port": 7999, "allowed": false}, + {"policy": "standard", "protocol": "ws", "host": "echo.example.com", "port": 80, "allowed": true}, + {"policy": "standard", "protocol": "wss", "host": "echo.example.com", "port": 443, "allowed": false}, + {"policy": "standard", "protocol": "ws", "host": "echo.example.com", "port": 8080, "allowed": false}, + {"policy": "secure-only", "protocol": "https", "host": "api.example.com", "port": 443, "allowed": true}, + {"policy": "secure-only", "protocol": "http", "host": "api.example.com", "port": 80, "allowed": false}, + {"policy": "secure-only", "protocol": "ws", "host": "api.example.com", "port": 80, "allowed": false}, + {"policy": "deny-all", "protocol": "https", "host": "api.example.com", "port": 443, "allowed": false}, + {"policy": "ipv6", "protocol": "https", "host": "2001:db8::1", "port": 443, "allowed": true}, + {"policy": "ipv6", "protocol": "https", "host": "[2001:DB8:0:0:0:0:0:1]", "port": 443, "allowed": true}, + {"policy": "ipv6", "protocol": "https", "host": "2001:db8::2", "port": 443, "allowed": false} + ], + "address": [ + {"address": "8.8.8.8", "public": true, "multicast": false}, + {"address": "0.0.0.0", "public": false, "multicast": false}, + {"address": "10.0.0.1", "public": false, "multicast": false}, + {"address": "127.0.0.1", "public": false, "multicast": false}, + {"address": "169.254.1.1", "public": false, "multicast": false}, + {"address": "172.16.0.1", "public": false, "multicast": false}, + {"address": "172.31.255.255", "public": false, "multicast": false}, + {"address": "172.32.0.1", "public": true, "multicast": false}, + {"address": "192.168.0.1", "public": false, "multicast": false}, + {"address": "100.64.0.1", "public": false, "multicast": false}, + {"address": "100.127.255.255", "public": false, "multicast": false}, + {"address": "100.128.0.1", "public": true, "multicast": false}, + {"address": "224.0.0.1", "public": false, "multicast": true}, + {"address": "239.255.255.250", "public": false, "multicast": true}, + {"address": "255.255.255.255", "public": false, "multicast": false}, + {"address": "::1", "public": false, "multicast": false}, + {"address": "::", "public": false, "multicast": false}, + {"address": "fe80::1", "public": false, "multicast": false}, + {"address": "febf::1", "public": false, "multicast": false}, + {"address": "fec0::1", "public": true, "multicast": false}, + {"address": "fc00::1", "public": false, "multicast": false}, + {"address": "fd12:3456::1", "public": false, "multicast": false}, + {"address": "ff02::1", "public": false, "multicast": true}, + {"address": "2001:db8::1", "public": true, "multicast": false}, + {"address": "::ffff:10.0.0.1", "public": false, "multicast": false}, + {"address": "::ffff:8.8.8.8", "public": true, "multicast": false} + ], + "listen": [ + {"policy": "standard", "protocol": "http", "address": "0.0.0.0", "port": 8080, "allowed": true}, + {"policy": "standard", "protocol": "http", "address": "0.0.0.0", "port": 8081, "allowed": false}, + {"policy": "standard", "protocol": "http", "address": "127.0.0.1", "port": 0, "allowed": true}, + {"policy": "standard", "protocol": "http", "address": "127.0.0.1", "port": 8080, "allowed": false}, + {"policy": "standard", "protocol": "https", "address": "0.0.0.0", "port": 8080, "allowed": false}, + {"policy": "standard", "protocol": "http", "address": "::", "port": 8080, "allowed": false}, + {"policy": "secure-only", "protocol": "http", "address": "0.0.0.0", "port": 8080, "allowed": false}, + {"policy": "ipv6", "protocol": "https", "address": "::", "port": 8443, "allowed": true}, + {"policy": "ipv6", "protocol": "https", "address": "0:0:0:0:0:0:0:0", "port": 8443, "allowed": true}, + {"policy": "ipv6", "protocol": "https", "address": "::1", "port": 8443, "allowed": false}, + {"policy": "ipv6", "protocol": "http", "address": "::", "port": 8443, "allowed": false} + ] +} diff --git a/docs/NET.md b/docs/NET.md index 04ab3ef2..fa451707 100644 --- a/docs/NET.md +++ b/docs/NET.md @@ -44,9 +44,57 @@ const socket = await connect("ws://broker.example.test/telemetry", { The capability ids are registered in `contracts/spec/platforms.ts`. **No stock target advertises them yet**: a target appends an id only when its native host -ships and tests the module. Importing a module -never grants access; the host's immutable policy (connect/listen rules, -`insecureTransport`, `localNetwork`) is checked again on every command. +ships and tests the module. Importing a module never grants access: every +command is checked against the application's network policy, which the +Build Plan owns (next section). + +## Network policy: manifest → plan → host + +The policy has one author, the application manifest, and one carrier, the +Build Plan. A **format 3** `pocket.json` (`"pocket": 3`, +`https://pocketjs.dev/schema/pocket-3.json`) declares it under +`permissions.network`: + +```json +"permissions": { + "network": { + "connect": [ + { "protocol": "https", "host": "api.example.com", "port": 443 }, + { "protocol": "https", "host": "*.devices.example.com", "port": { "min": 8443, "max": 8443 } }, + { "protocol": "http", "host": "192.168.1.20", "port": 8080 } + ], + "listen": [{ "protocol": "http", "address": "0.0.0.0", "port": 8080 }], + "credentials": ["device-cert"], + "localNetwork": false, + "insecureTransport": false, + "allowInvalidTlsForDevelopment": false + } +} +``` + +`contracts/spec/network-policy.ts` is the typed contract: the rule shapes, the +normalization the resolver applies (lowercase A-label hostnames, canonical IP +literals, single-port ranges collapsed, rules sorted, exact duplicates and +reversed ranges refused, `allowInvalidTlsForDevelopment` refused outside a +development build), the reference matcher, and the canonical JSON. The +resolver writes the normalized policy into `ResolvedBuildPlan.network`, so +`planHash` covers it; a format-2 manifest resolves to the deny-all policy. +`extractHostBuildInputs()` hands custom hosts `network.policyJson` (also +`POCKETJS_NETWORK_POLICY` in the host build environment) — **the exact string +a host passes to its core** (`pnet_runtime_create` in C, `NetPolicy::parse` +in Rust, the sim hosts' `policy` option). A host never authors or widens a +policy; the ESP-IDF smoke firmware embeds the projection its plan produced +(`tools/esp-idf.ts`). + +Enforcement is the same in every core, and the shared vectors +(`contracts/spec/vectors/network-policy.json`, run by the TypeScript +reference, `pnet_unit_test` and the Rust tests) pin it: the connect rule and +`insecureTransport` before DNS; every resolved candidate address after DNS +(loopback, link-local, RFC 1918, CGNAT, ULA only with `localNetwork`, +multicast never); the listen rule before bind; the endpoint rule again on +every redirect hop. In the Rust core these wire-side decisions go through +the `PolicyGate` the backend receives with each request, and the core +refuses a response whose URL the gate did not authorize. ## Ownership @@ -54,8 +102,9 @@ never grants access; the host's immutable policy (connect/listen rules, | --- | --- | --- | | SDK | `framework/src/net/*.ts` | Fetch-shaped objects, body locking, `BodyStream` over `readInto`, the per-module guest binding (one `poll` per tick from the service pump), Promise settlement, `NetworkError` | | Spec | `contracts/spec/{net,ws,httpd}.ts` | op codes (append-only), event shapes, metadata JSON, portable ceilings, the shared error vocabulary; generated mirrors `engine/core/src/spec.rs` and `engine/net/include/pocketjs/net/spec.h` (drift-guarded by `tests/contract.ts`) | -| C core | `engine/net` | HTTP/1.1 client and server, RFC 6455 client, strict framing, bounded queues, policy, tick queues, and the TLS handshake state machine; a `pnet_driver_ops` socket driver (`drivers/posix`) and an optional `pnet_tls_ops` TLS provider (`drivers/openssl`, ESP-TLS) are the only host interfaces | -| Rust core | `engine/crates/pocket-net` | The HTTP Client core for Rust hosts over an `HttpClientBackend`; `mount` installs the six v2 ops through rquickjs | +| Spec vectors | `contracts/spec/vectors/*.json` | the policy and HTTP-semantics decisions (methods, core-owned headers, bodyless / null-body statuses, redirect rewrites) every implementation reproduces | +| C core | `engine/net` | HTTP/1.1 client and server, RFC 6455 client, strict framing, bounded queues, policy, tick queues (transactional `poll`), and the TLS handshake state machine; a `pnet_driver_ops` socket driver (`drivers/posix`, with its own resolver worker so `getaddrinfo` never blocks the network task) and an optional `pnet_tls_ops` TLS provider (`drivers/openssl`, ESP-TLS) are the only host interfaces | +| Rust core | `engine/crates/pocket-net` | The HTTP Client core for Rust hosts over an `HttpClientBackend` that receives a `PolicyGate` (address / redirect / TLS authority); `mount` installs the six v2 ops through rquickjs | | Deterministic hosts | `hosts/sim/{net,ws,httpd}.ts` | fixture routes/peers/injected requests with virtual-tick visibility for the SDK tests | | Browser host | `hosts/web/net.js` | browser `fetch` behind the v2 ops (Browser profile: no redirect following, TLS by the browser) | | ESP-IDF host | `hosts/esp-idf` | QuickJS-ng owner task, network task, bindings, AtomS3R/Tab5 bring-up, the hardware smoke | @@ -71,8 +120,11 @@ the provider owns host trust, entropy and the wire. `serverName` equals the authorized hostname and is both the SNI sent and the DNS-ID/IP-ID the certificate must match (TLS 1.2 minimum, renegotiation and 0-RTT off). Before any I/O, a verifying connection fails closed with -`tls_clock_untrusted` when the platform reports the wall clock untrusted -(the ESP host requires an SNTP/RTC sync first). Handshake failures map to the +`tls_clock_untrusted` when the platform reports the wall clock untrusted. +"Trusted" is a state the platform maintains, not a date check: the ESP-IDF +board layer latches it when an SNTP sync completes (and on every re-sync) or +when the product asserts it from a validated RTC; until then TLS fails +closed. Handshake failures map to the four stable codes `tls_certificate_invalid`, `tls_hostname_mismatch`, `tls_handshake_failed` and `tls_clock_untrusted`. @@ -96,13 +148,24 @@ once, and the SDK copies body bytes with `readInto` in the same call graph. Promise reactions run in the same tick's job drain. **The upper bound for a network round trip to reach application code is one frame period**; the per-tick budget (`maxEventsPerTick`, `maxTickBytes`) leaves excess events -queued natively in sequence order for the next tick. +queued natively in sequence order for the next tick. `poll()` is +transactional: the core sizes and reserves the batch before it dequeues a +single event, so memory pressure can delay a batch (the next poll retries) +but never drops one — a handle's terminal `end`/`error` is never lost to an +allocation failure. Hosts that marshal the batch into a guest value use the +two-phase `*_poll_render` / `*_poll_consume` and consume only once the guest +holds its copy. Body bytes never live in JS until read: the native receive queue (`queueBytes`, default 32 KiB, host-tightened on MCUs) is the backpressure -window — when it is full the core stops reading the socket and TCP flow -control holds the peer. `text()`, `json()` and `arrayBuffer()` are SDK -helpers over the same path with an aggregate cap (`response_too_large`). +window and a **hard bound** — the core reads at most the free space, and +when the queue is full it stops reading the socket so TCP flow control +holds the peer. `clone()` is a bounded tee: a branch's backlog never exceeds +the aggregate limit (each pull is sized to the remaining room). `text()`, +`json()` and `arrayBuffer()` are SDK helpers over the same path with an +aggregate cap (`response_too_large`). The browser dev host holds the same +bound through a BYOB reader where the body is a byte stream; its default +reader fallback can overshoot by one browser chunk. ## Errors diff --git a/engine/core/src/spec.rs b/engine/core/src/spec.rs index 581f750b..e58dc48b 100644 --- a/engine/core/src/spec.rs +++ b/engine/core/src/spec.rs @@ -578,7 +578,14 @@ pub mod net { pub const MAX_TIMEOUT_MS: u32 = 120000; pub const MAX_REDIRECTS: usize = 5; pub const TLS_MIN_VERSION: &str = "1.2"; - pub const METHODS_FORBIDDEN: [&str; 2] = ["CONNECT", "TRACE"]; + pub const METHODS_FORBIDDEN: [&str; 3] = ["CONNECT", "TRACE", "TRACK"]; + /// HTTP semantics shared by client, server and SDK (see net.ts). + pub const HTTP_CORE_OWNED_REQUEST_HEADERS: [&str; 10] = ["host", "connection", "content-length", "transfer-encoding", "trailer", "te", "upgrade", "keep-alive", "expect", "proxy-connection"]; + pub const HTTP_BODYLESS_STATUS: [u16; 2] = [204, 304]; + pub const HTTP_NULL_BODY_STATUS: [u16; 5] = [101, 103, 204, 205, 304]; + pub const HTTP_REDIRECT_STATUS: [u16; 5] = [301, 302, 303, 307, 308]; + pub const HTTP_REDIRECT_POST_TO_GET_STATUS: [u16; 2] = [301, 302]; + pub const HTTP_REDIRECT_ANY_TO_GET_STATUS: [u16; 1] = [303]; pub const EVENT_HEADERS: &str = "headers"; pub const EVENT_READABLE: &str = "readable"; pub const EVENT_END: &str = "end"; diff --git a/engine/crates/pocket-net/src/lib.rs b/engine/crates/pocket-net/src/lib.rs index 08a9dc65..f61d6ce4 100644 --- a/engine/crates/pocket-net/src/lib.rs +++ b/engine/crates/pocket-net/src/lib.rs @@ -15,14 +15,29 @@ //! service pump then calls `poll` exactly once; completions that arrive //! after `begin_tick` wait for the next tick. //! -//! The portable C implementation of the same boundary (engine/net) is what -//! the ESP-IDF host links; both speak the spec verbatim. +//! Security authority: the core owns the policy decisions. It checks the +//! endpoint rule and insecureTransport before the backend sees a request, +//! classifies literal addresses, and hands the backend a [`PolicyGate`] that +//! decides every wire-side question — each resolved address, each redirect +//! hop (with the spec's rewrite table and hop budget), the TLS verification +//! mode — and records what it authorized; the response URL a backend reports +//! must be one the gate authorized for that handle or the exchange fails +//! with `permission_denied`. The portable C implementation of the same +//! boundary (engine/net) applies the same rules inside its own dialer; +//! contracts/spec/vectors pin both. + +pub mod policy; use std::collections::{BTreeMap, VecDeque}; use pocketjs_core::spec::net as spec; use serde::Deserialize; +pub use policy::{ + address_is_multicast, address_is_public, hostname_valid, parse_address, resolve_url, ConnectRule, HostRule, + ListenRule, NetPolicy, PolicyGate, PortRule, Protocol, RedirectPlan, TlsVerification, +}; + /// A request handed to the backend after the core validated it. #[derive(Clone, Debug, PartialEq, Eq)] pub struct HttpRequest { @@ -95,9 +110,17 @@ pub enum BackendEvent { /// The host-specific wire layer. The core calls it only from the owner /// thread's `start`/`cancel`/`begin_tick`; a backend that runs I/O elsewhere /// hands results over through `drain` at the tick boundary. +/// +/// The `gate` is the policy authority for everything that happens on the +/// wire side: the backend must call `gate.authorize_address` for every +/// candidate address before connecting, `gate.authorize_redirect` for every +/// redirect response before following it (and follow exactly its plan), and +/// apply `gate.tls_verification` to every TLS connection. It never decides +/// those itself; the core rejects a response whose URL the gate did not +/// authorize. pub trait HttpClientBackend { /// Begin the exchange; refusal is synchronous (`resource_limit` etc.). - fn start(&mut self, request: HttpRequest) -> Result<(), NetFailure>; + fn start(&mut self, request: HttpRequest, gate: PolicyGate) -> Result<(), NetFailure>; /// Best-effort cancellation; a later completion for the handle is dropped. fn cancel(&mut self, handle: i32); /// Move every completed event into `out` (tick boundary). @@ -111,99 +134,6 @@ pub trait HttpClientBackend { fn set_paused(&mut self, _handle: i32, _paused: bool) {} } -// --------------------------------------------------------------------------- -// Policy — immutable host build inputs, never passed through an op -// --------------------------------------------------------------------------- - -#[derive(Clone, Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct NetPolicy { - #[serde(default)] - pub connect: Vec, - #[serde(default)] - pub insecure_transport: bool, - #[serde(default)] - pub local_network: bool, - #[serde(default)] - pub allow_invalid_tls_for_development: bool, -} - -#[derive(Clone, Debug, Deserialize)] -pub struct ConnectRule { - pub protocol: String, - pub host: String, - pub port: PortRule, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(untagged)] -pub enum PortRule { - Single(u16), - Range { min: u16, max: u16 }, -} - -impl NetPolicy { - pub fn parse(json: &str) -> Result { - let policy: NetPolicy = serde_json::from_str(json).map_err(|e| e.to_string())?; - for rule in &policy.connect { - if !matches!(rule.protocol.as_str(), "http" | "https" | "ws" | "wss") { - return Err(format!("unknown protocol {}", rule.protocol)); - } - if rule.host.is_empty() { - return Err("empty host".into()); - } - if let PortRule::Range { min, max } = rule.port { - if min == 0 || min > max { - return Err("invalid port range".into()); - } - } - } - Ok(policy) - } - - /// Everything allowed on plaintext HTTP to loopback (tests, dev hosts). - pub fn permissive() -> Self { - Self { - connect: vec![ConnectRule { - protocol: "http".into(), - host: "*".into(), - port: PortRule::Range { min: 1, max: 65535 }, - }], - insecure_transport: true, - local_network: true, - allow_invalid_tls_for_development: false, - } - } - - pub fn allows(&self, protocol: &str, host: &str, port: u16) -> bool { - if matches!(protocol, "http" | "ws") && !self.insecure_transport { - return false; - } - self.connect.iter().any(|rule| { - rule.protocol == protocol - && match rule.port { - PortRule::Single(p) => p == port, - PortRule::Range { min, max } => (min..=max).contains(&port), - } - && host_matches(&rule.host, host) - }) - } -} - -fn host_matches(rule: &str, host: &str) -> bool { - if rule == "*" { - return true; - } - if let Some(suffix) = rule.strip_prefix('*') { - // "*.example.com" matches exactly one non-empty label. - return host.len() > suffix.len() - && host.ends_with(suffix) - && !host[..host.len() - suffix.len()].is_empty() - && !host[..host.len() - suffix.len()].contains('.'); - } - rule.eq_ignore_ascii_case(host) -} - // --------------------------------------------------------------------------- // Limits // --------------------------------------------------------------------------- @@ -330,6 +260,7 @@ struct Handle { pub struct NetCore { backend: B, policy: NetPolicy, + gate: PolicyGate, limits: NetLimits, development_build: bool, handles: BTreeMap, @@ -347,9 +278,11 @@ impl NetCore { pub fn with_limits(backend: B, policy: NetPolicy, limits: NetLimits) -> Self { let limits = limits.clamp(); + let gate = PolicyGate::new(policy.clone(), backend.supports_tls()); let mut core = Self { backend, policy, + gate, limits, development_build: false, handles: BTreeMap::new(), @@ -367,6 +300,12 @@ impl NetCore { /// also allows it (never in production builds). pub fn set_development_build(&mut self, enabled: bool) { self.development_build = enabled; + self.gate.set_development_build(enabled); + } + + /// The policy gate (a clone is cheap): what the backend consults. + pub fn gate(&self) -> PolicyGate { + self.gate.clone() } pub fn backend_mut(&mut self) -> &mut B { @@ -444,14 +383,18 @@ impl NetCore { if scheme == "https" && !self.backend.supports_tls() { return self.refuse(spec::ERROR_UNSUPPORTED, "this host does not provide network.http.client.tls"); } - if !self.policy.allows(scheme, &host, port) { + if !self.policy.allows_connect(scheme, &host, port) { return self.refuse(spec::ERROR_PERMISSION_DENIED, "endpoint is not an allowed connect rule"); } + // A literal address skips DNS: classify it now; the refusal arrives as + // the asynchronous error event the dialer would raise (the C core + // filters candidates the same way after its own resolve). + let literal_refused = policy::parse_address(&host).is_some_and(|addr| !self.policy.allows_address(addr)); if !is_token(&meta.method) { return self.refuse(spec::ERROR_INVALID_REQUEST, "invalid method"); } let upper = meta.method.to_ascii_uppercase(); - if spec::METHODS_FORBIDDEN.contains(&upper.as_str()) || upper == "TRACK" { + if spec::METHODS_FORBIDDEN.contains(&upper.as_str()) { return self.refuse(spec::ERROR_INVALID_REQUEST, "method not allowed"); } if (upper == "GET" || upper == "HEAD") && !body.is_empty() { @@ -464,7 +407,7 @@ impl NetCore { if !is_token(&lower) || value.bytes().any(|b| (b < 0x20 && b != b'\t') || b == 0x7f) { return self.refuse(spec::ERROR_INVALID_REQUEST, format!("invalid header {name}")); } - if CORE_OWNED_HEADERS.contains(&lower.as_str()) { + if spec::HTTP_CORE_OWNED_REQUEST_HEADERS.contains(&lower.as_str()) { continue; } header_bytes += lower.len() + value.len() + 4; @@ -545,8 +488,14 @@ impl NetCore { paused: false, }, ); - if let Err(failure) = self.backend.start(request) { + self.gate.begin(handle, &request.url); + if literal_refused { + self.fail(handle, NetFailure::new(spec::ERROR_PERMISSION_DENIED, "resolved address is not permitted by the policy")); + return handle; + } + if let Err(failure) = self.backend.start(request, self.gate.clone()) { self.handles.remove(&handle); + self.gate.forget(handle); return self.refuse(&failure.code, failure.message); } handle @@ -568,6 +517,7 @@ impl NetCore { let Some(h) = self.handles.get(&handle) else { return }; if h.terminal { self.handles.remove(&handle); + self.gate.forget(handle); return; } self.backend.cancel(handle); @@ -598,6 +548,7 @@ impl NetCore { // The handle stays until the terminal event was polled? No: errors // carry no bytes, so nothing remains to read; drop it now. self.handles.remove(&handle); + self.gate.forget(handle); } /// Tick boundary: drain the backend, apply completions, freeze the @@ -653,6 +604,19 @@ impl NetCore { self.fail(handle, NetFailure::new(spec::ERROR_PROTOCOL, "malformed response head")); return; } + // The response must come from the URL the gate last authorized + // for this handle (the start URL, or the latest redirect hop the + // backend asked the gate about), and `redirected` must say so. + let authorized = self.gate.authorized_urls(handle); + let expected = authorized.last().cloned().unwrap_or_default(); + if url != expected || redirected != (authorized.len() > 1) { + self.backend.cancel(handle); + self.fail( + handle, + NetFailure::new(spec::ERROR_PERMISSION_DENIED, "response from a URL the policy gate did not authorize"), + ); + return; + } let header_bytes: usize = headers.iter().map(|(k, v)| k.len() + v.len() + 4).sum(); if headers.len() > self.limits.max_headers || header_bytes > self.limits.max_header_bytes @@ -723,6 +687,7 @@ impl NetCore { self.pending.push_back(QueuedEvent { handle, barrier: true, weight: 0, json }); if h.queue.is_empty() && !h.dirty { self.handles.remove(&handle); + self.gate.forget(handle); } } BackendEvent::Error { handle, failure } => self.fail(handle, failure), @@ -766,6 +731,7 @@ impl NetCore { } if h.terminal && h.queue.is_empty() && !h.dirty { self.handles.remove(&handle); + self.gate.forget(handle); } want as i32 } @@ -775,10 +741,6 @@ impl NetCore { // Helpers // --------------------------------------------------------------------------- -const CORE_OWNED_HEADERS: [&str; 10] = [ - "host", "connection", "content-length", "transfer-encoding", "trailer", "te", "upgrade", "keep-alive", "expect", - "proxy-connection", -]; fn is_token(s: &str) -> bool { !s.is_empty() @@ -983,6 +945,7 @@ mod tests { #[derive(Default)] struct Fixture { started: Vec, + gates: Vec, cancelled: Vec, queue: VecDeque, paused: Vec<(i32, bool)>, @@ -990,11 +953,12 @@ mod tests { } impl HttpClientBackend for Fixture { - fn start(&mut self, request: HttpRequest) -> Result<(), NetFailure> { + fn start(&mut self, request: HttpRequest, gate: PolicyGate) -> Result<(), NetFailure> { if self.refuse { return Err(NetFailure::new(spec::ERROR_RESOURCE_LIMIT, "no sockets")); } self.started.push(request); + self.gates.push(gate); Ok(()) } fn cancel(&mut self, handle: i32) { @@ -1131,6 +1095,251 @@ mod tests { assert!(core.limits().contains("\"features\":[]")); } + #[test] + fn literal_addresses_are_classified_without_dns() { + let strict = NetPolicy::parse( + r#"{"version":1,"connect":[{"protocol":"http","host":"10.0.0.5","port":80},{"protocol":"http","host":"93.184.216.34","port":80}],"insecureTransport":true,"localNetwork":false}"#, + ) + .unwrap(); + let mut core = NetCore::new(Fixture::default(), strict); + // Private literal under localNetwork:false: admitted synchronously, + // refused with the asynchronous permission_denied the dialer raises. + let h = core.start(r#"{"url":"http://10.0.0.5/","method":"GET"}"#, &[]); + assert!(h > 0); + assert!(core.backend_mut().started.is_empty(), "the backend never sees it"); + core.begin_tick(); + let batch = core.poll().unwrap(); + assert!(batch.contains("\"code\":\"permission_denied\""), "{batch}"); + // A public literal starts. + assert!(core.start(r#"{"url":"http://93.184.216.34/","method":"GET"}"#, &[]) > 0); + assert_eq!(core.backend_mut().started.len(), 1); + } + + #[test] + fn the_gate_decides_addresses_redirects_and_tls_and_the_core_checks_the_response_url() { + let policy = NetPolicy::parse( + r#"{"version":1,"connect":[{"protocol":"http","host":"example.test","port":80},{"protocol":"http","host":"next.test","port":80},{"protocol":"https","host":"secure.test","port":443}],"insecureTransport":true,"localNetwork":false}"#, + ) + .unwrap(); + let mut core = NetCore::new(Fixture::default(), policy); + let h = core.start(r#"{"url":"http://example.test/start","method":"POST","headers":{}}"#, b"body"); + assert!(h > 0); + let gate = core.backend_mut().gates[0].clone(); + // Addresses: public yes, private no, multicast never. + assert!(gate.authorize_address("93.184.216.34".parse().unwrap()).is_ok()); + assert_eq!(gate.authorize_address("10.1.2.3".parse().unwrap()).unwrap_err().code, spec::ERROR_PERMISSION_DENIED); + assert_eq!(gate.authorize_address("224.0.0.1".parse().unwrap()).unwrap_err().code, spec::ERROR_PERMISSION_DENIED); + // Redirects: the spec table (302 POST → GET without body), the + // endpoint policy on the target, the budget, the scheme, TLS. + assert_eq!( + gate.authorize_redirect(h, "http://example.test/start", "POST", 302, Some("http://next.test/landed"), 3, RedirectMode::Follow), + Ok(RedirectPlan::Follow { url: "http://next.test/landed".into(), method: "GET".into(), drop_body: true }) + ); + assert_eq!( + gate.authorize_redirect(h, "http://next.test/landed", "GET", 307, Some("/again?x=1"), 2, RedirectMode::Follow), + Ok(RedirectPlan::Follow { url: "http://next.test/again?x=1".into(), method: "GET".into(), drop_body: false }) + ); + assert_eq!( + gate.authorize_redirect(h, "http://next.test/a", "GET", 301, Some("http://evil.test/"), 1, RedirectMode::Follow).unwrap_err().code, + spec::ERROR_PERMISSION_DENIED + ); + assert_eq!( + gate.authorize_redirect(h, "http://next.test/a", "GET", 301, Some("http://next.test/b"), 0, RedirectMode::Follow).unwrap_err().code, + spec::ERROR_REDIRECT + ); + assert_eq!( + gate.authorize_redirect(h, "http://next.test/a", "GET", 301, Some("https://secure.test/"), 1, RedirectMode::Follow).unwrap_err().code, + spec::ERROR_UNSUPPORTED, + "https without a TLS-capable backend" + ); + assert_eq!( + gate.authorize_redirect(h, "http://next.test/a", "GET", 301, Some("ftp://next.test/"), 1, RedirectMode::Follow).unwrap_err().code, + spec::ERROR_REDIRECT + ); + assert_eq!(gate.authorize_redirect(h, "http://next.test/a", "GET", 200, None, 1, RedirectMode::Follow), Ok(RedirectPlan::Deliver)); + assert_eq!(gate.authorize_redirect(h, "http://next.test/a", "GET", 302, Some("/x"), 1, RedirectMode::Manual), Ok(RedirectPlan::Deliver)); + assert_eq!( + gate.authorize_redirect(h, "http://next.test/a", "GET", 302, Some("/x"), 1, RedirectMode::Error).unwrap_err().code, + spec::ERROR_REDIRECT + ); + // TLS: verify unless policy + build + request all ask otherwise. + assert!(gate.tls_verification("secure.test", true).verify_peer); + assert_eq!(gate.tls_verification("secure.test", false).min_version, spec::TLS_MIN_VERSION); + // The core accepts the response only from the last authorized hop, + // and only with `redirected` set. + let mut h2 = BTreeMap::new(); + h2.insert("content-type".to_string(), "text/plain".to_string()); + core.backend_mut().queue.push_back(BackendEvent::Headers { + handle: h, + status: 200, + url: "http://elsewhere.test/".into(), + headers: h2.clone(), + redirected: true, + length: Some(0), + }); + core.begin_tick(); + let batch = core.poll().unwrap(); + assert!(batch.contains("\"code\":\"permission_denied\""), "{batch}"); + // A fresh request answered from an authorized hop passes. + let h = core.start(r#"{"url":"http://example.test/start","method":"GET","headers":{}}"#, &[]); + let gate = core.backend_mut().gates.last().unwrap().clone(); + gate.authorize_redirect(h, "http://example.test/start", "GET", 301, Some("http://next.test/landed"), 5, RedirectMode::Follow).unwrap(); + core.backend_mut().queue.push_back(BackendEvent::Headers { + handle: h, + status: 200, + url: "http://next.test/landed".into(), + headers: h2, + redirected: true, + length: Some(0), + }); + core.backend_mut().queue.push_back(BackendEvent::End { handle: h }); + core.begin_tick(); + let batch = core.poll().unwrap(); + assert!(batch.contains("\"t\":\"headers\"") && batch.contains("\"redirected\":true"), "{batch}"); + } + + #[derive(Deserialize)] + struct PolicyVectors { + policies: BTreeMap, + invalid: Vec, + connect: Vec, + address: Vec, + listen: Vec, + } + #[derive(Deserialize)] + struct InvalidVector { + name: String, + policy: serde_json::Value, + } + #[derive(Deserialize)] + struct ConnectVector { + policy: String, + protocol: String, + host: String, + port: u16, + allowed: bool, + } + #[derive(Deserialize)] + struct AddressVector { + address: String, + public: bool, + multicast: bool, + } + #[derive(Deserialize)] + struct ListenVector { + policy: String, + protocol: String, + address: String, + port: u16, + allowed: bool, + } + + #[test] + fn shared_policy_vectors() { + let vectors: PolicyVectors = + serde_json::from_str(include_str!("../../../../contracts/spec/vectors/network-policy.json")).unwrap(); + let mut policies = BTreeMap::new(); + for (name, doc) in &vectors.policies { + policies.insert(name.clone(), NetPolicy::parse(&doc.to_string()).unwrap_or_else(|e| panic!("{name}: {e}"))); + } + for v in &vectors.invalid { + assert!(NetPolicy::parse(&v.policy.to_string()).is_err(), "invalid vector accepted: {}", v.name); + } + for v in &vectors.connect { + let policy = &policies[&v.policy]; + assert_eq!(policy.allows_connect(&v.protocol, &v.host, v.port), v.allowed, "connect {:?}", (&v.policy, &v.protocol, &v.host, v.port)); + } + let open = &policies["standard"]; + let closed = &policies["secure-only"]; + for v in &vectors.address { + let addr = policy::parse_address(&v.address).unwrap_or_else(|| panic!("{}", v.address)); + assert_eq!(policy::address_is_public(addr), v.public, "{}", v.address); + assert_eq!(policy::address_is_multicast(addr), v.multicast, "{}", v.address); + assert_eq!(closed.allows_address(addr), v.public, "{}", v.address); + assert_eq!(open.allows_address(addr), !v.multicast, "{}", v.address); + } + for v in &vectors.listen { + let policy = &policies[&v.policy]; + assert_eq!(policy.allows_listen(&v.protocol, &v.address, v.port), v.allowed, "listen {:?}", (&v.policy, &v.protocol, &v.address, v.port)); + } + } + + #[derive(Deserialize)] + struct SemanticsVectors { + methods: Vec, + #[serde(rename = "requestHeaders")] + request_headers: Vec, + status: Vec, + redirect: Vec, + } + #[derive(Deserialize)] + struct MethodVector { + method: String, + accepted: bool, + } + #[derive(Deserialize)] + struct HeaderVector { + name: String, + #[serde(rename = "coreOwned")] + core_owned: bool, + } + #[derive(Deserialize)] + struct StatusVector { + status: u16, + #[serde(rename = "bodylessFraming")] + bodyless_framing: bool, + #[serde(rename = "nullBody")] + null_body: bool, + } + #[derive(Deserialize)] + struct RedirectVector { + status: u16, + method: String, + followed: bool, + #[serde(rename = "nextMethod")] + next_method: Option, + #[serde(rename = "keepBody")] + keep_body: Option, + } + + #[test] + fn shared_http_semantics_vectors() { + let vectors: SemanticsVectors = + serde_json::from_str(include_str!("../../../../contracts/spec/vectors/http-semantics.json")).unwrap(); + for v in &vectors.methods { + let mut core = core(); + let meta = serde_json::json!({"url": "http://example.test/", "method": v.method, "headers": {}}).to_string(); + let h = core.start(&meta, &[]); + assert_eq!(h > 0, v.accepted, "method {:?}", v.method); + } + for v in &vectors.request_headers { + let mut core = core(); + let meta = serde_json::json!({"url": "http://example.test/", "method": "GET", "headers": {v.name.clone(): "v"}}).to_string(); + assert!(core.start(&meta, &[]) > 0); + let sent = &core.backend_mut().started[0].headers; + assert_eq!(!sent.contains_key(&v.name.to_ascii_lowercase()), v.core_owned, "header {}", v.name); + } + for v in &vectors.status { + let framing = (100..200).contains(&v.status) || spec::HTTP_BODYLESS_STATUS.contains(&v.status); + assert_eq!(framing, v.bodyless_framing, "framing {}", v.status); + assert_eq!(spec::HTTP_NULL_BODY_STATUS.contains(&v.status), v.null_body, "null body {}", v.status); + } + let gate = PolicyGate::new(NetPolicy::permissive(), false); + for v in &vectors.redirect { + gate.begin(1, "http://example.test/a"); + let plan = gate.authorize_redirect(1, "http://example.test/a", &v.method, v.status, Some("http://example.test/b"), 5, RedirectMode::Follow); + match plan { + Ok(RedirectPlan::Follow { method, drop_body, .. }) => { + assert!(v.followed, "{} {} followed", v.status, v.method); + assert_eq!(&method, v.next_method.as_ref().unwrap(), "{} {}", v.status, v.method); + assert_eq!(!drop_body, v.keep_body.unwrap(), "{} {} body", v.status, v.method); + } + Ok(RedirectPlan::Deliver) => assert!(!v.followed, "{} {} delivered", v.status, v.method), + Err(e) => panic!("{} {}: {:?}", v.status, v.method, e.code), + } + } + } + #[cfg(feature = "mount")] #[test] fn mounts_the_v2_ops() { diff --git a/engine/crates/pocket-net/src/policy.rs b/engine/crates/pocket-net/src/policy.rs new file mode 100644 index 00000000..1cda6425 --- /dev/null +++ b/engine/crates/pocket-net/src/policy.rs @@ -0,0 +1,621 @@ +//! The network policy and its enforcement gate. +//! +//! `NetPolicy` parses exactly the canonical `ResolvedNetworkPolicy` document +//! the Build Plan resolver emits (contracts/spec/network-policy.ts, version +//! 1) — the same shapes engine/net's `pnet_policy_parse` accepts — and +//! decides connect rules, listen rules and address classification with the +//! reference semantics; contracts/spec/vectors/network-policy.json pins them +//! (see the tests at the bottom of lib.rs). +//! +//! `PolicyGate` is the authority a backend cannot route around: the core +//! hands one clone to the backend with every request, and the backend must +//! ask it for every decision that happens on the wire side of the core — +//! each resolved candidate address (`authorize_address`), each redirect hop +//! (`authorize_redirect`, which also applies the spec's method/body rewrite +//! table and the hop budget) and the TLS verification mode +//! (`tls_verification`). The gate records the URLs it authorized per handle; +//! when the backend reports response headers the core checks the response +//! URL against that record, so a backend that followed a hop on its own +//! (or answered from somewhere else) fails the exchange with +//! `permission_denied` instead of smuggling the response through. Backends +//! therefore implement transport, not policy; the rules live here once. + +use std::collections::BTreeMap; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use pocketjs_core::spec::net as spec; +use serde::Deserialize; + +use crate::{NetFailure, RedirectMode}; + +pub const NETWORK_POLICY_VERSION: u64 = 1; + +// --------------------------------------------------------------------------- +// Document +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PolicyDocument { + #[serde(default)] + version: Option, + #[serde(default)] + connect: Vec, + #[serde(default)] + listen: Vec, + #[serde(default)] + credentials: Vec, + #[serde(default, rename = "localNetwork")] + local_network: bool, + #[serde(default, rename = "insecureTransport")] + insecure_transport: bool, + #[serde(default, rename = "allowInvalidTlsForDevelopment")] + allow_invalid_tls_for_development: bool, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RuleDocument { + protocol: String, + #[serde(default)] + host: Option, + #[serde(default)] + address: Option, + port: serde_json::Value, +} + +/// `http` / `https` / `ws` / `wss`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Protocol { + Http, + Https, + Ws, + Wss, +} + +impl Protocol { + pub fn parse(scheme: &str) -> Option { + match scheme { + "http" => Some(Protocol::Http), + "https" => Some(Protocol::Https), + "ws" => Some(Protocol::Ws), + "wss" => Some(Protocol::Wss), + _ => None, + } + } + pub fn is_plaintext(self) -> bool { + matches!(self, Protocol::Http | Protocol::Ws) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PortRule { + Single(u16), + Range { min: u16, max: u16 }, + /// Listen only: bind port 0. + Ephemeral, +} + +impl PortRule { + pub fn matches(self, port: u16) -> bool { + match self { + PortRule::Single(p) => p == port, + PortRule::Range { min, max } => (min..=max).contains(&port), + PortRule::Ephemeral => port == 0, + } + } +} + +/// What a rule's host matches. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum HostRule { + /// A lowercase ASCII DNS name, compared exactly. + Name(String), + /// `*.suffix`: exactly one extra label. + Wildcard(String), + /// An IP literal, compared by address. + Address(IpAddr), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ConnectRule { + pub protocol: Protocol, + pub host: HostRule, + pub port: PortRule, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ListenRule { + pub protocol: Protocol, + pub address: IpAddr, + pub port: PortRule, +} + +/// The immutable policy (one ResolvedNetworkPolicy). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NetPolicy { + pub connect: Vec, + pub listen: Vec, + pub credentials: Vec, + pub local_network: bool, + pub insecure_transport: bool, + pub allow_invalid_tls_for_development: bool, +} + +// --------------------------------------------------------------------------- +// Hostnames and addresses (mirrors contracts/spec/network-policy.ts) +// --------------------------------------------------------------------------- + +/// Lowercase ASCII DNS name: labels of [a-z0-9-], 1..63 bytes, not starting +/// or ending with '-', whole name <= 253 bytes, last label not all digits. +pub fn hostname_valid(name: &str) -> bool { + if name.is_empty() || name.len() > 253 { + return false; + } + let labels: Vec<&str> = name.split('.').collect(); + if labels.iter().any(|label| { + label.is_empty() + || label.len() > 63 + || label.starts_with('-') + || label.ends_with('-') + || !label.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + }) { + return false; + } + // A name whose last label is all digits is a malformed IPv4 literal. + !labels[labels.len() - 1].bytes().all(|b| b.is_ascii_digit()) +} + +/// Parse an IP literal (`1.2.3.4`, `::1`, `[::1]`); IPv4 octets with leading +/// zeros are refused (octal to some resolvers, decimal to others). +pub fn parse_address(text: &str) -> Option { + let body = text.strip_prefix('[').and_then(|t| t.strip_suffix(']')).unwrap_or(text); + if body.contains(':') { + return body.parse::().ok().map(IpAddr::V6); + } + let parts: Vec<&str> = body.split('.').collect(); + if parts.len() != 4 { + return None; + } + let mut octets = [0u8; 4]; + for (i, part) in parts.iter().enumerate() { + if part.is_empty() || part.len() > 3 || !part.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + if part.len() > 1 && part.starts_with('0') { + return None; + } + octets[i] = part.parse::().ok().filter(|v| *v <= 255)? as u8; + } + Some(IpAddr::V4(Ipv4Addr::from(octets))) +} + +/// Lowercase, drop one trailing root dot; None when not a valid name. +pub fn normalize_hostname(host: &str) -> Option { + if !host.bytes().all(|b| (0x21..=0x7e).contains(&b)) { + return None; + } + let mut lower = host.to_ascii_lowercase(); + if lower.len() > 1 && lower.ends_with('.') { + lower.pop(); + } + if hostname_valid(&lower) { + Some(lower) + } else { + None + } +} + +pub fn address_is_multicast(addr: IpAddr) -> bool { + match addr { + IpAddr::V4(v4) => (v4.octets()[0] & 0xf0) == 0xe0, + IpAddr::V6(v6) => v6.octets()[0] == 0xff, + } +} + +/// Globally routable unicast, the classification shared with engine/net's +/// pnet_addr_is_public and the TypeScript reference. +pub fn address_is_public(addr: IpAddr) -> bool { + match addr { + IpAddr::V4(v4) => { + let a = v4.octets(); + !(a[0] == 0 + || a[0] == 10 + || a[0] == 127 + || (a[0] == 169 && a[1] == 254) + || (a[0] == 172 && (a[1] & 0xf0) == 16) + || (a[0] == 192 && a[1] == 168) + || (a[0] == 100 && (a[1] & 0xc0) == 64) + || (a[0] & 0xf0) == 0xe0 + || a == [255, 255, 255, 255]) + } + IpAddr::V6(v6) => { + let a = v6.octets(); + if a[..15].iter().all(|b| *b == 0) && (a[15] == 0 || a[15] == 1) { + return false; + } + if a[0] == 0xfe && (a[1] & 0xc0) == 0x80 { + return false; + } + if (a[0] & 0xfe) == 0xfc { + return false; + } + if a[0] == 0xff { + return false; + } + if a[..10].iter().all(|b| *b == 0) && a[10] == 0xff && a[11] == 0xff { + return address_is_public(IpAddr::V4(Ipv4Addr::new(a[12], a[13], a[14], a[15]))); + } + true + } + } +} + +impl HostRule { + fn parse(text: &str) -> Option { + if let Some(addr) = parse_address(text) { + return Some(HostRule::Address(addr)); + } + if !text.bytes().all(|b| (0x21..=0x7e).contains(&b)) { + return None; + } + let lower = text.to_ascii_lowercase(); + let lower = if lower.len() > 1 && lower.ends_with('.') { &lower[..lower.len() - 1] } else { &lower[..] }; + if let Some(suffix) = lower.strip_prefix("*.") { + if parse_address(suffix).is_some() || !hostname_valid(suffix) { + return None; + } + return Some(HostRule::Wildcard(suffix.to_string())); + } + if lower.starts_with('*') { + return None; // a bare `*` or `*foo` is not a rule + } + if hostname_valid(lower) { + Some(HostRule::Name(lower.to_string())) + } else { + None + } + } + + /// `host` as the URL parser hands it over (brackets allowed). + pub fn matches(&self, host: &str) -> bool { + match self { + HostRule::Address(addr) => parse_address(host) == Some(*addr), + HostRule::Name(name) => normalize_hostname(host).as_deref() == Some(name.as_str()), + HostRule::Wildcard(suffix) => match normalize_hostname(host) { + Some(target) => { + target.len() > suffix.len() + 1 + && target.ends_with(suffix) + && target.as_bytes()[target.len() - suffix.len() - 1] == b'.' + && !target[..target.len() - suffix.len() - 1].contains('.') + } + None => false, + }, + } + } +} + +fn parse_port(value: &serde_json::Value, listen: bool) -> Option { + match value { + serde_json::Value::Number(n) => { + let v = n.as_u64()?; + if (1..=65535).contains(&v) { Some(PortRule::Single(v as u16)) } else { None } + } + serde_json::Value::String(s) if listen && s == "ephemeral" => Some(PortRule::Ephemeral), + serde_json::Value::Object(map) => { + let min = map.get("min")?.as_u64()?; + let max = map.get("max")?.as_u64()?; + if map.len() != 2 || min < 1 || max > 65535 || min > max { + return None; + } + Some(PortRule::Range { min: min as u16, max: max as u16 }) + } + _ => None, + } +} + +impl NetPolicy { + /// Parse the canonical policy JSON; `Err` names the first fault. + pub fn parse(json: &str) -> Result { + let doc: PolicyDocument = serde_json::from_str(json).map_err(|e| e.to_string())?; + if let Some(version) = doc.version { + if version != NETWORK_POLICY_VERSION { + return Err(format!("unsupported network policy version {version}")); + } + } + let mut connect = Vec::with_capacity(doc.connect.len()); + for (i, rule) in doc.connect.iter().enumerate() { + let protocol = Protocol::parse(&rule.protocol).ok_or_else(|| format!("connect[{i}]: unknown protocol"))?; + let host = rule.host.as_deref().ok_or_else(|| format!("connect[{i}]: host missing"))?; + let host = HostRule::parse(host).ok_or_else(|| format!("connect[{i}]: invalid host"))?; + let port = parse_port(&rule.port, false).ok_or_else(|| format!("connect[{i}]: invalid port"))?; + if rule.address.is_some() { + return Err(format!("connect[{i}]: unexpected address")); + } + connect.push(ConnectRule { protocol, host, port }); + } + let mut listen = Vec::with_capacity(doc.listen.len()); + for (i, rule) in doc.listen.iter().enumerate() { + let protocol = Protocol::parse(&rule.protocol).ok_or_else(|| format!("listen[{i}]: unknown protocol"))?; + let address = rule.address.as_deref().ok_or_else(|| format!("listen[{i}]: address missing"))?; + let address = parse_address(address).ok_or_else(|| format!("listen[{i}]: address must be an IP literal"))?; + let port = parse_port(&rule.port, true).ok_or_else(|| format!("listen[{i}]: invalid port"))?; + if rule.host.is_some() { + return Err(format!("listen[{i}]: unexpected host")); + } + listen.push(ListenRule { protocol, address, port }); + } + if doc.credentials.iter().any(|c| c.is_empty()) { + return Err("credentials: empty id".into()); + } + Ok(NetPolicy { + connect, + listen, + credentials: doc.credentials, + local_network: doc.local_network, + insecure_transport: doc.insecure_transport, + allow_invalid_tls_for_development: doc.allow_invalid_tls_for_development, + }) + } + + /// A development/test policy: plaintext and TLS to loopback names and + /// `*.test` on any port, local network allowed. Never a bare wildcard — + /// the contract has none. + pub fn permissive() -> Self { + let any = PortRule::Range { min: 1, max: 65535 }; + let hosts = [ + HostRule::Name("localhost".into()), + HostRule::Address(IpAddr::V4(Ipv4Addr::LOCALHOST)), + HostRule::Address(IpAddr::V6(Ipv6Addr::LOCALHOST)), + HostRule::Wildcard("test".into()), + ]; + let mut connect = Vec::new(); + for protocol in [Protocol::Http, Protocol::Https, Protocol::Ws, Protocol::Wss] { + for host in &hosts { + connect.push(ConnectRule { protocol, host: host.clone(), port: any }); + } + } + Self { + connect, + listen: vec![ + ListenRule { protocol: Protocol::Http, address: IpAddr::V4(Ipv4Addr::LOCALHOST), port: any }, + ListenRule { protocol: Protocol::Http, address: IpAddr::V4(Ipv4Addr::LOCALHOST), port: PortRule::Ephemeral }, + ], + credentials: Vec::new(), + local_network: true, + insecure_transport: true, + allow_invalid_tls_for_development: false, + } + } + + /// Endpoint rule + insecureTransport, before DNS. + pub fn allows_connect(&self, scheme: &str, host: &str, port: u16) -> bool { + let Some(protocol) = Protocol::parse(scheme) else { return false }; + if protocol.is_plaintext() && !self.insecure_transport { + return false; + } + self.connect + .iter() + .any(|rule| rule.protocol == protocol && rule.port.matches(port) && rule.host.matches(host)) + } + + /// A resolved candidate address: public, or local with `localNetwork`; + /// multicast never. + pub fn allows_address(&self, addr: IpAddr) -> bool { + if address_is_multicast(addr) { + return false; + } + address_is_public(addr) || self.local_network + } + + pub fn allows_listen(&self, scheme: &str, address: &str, port: u16) -> bool { + let Some(protocol) = Protocol::parse(scheme) else { return false }; + if protocol.is_plaintext() && !self.insecure_transport { + return false; + } + let Some(addr) = parse_address(address) else { return false }; + self.listen + .iter() + .any(|rule| rule.protocol == protocol && rule.address == addr && rule.port.matches(port)) + } + + pub fn has_credential(&self, id: &str) -> bool { + self.credentials.iter().any(|c| c == id) + } +} + +// --------------------------------------------------------------------------- +// Gate +// --------------------------------------------------------------------------- + +/// The plan a redirect gets from the gate. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RedirectPlan { + /// Not a redirect the client follows here (no redirect status, no + /// Location, or `redirect: "manual"`): deliver the response as it is. + Deliver, + /// Follow: the next hop's absolute URL, the method to use, and whether + /// the request body is dropped (303 for everything but HEAD, 301/302 + /// for POST). + Follow { url: String, method: String, drop_body: bool }, +} + +/// TLS verification the backend must apply to a connection. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TlsVerification { + /// Verify the chain and the hostname (DNS-ID) — always, except the + /// development-insecure case the policy + build admitted. + pub verify_peer: bool, + /// SNI / DNS-ID: the authorized hostname. + pub server_name: String, + pub min_version: &'static str, +} + +struct GateInner { + policy: NetPolicy, + development_build: AtomicBool, + tls_available: bool, + /// Per handle: every URL the gate authorized, in order (the first entry + /// is the start URL). + hops: Mutex>>, +} + +/// Clone-cheap, `Send + Sync`: a backend keeps one and consults it from +/// whatever thread runs its I/O. +#[derive(Clone)] +pub struct PolicyGate { + inner: Arc, +} + +impl PolicyGate { + pub(crate) fn new(policy: NetPolicy, tls_available: bool) -> Self { + Self { + inner: Arc::new(GateInner { + policy, + development_build: AtomicBool::new(false), + tls_available, + hops: Mutex::new(BTreeMap::new()), + }), + } + } + + pub(crate) fn set_development_build(&self, enabled: bool) { + self.inner.development_build.store(enabled, Ordering::SeqCst); + } + + pub fn policy(&self) -> &NetPolicy { + &self.inner.policy + } + + /// The core records the start URL when it admits a request. + pub(crate) fn begin(&self, handle: i32, url: &str) { + self.inner.hops.lock().unwrap().insert(handle, vec![url.to_string()]); + } + + pub(crate) fn forget(&self, handle: i32) { + self.inner.hops.lock().unwrap().remove(&handle); + } + + /// The URLs authorized for `handle` so far (start URL first). + pub fn authorized_urls(&self, handle: i32) -> Vec { + self.inner.hops.lock().unwrap().get(&handle).cloned().unwrap_or_default() + } + + /// Endpoint rule + insecureTransport for an arbitrary tuple (proxies, + /// alternate services). The core already ran it for the start URL. + pub fn authorize_endpoint(&self, scheme: &str, host: &str, port: u16) -> Result<(), NetFailure> { + if self.inner.policy.allows_connect(scheme, host, port) { + Ok(()) + } else { + Err(NetFailure::new(spec::ERROR_PERMISSION_DENIED, "endpoint is not an allowed connect rule")) + } + } + + /// Every candidate address the resolver produced, before connecting to + /// it: loopback / link-local / private / CGNAT / ULA only with + /// localNetwork, multicast never. + pub fn authorize_address(&self, addr: IpAddr) -> Result<(), NetFailure> { + if self.inner.policy.allows_address(addr) { + Ok(()) + } else { + Err(NetFailure::new(spec::ERROR_PERMISSION_DENIED, "resolved address is not permitted by the policy")) + } + } + + /// The redirect decision for a response: the spec's followed statuses + /// and rewrite table, the hop budget, the scheme and TLS availability, + /// the endpoint policy for the target — recorded for the core's check. + #[allow(clippy::too_many_arguments)] + pub fn authorize_redirect( + &self, + handle: i32, + from_url: &str, + method: &str, + status: u16, + location: Option<&str>, + redirects_left: u32, + mode: RedirectMode, + ) -> Result { + if !spec::HTTP_REDIRECT_STATUS.contains(&status) { + return Ok(RedirectPlan::Deliver); + } + let Some(location) = location else { return Ok(RedirectPlan::Deliver) }; + match mode { + RedirectMode::Manual => return Ok(RedirectPlan::Deliver), + RedirectMode::Error => return Err(NetFailure::new(spec::ERROR_REDIRECT, "redirect refused by policy")), + RedirectMode::Follow => {} + } + if redirects_left == 0 { + return Err(NetFailure::new(spec::ERROR_REDIRECT, "too many redirects")); + } + let Some(next) = resolve_url(from_url, location) else { + return Err(NetFailure::new(spec::ERROR_REDIRECT, "invalid Location")); + }; + let Some((scheme, host, port)) = crate::parse_url(&next) else { + return Err(NetFailure::new(spec::ERROR_REDIRECT, "invalid Location")); + }; + if scheme != "http" && scheme != "https" { + return Err(NetFailure::new(spec::ERROR_REDIRECT, "redirect to a non-HTTP scheme")); + } + if scheme == "https" && !self.inner.tls_available { + return Err(NetFailure::new(spec::ERROR_UNSUPPORTED, "redirect to https without network.http.client.tls")); + } + if !self.inner.policy.allows_connect(scheme, &host, port) { + return Err(NetFailure::new(spec::ERROR_PERMISSION_DENIED, "redirect target is not an allowed endpoint")); + } + let upper = method.to_ascii_uppercase(); + let to_get = (spec::HTTP_REDIRECT_ANY_TO_GET_STATUS.contains(&status) && upper != "HEAD") + || (spec::HTTP_REDIRECT_POST_TO_GET_STATUS.contains(&status) && upper == "POST"); + self.inner.hops.lock().unwrap().entry(handle).or_default().push(next.clone()); + Ok(RedirectPlan::Follow { + url: next, + method: if to_get { "GET".to_string() } else { method.to_string() }, + drop_body: to_get, + }) + } + + /// Verification for a TLS connection to `server_name`; the + /// development-insecure mode applies only when the policy, the build and + /// the request all asked for it. + pub fn tls_verification(&self, server_name: &str, development_insecure_requested: bool) -> TlsVerification { + let insecure = development_insecure_requested + && self.inner.development_build.load(Ordering::SeqCst) + && self.inner.policy.allow_invalid_tls_for_development; + TlsVerification { verify_peer: !insecure, server_name: server_name.to_string(), min_version: spec::TLS_MIN_VERSION } + } +} + +/// Resolve a Location against the current URL: absolute, scheme-relative, +/// path-absolute, or relative to the current path's directory. Query and +/// fragment of the Location are kept; the base's are dropped. +pub fn resolve_url(base: &str, location: &str) -> Option { + let location = location.trim(); + if location.is_empty() { + return None; + } + if location.contains("://") { + return Some(location.to_string()); + } + let (scheme, rest) = base.split_once("://")?; + let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + let authority = &rest[..authority_end]; + if let Some(stripped) = location.strip_prefix("//") { + return Some(format!("{scheme}://{stripped}")); + } + let base_path = { + let after = &rest[authority_end..]; + let end = after.find(['?', '#']).unwrap_or(after.len()); + let path = &after[..end]; + if path.is_empty() { "/" } else { path } + }; + if location.starts_with('/') { + return Some(format!("{scheme}://{authority}{location}")); + } + let dir = match base_path.rfind('/') { + Some(i) => &base_path[..=i], + None => "/", + }; + Some(format!("{scheme}://{authority}{dir}{location}")) +} diff --git a/engine/net/include/pocketjs/net/spec.h b/engine/net/include/pocketjs/net/spec.h index 9b88ebb9..b7e280e1 100644 --- a/engine/net/include/pocketjs/net/spec.h +++ b/engine/net/include/pocketjs/net/spec.h @@ -32,8 +32,21 @@ #define PNET_MAX_TIMEOUT_MS 120000 #define PNET_MAX_REDIRECTS 5 #define PNET_TLS_MIN_VERSION "1.2" -#define PNET_METHODS_FORBIDDEN_COUNT 2 -#define PNET_METHODS_FORBIDDEN { "CONNECT", "TRACE" } +#define PNET_METHODS_FORBIDDEN_COUNT 3 +#define PNET_METHODS_FORBIDDEN { "CONNECT", "TRACE", "TRACK" } +/* HTTP semantics shared by client, server and SDK (see net.ts). */ +#define PNET_HTTP_CORE_OWNED_REQUEST_HEADERS_COUNT 10 +#define PNET_HTTP_CORE_OWNED_REQUEST_HEADERS { "host", "connection", "content-length", "transfer-encoding", "trailer", "te", "upgrade", "keep-alive", "expect", "proxy-connection" } +#define PNET_HTTP_BODYLESS_STATUS_COUNT 2 +#define PNET_HTTP_BODYLESS_STATUS { 204, 304 } +#define PNET_HTTP_NULL_BODY_STATUS_COUNT 5 +#define PNET_HTTP_NULL_BODY_STATUS { 101, 103, 204, 205, 304 } +#define PNET_HTTP_REDIRECT_STATUS_COUNT 5 +#define PNET_HTTP_REDIRECT_STATUS { 301, 302, 303, 307, 308 } +#define PNET_HTTP_REDIRECT_POST_TO_GET_STATUS_COUNT 2 +#define PNET_HTTP_REDIRECT_POST_TO_GET_STATUS { 301, 302 } +#define PNET_HTTP_REDIRECT_ANY_TO_GET_STATUS_COUNT 1 +#define PNET_HTTP_REDIRECT_ANY_TO_GET_STATUS { 303 } #define PNET_EVENT_HEADERS "headers" #define PNET_EVENT_READABLE "readable" #define PNET_EVENT_END "end" diff --git a/framework/src/frame-prelude.ts b/framework/src/frame-prelude.ts new file mode 100644 index 00000000..8cdeb72f --- /dev/null +++ b/framework/src/frame-prelude.ts @@ -0,0 +1,40 @@ +// The fixed prefix of every frame transaction, shared by the Solid, Vue +// Vapor, Octane and headless entries: +// +// virtual clock → input latches → service pumps → effect delivery +// +// The order is a correctness contract, not a convention: module Promise +// delivery (network batches polled by the service pumps) must enter the world +// before the frame-boundary effects and before any app code runs, and the +// input latches must be in place before anything reads analog/touch state. +// One definition here keeps a fifth runtime from re-typing the sequence. + +import { __setAnalog } from "./analog.ts"; +import { __advanceClock } from "./clock.ts"; +import { __drainEffects } from "./effects.ts"; +import { runServicePumps } from "./services.ts"; +import { __setTouches } from "./touch.ts"; + +export interface FramePreludeInput { + /** Packed analog nub sample (see analog.ts); undefined = no nub. */ + readonly analog?: number; + /** Packed touch contacts and their host-resolved hit facts (touch.ts). */ + readonly touches?: readonly number[]; + readonly hits?: readonly number[]; +} + +/** + * Run the frame prelude. UI entries pass the host's input snapshot so the + * latches are set before pumps and effects; the headless entry passes + * nothing (no input surface). Promise reactions raised inside the pumps run + * in the host's job drain after `frame()` returns. + */ +export function runFramePrelude(input?: FramePreludeInput): void { + __advanceClock(); // virtual frame++, fire due after() timers + if (input) { + __setAnalog(input.analog); // latch the nub before any app code reads it + __setTouches(input.touches, input.hits); // latch contacts + their hit facts + } + runServicePumps(); // only modules with pending async work register here + __drainEffects(); // frame-boundary deliveries enter the world first +} diff --git a/framework/src/headless.ts b/framework/src/headless.ts index b5a974cf..32bad3a4 100644 --- a/framework/src/headless.ts +++ b/framework/src/headless.ts @@ -12,10 +12,10 @@ // This is what the network smoke firmware and headless daemons use; a UI // app keeps using `render()`/`mount()` from the framework entry. -import { __advanceClock, resetClock } from "./clock.ts"; -import { __drainEffects, resetEffects } from "./effects.ts"; +import { resetClock } from "./clock.ts"; +import { resetEffects } from "./effects.ts"; +import { runFramePrelude } from "./frame-prelude.ts"; import { installFrameHandler } from "./host.ts"; -import { runServicePumps } from "./services.ts"; export interface HeadlessOptions { /** Called every frame after service pumps and effect delivery. */ @@ -28,9 +28,7 @@ export function mountHeadless(options: HeadlessOptions = {}): () => void { resetEffects(); const hook = options.frame; installFrameHandler((buttons: number, analog?: number) => { - __advanceClock(); // virtual frame++, fire due after() timers - runServicePumps(); // only modules with pending async work register here - __drainEffects(); // frame-boundary deliveries enter the world first + runFramePrelude(); // clock → pumps → effects (frame-prelude.ts); no input surface to latch if (hook) hook(buttons, analog ?? 0); }); return () => { diff --git a/framework/src/index-octane.ts b/framework/src/index-octane.ts index 73b3ad8a..7b69ac8f 100644 --- a/framework/src/index-octane.ts +++ b/framework/src/index-octane.ts @@ -29,11 +29,11 @@ import { import { setOverlayRoot } from "./overlay.ts"; import { registerStyles, resolveStyle } from "./styles.ts"; import { handleFrame, setInputRoot } from "./input.ts"; -import { __setAnalog, resetFrameHooks, runFrameHooks } from "./frame-octane.tsx"; -import { __resetTouches, __setTouches } from "./touch.ts"; -import { __advanceClock, resetClock } from "./clock.ts"; -import { __drainEffects, resetEffects } from "./effects.ts"; -import { runServicePumps } from "./services.ts"; +import { resetFrameHooks, runFrameHooks } from "./frame-octane.tsx"; +import { __resetTouches } from "./touch.ts"; +import { resetClock } from "./clock.ts"; +import { resetEffects } from "./effects.ts"; +import { runFramePrelude } from "./frame-prelude.ts"; import { entries as pakEntries, get as pakGet, hasPack, loadPack } from "./pak.ts"; import { STYLE_IDS as DEFAULT_STYLE_IDS } from "./styles.generated.ts"; import { ENUMS, SCREEN_H, SCREEN_W } from "../../contracts/spec/spec.ts"; @@ -204,11 +204,7 @@ export function render(code: OctaneRenderRoot, opts: RenderOptions = {}): () => initDevtools(host.ops); // DevTools shim (docs/DEVTOOLS.md), same as the Solid path. installFrameHandler( wrapFrameHandler((buttons: number, analog: number, touches?: readonly number[]) => { - __advanceClock(); - __setAnalog(analog); - __setTouches(touches); - runServicePumps(); - __drainEffects(); + runFramePrelude({ analog, touches }); // clock → input latches → pumps → effects (frame-prelude.ts) // Octane schedules re-renders on the microtask queue; the sync boundary // drains them before the sweep so a frame's commits land in that frame. flushUniversalSync(() => { diff --git a/framework/src/index-vue-vapor.ts b/framework/src/index-vue-vapor.ts index 7715591a..00f47a71 100644 --- a/framework/src/index-vue-vapor.ts +++ b/framework/src/index-vue-vapor.ts @@ -28,11 +28,11 @@ import { import { setOverlayRoot } from "./overlay.ts"; import { registerStyles, resolveStyle } from "./styles.ts"; import { handleFrame, setInputRoot } from "./input.ts"; -import { __setAnalog, resetFrameHooks, runFrameHooks } from "./frame-vue-vapor.ts"; -import { __resetTouches, __setTouches } from "./touch.ts"; -import { __advanceClock, resetClock } from "./clock.ts"; -import { __drainEffects, resetEffects } from "./effects.ts"; -import { runServicePumps } from "./services.ts"; +import { resetFrameHooks, runFrameHooks } from "./frame-vue-vapor.ts"; +import { __resetTouches } from "./touch.ts"; +import { resetClock } from "./clock.ts"; +import { resetEffects } from "./effects.ts"; +import { runFramePrelude } from "./frame-prelude.ts"; import { entries as pakEntries, get as pakGet, hasPack, loadPack } from "./pak.ts"; import { STYLE_IDS as DEFAULT_STYLE_IDS } from "./styles.generated.ts"; import { ENUMS, SCREEN_H, SCREEN_W } from "../../contracts/spec/spec.ts"; @@ -203,11 +203,7 @@ export function render(code: VaporRenderRoot, opts: RenderOptions = {}): () => v initDevtools(host.ops); // DevTools shim (docs/DEVTOOLS.md), same as the Solid path. installFrameHandler( wrapFrameHandler((buttons: number, analog: number, touches?: readonly number[]) => { - __advanceClock(); - __setAnalog(analog); - __setTouches(touches); - runServicePumps(); - __drainEffects(); + runFramePrelude({ analog, touches }); // clock → input latches → pumps → effects (frame-prelude.ts) runFrameHooks(buttons); handleFrame(buttons); runSweep(); diff --git a/framework/src/index.ts b/framework/src/index.ts index 0d79aead..3775705c 100644 --- a/framework/src/index.ts +++ b/framework/src/index.ts @@ -43,11 +43,11 @@ import { registerStyles, resolveStyle } from "./styles.ts"; import { handleFrame, setHitRoot, setInputRoot } from "./input.ts"; import { __runGestures, resetGestures } from "./gesture.ts"; import { installTouchActivation } from "./touch-activation.ts"; -import { __setAnalog, resetFrameHooks, runFrameHooks } from "./frame.ts"; -import { __resetTouches, __setTouches } from "./touch.ts"; -import { __advanceClock, resetClock } from "./clock.ts"; -import { __drainEffects, resetEffects } from "./effects.ts"; -import { runServicePumps } from "./services.ts"; +import { resetFrameHooks, runFrameHooks } from "./frame.ts"; +import { __resetTouches } from "./touch.ts"; +import { resetClock } from "./clock.ts"; +import { resetEffects } from "./effects.ts"; +import { runFramePrelude } from "./frame-prelude.ts"; import { entries as pakEntries, get as pakGet, hasPack, loadPack } from "./pak.ts"; import { STYLE_IDS as DEFAULT_STYLE_IDS } from "./styles.generated.ts"; import { ENUMS, SCREEN_H, SCREEN_W } from "../../contracts/spec/spec.ts"; @@ -267,11 +267,7 @@ export function render(code: () => unknown, opts: RenderOptions = {}): () => voi // debug channel; one branch per frame when no transport is connected. installFrameHandler( wrapFrameHandler((buttons: number, analog: number, touches?: readonly number[], hits?: readonly number[]) => { - __advanceClock(); // virtual frame++, fire due after() timers - __setAnalog(analog); // latch the nub before any app code reads it - __setTouches(touches, hits); // latch contacts + their host-resolved hit facts - runServicePumps(); // only modules with pending async work register here - __drainEffects(); // frame-boundary deliveries enter the world first + runFramePrelude({ analog, touches, hits }); // clock → input latches → pumps → effects (frame-prelude.ts) __runGestures(); // contact lifecycles resolve before app hooks read them runFrameHooks(buttons); // app lifecycle callbacks: onFrame/onButtonPress/etc. handleFrame(buttons); // edge-detect, focus nav, onPress (runs effects) diff --git a/framework/src/manifest/host-build-inputs.ts b/framework/src/manifest/host-build-inputs.ts index 844bd7d8..b77a4a14 100644 --- a/framework/src/manifest/host-build-inputs.ts +++ b/framework/src/manifest/host-build-inputs.ts @@ -1,3 +1,8 @@ +import { + canonicalNetworkPolicyJson, + parseNetworkPolicyJson, + type ResolvedNetworkPolicy, +} from "../../../contracts/spec/network-policy.ts"; import { PRESENTATION_MODES, type PresentationMode, @@ -10,12 +15,24 @@ export interface HostBuildInputs { readonly appOutput: string; readonly target: string; readonly hostAbi: number; + /** The plan checksum the host records next to the artifacts it embeds. */ + readonly planHash: string; readonly viewport: { readonly logical: Viewport; readonly physical: Viewport; readonly presentation: PresentationMode; readonly rasterDensity: number; }; + /** Resolved feature availability (required ids true, enhancements as the + * target provides them): the host mounts exactly the network roles the + * plan turned on. */ + readonly features: Readonly>; + /** The network policy the host hands to its core, verbatim: the resolved + * policy object and its canonical JSON (byte-identical across hosts). */ + readonly network: { + readonly policy: ResolvedNetworkPolicy; + readonly policyJson: string; + }; } export interface ExtractHostBuildInputsOptions { @@ -55,6 +72,7 @@ function hasHostInputShape(input: unknown): input is ResolvedBuildPlan { (input.viewport.rasterDensity as number) > 255 ) return false; if (typeof input.planHash !== "string" || !/^sha256:[0-9a-f]{64}$/.test(input.planHash)) return false; + if (!isRecord(input.network)) return false; return Object.values(input.features).every((available) => typeof available === "boolean"); } @@ -89,16 +107,26 @@ export function extractHostBuildInputs( `PocketJS host build: expected target ${options.expectedTarget}, got ${plan.target.id}`, ); } + // Round-trip the plan's policy through the contract parser: the host + // receives a policy the reference normalizer accepts, never a hand-edited + // object that happened to keep the checksum. + const policyJson = canonicalNetworkPolicyJson(parseNetworkPolicyJson(JSON.stringify(plan.network))); return { appOutput: plan.app.output, target: plan.target.id, hostAbi: plan.target.hostAbi, + planHash: plan.planHash, viewport: { logical: plan.viewport.logical, physical: plan.viewport.physical, presentation: plan.viewport.presentation, rasterDensity: plan.viewport.rasterDensity, }, + features: Object.freeze({ ...plan.features }), + network: Object.freeze({ + policy: parseNetworkPolicyJson(policyJson), + policyJson, + }), }; } @@ -119,5 +147,7 @@ export function hostBuildEnvironment( POCKETJS_PHYSICAL_HEIGHT: String(inputs.viewport.physical[1]), POCKETJS_PRESENTATION: inputs.viewport.presentation, POCKETJS_RASTER_DENSITY: String(inputs.viewport.rasterDensity), + POCKETJS_PLAN_HASH: inputs.planHash, + POCKETJS_NETWORK_POLICY: inputs.network.policyJson, }; } diff --git a/framework/src/manifest/plan.ts b/framework/src/manifest/plan.ts index fa821c19..1af87913 100644 --- a/framework/src/manifest/plan.ts +++ b/framework/src/manifest/plan.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import type { ResolvedNetworkPolicy } from "../../../contracts/spec/network-policy.ts"; import type { PocketManifestV2 } from "../../../contracts/spec/pocket-manifest.ts"; import type { PresentationMode, Viewport } from "../../../contracts/spec/platforms.ts"; @@ -27,6 +28,11 @@ export interface ResolvedBuildPlanContent { * svcOpen strings the app's adapters speak. Hosts build their svc * allowlist from this list (issue #295). */ readonly companions: readonly string[]; + /** The network endpoint policy resolved from `permissions.network` + * (format 3), or the deny-all policy. Hosts hand its canonical JSON to + * their network core at runtime creation and never author one + * themselves (contracts/spec/network-policy.ts). */ + readonly network: ResolvedNetworkPolicy; } export interface ResolvedBuildPlan extends ResolvedBuildPlanContent { diff --git a/framework/src/manifest/resolve.ts b/framework/src/manifest/resolve.ts index dba647da..10a6cbd2 100644 --- a/framework/src/manifest/resolve.ts +++ b/framework/src/manifest/resolve.ts @@ -1,5 +1,6 @@ import { DYNAMIC_FORMS, TARGET_FORMS } from "../../../contracts/spec/platforms.ts"; -import type { PocketManifestV2 } from "../../../contracts/spec/pocket-manifest.ts"; +import { resolveNetworkPolicy } from "../../../contracts/spec/network-policy.ts"; +import type { PocketManifest, PocketManifestV2 } from "../../../contracts/spec/pocket-manifest.ts"; import { POCKET_PLATFORM_CONTRACTS, type PlatformContractRegistry, @@ -12,10 +13,13 @@ import { type ResolvedBuildPlan, type ResolvedBuildPlanContent, } from "./plan.ts"; -import { validatePocketManifest, type ContractDiagnostic } from "./validate.ts"; +import { manifestPermissions, validatePocketManifest, type ContractDiagnostic } from "./validate.ts"; export interface ResolveBuildRequest { readonly target: string; + /** A development build: admits `permissions.network.allowInvalidTlsForDevelopment`. + * Production admission (the default) refuses it. */ + readonly development?: boolean; } export type ResolutionResult = @@ -53,7 +57,7 @@ const within = (v: Viewport, min: Viewport, max: Viewport): boolean => * pushing diagnostics. */ function resolveViewport( - manifest: PocketManifestV2, + manifest: PocketManifest, profile: TargetProfile, diagnostics: ContractDiagnostic[], ): { @@ -260,7 +264,7 @@ export function validatePlatformContractRegistry( } export function resolveBuildPlan( - manifest: PocketManifestV2, + manifest: PocketManifest, request: ResolveBuildRequest, registry: PlatformContractRegistry = POCKET_PLATFORM_CONTRACTS, ): ResolutionResult { @@ -348,7 +352,15 @@ export function resolveBuildPlan( }); } - if (diagnostics.length > 0 || !resolvedViewport) return { ok: false, diagnostics }; + // The network policy is plan truth: normalized here, covered by planHash, + // handed to the host's core verbatim. A format-2 manifest (no + // `permissions`) resolves to the deny-all policy. + const network = resolveNetworkPolicy(manifestPermissions(manifest)?.network, { + development: request.development === true, + }); + if (!network.ok) diagnostics.push(...network.diagnostics); + + if (diagnostics.length > 0 || !resolvedViewport || !network.ok) return { ok: false, diagnostics }; const logical: Viewport = [resolvedViewport.logical[0], resolvedViewport.logical[1]]; const physical: Viewport = [resolvedViewport.physical[0], resolvedViewport.physical[1]]; @@ -379,6 +391,7 @@ export function resolveBuildPlan( }, features, companions: manifest.app.companions ?? [], + network: network.policy, }; return { ok: true, plan: finalizeBuildPlan(content) }; } diff --git a/framework/src/manifest/validate.ts b/framework/src/manifest/validate.ts index 33efaaf4..91cee28f 100644 --- a/framework/src/manifest/validate.ts +++ b/framework/src/manifest/validate.ts @@ -1,8 +1,14 @@ import { + POCKET_MANIFEST_V3_VERSION, + POCKET_MANIFEST_VERSION, + POCKET_MANIFEST_VERSIONS, pocketManifestV2Schema, + pocketManifestV3Schema, type JsonSchema, type JsonSchemaObject, + type PocketManifest, type PocketManifestV2, + type PocketManifestV3, } from "../../../contracts/spec/pocket-manifest.ts"; export interface ContractDiagnostic { @@ -174,9 +180,38 @@ function validateSchema( } } -export function validatePocketManifest(input: unknown): ValidationResult { +/** + * Validate a manifest of either accepted format. The `pocket` field selects + * the schema: 2 (capabilities + viewport) or 3 (format 2 plus the top-level + * `permissions` block). A manifest with any other format value is reported + * at `/pocket` instead of failing every format-2 constant check. + */ +export function validatePocketManifest(input: unknown): ValidationResult { const diagnostics: ContractDiagnostic[] = []; + const format = input !== null && typeof input === "object" && !Array.isArray(input) + ? (input as { pocket?: unknown }).pocket + : undefined; + if (format === POCKET_MANIFEST_V3_VERSION) { + validateSchema(input, pocketManifestV3Schema, "", diagnostics); + if (diagnostics.length > 0) return { ok: false, diagnostics }; + return { ok: true, value: input as PocketManifestV3 }; + } + if (format !== undefined && format !== POCKET_MANIFEST_VERSION) { + return { + ok: false, + diagnostics: [{ + code: "schema.enum", + path: "/pocket", + message: `expected one of ${POCKET_MANIFEST_VERSIONS.join(", ")}`, + }], + }; + } validateSchema(input, pocketManifestV2Schema, "", diagnostics); if (diagnostics.length > 0) return { ok: false, diagnostics }; return { ok: true, value: input as PocketManifestV2 }; } + +/** The format-3 `permissions` block, or undefined for format 2. */ +export function manifestPermissions(manifest: PocketManifest): PocketManifestV3["permissions"] { + return manifest.pocket === POCKET_MANIFEST_V3_VERSION ? manifest.permissions : undefined; +} diff --git a/framework/src/net/body.ts b/framework/src/net/body.ts index 3c7ccd06..61e872cc 100644 --- a/framework/src/net/body.ts +++ b/framework/src/net/body.ts @@ -475,11 +475,26 @@ class TeeShared { } } - /** Pull one chunk from the source into both branch buffers. */ + /** Bytes one more pull may add without pushing a live branch past the + * limit: the branch that pulls has drained its own queue, so the bound is + * the other branch's backlog. */ + room(): number { + let backlog = 0; + for (const i of [0, 1] as const) { + if (!this.cancelled[i] && this.buffered[i] > backlog) backlog = this.buffered[i]; + } + return Math.min(16 * 1024, this.limit - backlog); + } + + /** Pull one chunk from the source into both branch buffers. The chunk is + * sized to the remaining room so a branch's backlog never exceeds + * `limit` (a hard bound, not "stop after crossing it"). */ pull(): Promise { if (this.pulling) return this.pulling; + const room = this.room(); + if (room <= 0) return Promise.resolve(); // the caller is blocked; it waits this.pulling = (async () => { - const chunk = new Uint8Array(16 * 1024); + const chunk = new Uint8Array(room); try { const { bytes, done } = await this.source["readIntoLocked"](chunk); if (bytes > 0) { diff --git a/framework/src/net/http.ts b/framework/src/net/http.ts index 9275bbb4..e2d8498a 100644 --- a/framework/src/net/http.ts +++ b/framework/src/net/http.ts @@ -11,6 +11,9 @@ // service pump and writes the handler's Response through `respond`/`write`. import { + HTTP_CORE_OWNED_REQUEST_HEADERS, + HTTP_NULL_BODY_STATUS, + HTTP_REDIRECT_STATUS, NET_DEFAULT_AGGREGATE_BYTES, NET_DEFAULT_QUEUE_BYTES, NET_DEFAULT_TIMEOUT_MS, @@ -74,18 +77,7 @@ const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; /** Request headers the core owns (framing, connection control, upgrade). An * app cannot set them; the Fetch request guard is otherwise not applied so * explicit `Cookie`, `Origin`, `User-Agent` etc. work on every host. */ -const CORE_OWNED_REQUEST_HEADERS = new Set([ - "host", - "connection", - "content-length", - "transfer-encoding", - "trailer", - "te", - "upgrade", - "keep-alive", - "expect", - "proxy-connection", -]); +const CORE_OWNED_REQUEST_HEADERS = new Set(HTTP_CORE_OWNED_REQUEST_HEADERS); function normalizeHeaderValue(value: string): string { // HTTP whitespace: tab, LF, CR, space. @@ -310,7 +302,7 @@ function normalizeMethod(raw: unknown): string { }); } const upper = method.toUpperCase(); - if ((NET_METHODS_FORBIDDEN as readonly string[]).includes(upper) || upper === "TRACK") { + if ((NET_METHODS_FORBIDDEN as readonly string[]).includes(upper)) { throw new NetworkError(NET_ERROR.invalidRequest, `method ${upper} is not allowed`, { operation: "fetch", protocol: PROTOCOL, @@ -566,7 +558,7 @@ const REASON_PHRASES: Record = { 504: "Gateway Timeout", }; -const NULL_BODY_STATUS = new Set([101, 103, 204, 205, 304]); +const NULL_BODY_STATUS = new Set(HTTP_NULL_BODY_STATUS); interface ResponseInternal { url: string; @@ -704,8 +696,8 @@ export class Response { } static redirect(url: string | URL, status = 302): Response { - if (![301, 302, 303, 307, 308].includes(status)) { - throw new NetworkError(NET_ERROR.invalidRequest, "redirect status must be 301, 302, 303, 307 or 308", { + if (!(HTTP_REDIRECT_STATUS as readonly number[]).includes(status)) { + throw new NetworkError(NET_ERROR.invalidRequest, `redirect status must be one of ${HTTP_REDIRECT_STATUS.join(", ")}`, { operation: "Response.redirect", protocol: PROTOCOL, }); diff --git a/framework/src/net/url.ts b/framework/src/net/url.ts index ecbe0bb4b370552333d11537b2ae063e7d9ddff2..869254c140c2251e914646b5b398c2875cf25e5c 100644 GIT binary patch delta 38 tcmaFl`@(m_K|aZt3IhXO1!YxzD;qm|hv=A?nAo_Qm%~$yNBmfNN4Eg{7 delta 32 ncmaFi`^b00K|WyyT?J)TeJdL~dxz+ln3&kOn)=PR`1m9Mya@`_ diff --git a/hosts/sim/httpd.ts b/hosts/sim/httpd.ts index e4c1b313..44c69522 100644 --- a/hosts/sim/httpd.ts +++ b/hosts/sim/httpd.ts @@ -36,8 +36,10 @@ import { type HttpdRespondMeta, } from "../../contracts/spec/httpd.ts"; import { NET_ERROR, NET_TLS_MIN_VERSION } from "../../contracts/spec/net.ts"; +import { networkPolicyAllowsListen } from "../../contracts/spec/network-policy.ts"; import { stringToUtf8 } from "../../framework/src/bytes.ts"; import type { HttpdOps } from "../../framework/src/net/http.ts"; +import { simPolicy, type SimHostOptions } from "./net.ts"; export interface SimInjectOptions { method?: string; @@ -126,7 +128,8 @@ function toBytes(value: string | Uint8Array): Uint8Array { return value instanceof Uint8Array ? value.slice() : stringToUtf8(value); } -export function createSimHttpdHost(): SimHttpdHost { +export function createSimHttpdHost(options: SimHostOptions = {}): SimHttpdHost { + const policy = simPolicy(options); const servers = new Map(); const requests = new Map(); const events: object[] = []; @@ -156,6 +159,9 @@ export function createSimHttpdHost(): SimHttpdHost { return refuse(NET_ERROR.invalidRequest, "address/port required"); } if (meta.tls) return refuse(NET_ERROR.unsupported, "tls not provided"); + if (policy && !networkPolicyAllowsListen(policy, meta.tls ? "https" : "http", meta.address, meta.port)) { + return refuse(NET_ERROR.permissionDenied, "address/port is not an allowed listen rule"); + } if (servers.size >= HTTPD_MAX_SERVERS) return refuse(NET_ERROR.resourceLimit, "too many servers"); for (const s of servers.values()) { if (s.meta.port === meta.port && meta.port !== 0 && !s.terminal) { diff --git a/hosts/sim/net.ts b/hosts/sim/net.ts index d2c15ea7..4160ce3b 100644 --- a/hosts/sim/net.ts +++ b/hosts/sim/net.ts @@ -27,8 +27,43 @@ import { type NetLimits, type NetStartMeta, } from "../../contracts/spec/net.ts"; +import { + networkPolicyAllowsConnect, + parseNetworkPolicyJson, + type ResolvedNetworkPolicy, +} from "../../contracts/spec/network-policy.ts"; import { stringToUtf8 } from "../../framework/src/bytes.ts"; import type { NetOps } from "../../framework/src/net/http.ts"; +import { URL } from "../../framework/src/net/url.ts"; + +/** Host options shared by the sim network modules. */ +export interface SimHostOptions { + /** The Build Plan's ResolvedNetworkPolicy (object or canonical JSON). When + * set, the sim enforces it exactly like a native core — connect rule and + * insecureTransport before any route lookup, listen rule before bind, + * the redirect target again — so the policy conformance vectors run on + * this host too. Without it the fixture routes act as the allowlist. */ + readonly policy?: ResolvedNetworkPolicy | string; +} + +export function simPolicy(options: SimHostOptions | undefined): ResolvedNetworkPolicy | null { + const policy = options?.policy; + if (policy === undefined) return null; + return typeof policy === "string" ? parseNetworkPolicyJson(policy) : policy; +} + +/** Endpoint tuple of an absolute http(s)/ws(s) URL for the policy matcher. */ +export function simEndpoint(url: string): { protocol: string; host: string; port: number } | null { + try { + const parsed = new URL(url); + const protocol = parsed.protocol.slice(0, -1); + const host = parsed.hostname.replace(/^\[|\]$/g, ""); + const port = parsed.port ? Number(parsed.port) : protocol === "http" || protocol === "ws" ? 80 : 443; + return { protocol, host, port }; + } catch { + return null; + } +} export interface SimNetRequest { readonly url: string; @@ -113,7 +148,8 @@ export const SIM_NET_LIMITS: NetLimits = Object.freeze({ features: [], }); -export function createSimNetHost(routes: Readonly>): SimNetHost { +export function createSimNetHost(routes: Readonly>, options: SimHostOptions = {}): SimNetHost { + const policy = simPolicy(options); const pending = new Map(); /** Handles that sent `end` but still hold visible unread bytes. */ const drained = new Map(); @@ -141,12 +177,22 @@ export function createSimNetHost(routes: Readonly>): return refuse(NET_ERROR.invalidRequest, "url must be absolute http:// or https://"); } if (meta.url.startsWith("https://")) return refuse(NET_ERROR.unsupported, "tls not provided"); - if (typeof meta.method !== "string" || (NET_METHODS_FORBIDDEN as readonly string[]).includes(meta.method)) { + if ( + typeof meta.method !== "string" || + !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(meta.method) || + (NET_METHODS_FORBIDDEN as readonly string[]).includes(meta.method.toUpperCase()) + ) { return refuse(NET_ERROR.invalidRequest, "method not allowed"); } if (pending.size >= NET_MAX_INFLIGHT) return refuse(NET_ERROR.resourceLimit, "too many requests in flight"); const body = bodyBuffer ? new Uint8Array(bodyBuffer.slice(0)) : new Uint8Array(0); if (body.length > NET_MAX_REQUEST_BYTES) return refuse(NET_ERROR.resourceLimit, "request body too large"); + if (policy) { + const endpoint = simEndpoint(meta.url); + if (!endpoint || !networkPolicyAllowsConnect(policy, endpoint.protocol, endpoint.host, endpoint.port)) { + return refuse(NET_ERROR.permissionDenied, "endpoint is not an allowed connect rule"); + } + } const route = routes[meta.url]; if (!route) return refuse(NET_ERROR.permissionDenied, `no route for ${meta.url}`); const request: SimNetRequest = { url: meta.url, method: meta.method, headers: meta.headers ?? {}, body, meta }; @@ -237,6 +283,17 @@ export function createSimNetHost(routes: Readonly>): events.push({ t: "error", h: p.handle, code: p.response.error.code, message: p.response.error.message }); continue; } + // A fixture that answers from another URL stands in for a redirect: + // the target is re-authorized like a native core re-checks each hop. + if (policy && p.response.url !== undefined && p.response.url !== p.request.url) { + const endpoint = simEndpoint(p.response.url); + if (!endpoint || !networkPolicyAllowsConnect(policy, endpoint.protocol, endpoint.host, endpoint.port)) { + p.terminal = true; + pending.delete(p.handle); + events.push({ t: "error", h: p.handle, code: NET_ERROR.permissionDenied, message: "redirect target is not an allowed endpoint" }); + continue; + } + } p.headSent = true; const total = p.chunks.reduce((n, c) => n + c.length, 0); const head: Record = { diff --git a/hosts/sim/ws.ts b/hosts/sim/ws.ts index db3760e1..6d015dd4 100644 --- a/hosts/sim/ws.ts +++ b/hosts/sim/ws.ts @@ -33,8 +33,10 @@ import { type WsConnectMeta, type WsLimits, } from "../../contracts/spec/ws.ts"; +import { networkPolicyAllowsConnect } from "../../contracts/spec/network-policy.ts"; import { bytesToBase64, stringToUtf8, utf8ToString } from "../../framework/src/bytes.ts"; import type { WsOps } from "../../framework/src/net/websocket.ts"; +import { simEndpoint, simPolicy, type SimHostOptions } from "./net.ts"; export interface SimWsPeer { /** Subprotocol the peer selects (default: first requested or ""). */ @@ -111,7 +113,8 @@ export const SIM_WS_LIMITS: WsLimits = Object.freeze({ features: [], }); -export function createSimWsHost(peers: Readonly>): SimWsHost { +export function createSimWsHost(peers: Readonly>, options: SimHostOptions = {}): SimWsHost { + const policy = simPolicy(options); const sockets = new Map(); const events: object[] = []; const log: string[] = []; @@ -145,6 +148,12 @@ export function createSimWsHost(peers: Readonly>): Sim } if (meta.url.startsWith("wss://")) return refuse(NET_ERROR.unsupported, "tls not provided"); if (sockets.size >= WS_MAX_SOCKETS) return refuse(NET_ERROR.resourceLimit, "too many sockets"); + if (policy) { + const endpoint = simEndpoint(meta.url); + if (!endpoint || !networkPolicyAllowsConnect(policy, endpoint.protocol, endpoint.host, endpoint.port)) { + return refuse(NET_ERROR.permissionDenied, "endpoint is not an allowed connect rule"); + } + } const peer = peers[meta.url]; if (!peer) return refuse(NET_ERROR.permissionDenied, `no peer for ${meta.url}`); const handle = nextHandle++; diff --git a/hosts/web/net-spec.js b/hosts/web/net-spec.js new file mode 100644 index 00000000..5d0cc829 --- /dev/null +++ b/hosts/web/net-spec.js @@ -0,0 +1,25 @@ +// GENERATED — do not edit; run `bun contracts/spec/gen-web.ts`. +// Plain-ESM mirror of contracts/spec/net.ts for the browser dev host +// (hosts/web/net.js). tests/contract.ts byte-compares this file. +export const NET_SPEC_MAJOR = 2; +export const NET_SPEC_MINOR = 0; +export const NET_MAX_INFLIGHT = 8; +export const NET_MAX_REQUEST_BYTES = 262144; +export const NET_DEFAULT_QUEUE_BYTES = 32768; +export const NET_MAX_QUEUE_BYTES = 262144; +export const NET_DEFAULT_AGGREGATE_BYTES = 1048576; +export const NET_MAX_AGGREGATE_BYTES = 8388608; +export const NET_MAX_EVENTS_PER_TICK = 128; +export const NET_MAX_TICK_BYTES = 262144; +export const NET_MAX_HEADERS = 64; +export const NET_MAX_HEADER_BYTES = 16384; +export const NET_DEFAULT_TIMEOUT_MS = 30000; +export const NET_MAX_TIMEOUT_MS = 120000; +export const NET_MAX_REDIRECTS = 5; +export const NET_TLS_MIN_VERSION = "1.2"; +export const NET_METHODS_FORBIDDEN = ["CONNECT","TRACE","TRACK"]; +export const HTTP_CORE_OWNED_REQUEST_HEADERS = ["host","connection","content-length","transfer-encoding","trailer","te","upgrade","keep-alive","expect","proxy-connection"]; +export const HTTP_NULL_BODY_STATUS = [101,103,204,205,304]; +export const HTTP_REDIRECT_STATUS = [301,302,303,307,308]; +export const NET_EVENT = {"headers":"headers","readable":"readable","end":"end","error":"error","drain":"drain"}; +export const NET_ERROR = {"invalidRequest":"invalid_request","invalidState":"invalid_state","unsupported":"unsupported","permissionDenied":"permission_denied","busy":"busy","resourceLimit":"resource_limit","dns":"dns","connect":"connect","addressInUse":"address_in_use","closed":"closed","timeout":"timeout","tlsCertificateInvalid":"tls_certificate_invalid","tlsHostnameMismatch":"tls_hostname_mismatch","tlsHandshakeFailed":"tls_handshake_failed","tlsClockUntrusted":"tls_clock_untrusted","redirect":"redirect","responseTooLarge":"response_too_large","protocol":"protocol","websocketHandshakeFailed":"websocket_handshake_failed","websocketProtocolError":"websocket_protocol_error","messageTooLarge":"message_too_large","cancelled":"cancelled","other":"other","unavailable":"unavailable"}; diff --git a/hosts/web/net.js b/hosts/web/net.js index 713ae084..b53bc290 100644 --- a/hosts/web/net.js +++ b/hosts/web/net.js @@ -14,23 +14,46 @@ // redirect "manual" — a redirect the browser hides ends the request with // `unsupported`; TLS is the browser's, so "tls" is advertised. -const SPEC_MAJOR = 2; -const SPEC_MINOR = 0; -const MAX_INFLIGHT = 8; -const MAX_REQUEST_BYTES = 256 * 1024; -const DEFAULT_QUEUE_BYTES = 32 * 1024; -const MAX_QUEUE_BYTES = 256 * 1024; -const DEFAULT_AGGREGATE_BYTES = 1024 * 1024; -const MAX_AGGREGATE_BYTES = 8 * 1024 * 1024; -const MAX_EVENTS_PER_TICK = 128; -const MAX_TICK_BYTES = 256 * 1024; -const MAX_HEADERS = 64; -const MAX_HEADER_BYTES = 16 * 1024; -const DEFAULT_TIMEOUT_MS = 30_000; -const MAX_TIMEOUT_MS = 120_000; -const MAX_REDIRECTS = 5; -const FORBIDDEN_METHODS = new Set(["CONNECT", "TRACE", "TRACK"]); -const NULL_BODY_STATUS = new Set([101, 103, 204, 205, 304]); +import { + HTTP_NULL_BODY_STATUS, + NET_DEFAULT_AGGREGATE_BYTES, + NET_DEFAULT_QUEUE_BYTES, + NET_DEFAULT_TIMEOUT_MS, + NET_MAX_AGGREGATE_BYTES, + NET_MAX_EVENTS_PER_TICK, + NET_MAX_HEADER_BYTES, + NET_MAX_HEADERS, + NET_MAX_INFLIGHT, + NET_MAX_QUEUE_BYTES, + NET_MAX_REDIRECTS, + NET_MAX_REQUEST_BYTES, + NET_MAX_TICK_BYTES, + NET_MAX_TIMEOUT_MS, + NET_METHODS_FORBIDDEN, + NET_SPEC_MAJOR, + NET_SPEC_MINOR, + NET_TLS_MIN_VERSION, +} from "./net-spec.js"; + +// The spec ceilings, as this host's effective limits (it tightens none of +// them; the generated net-spec.js is the single source, never literals here). +const SPEC_MAJOR = NET_SPEC_MAJOR; +const SPEC_MINOR = NET_SPEC_MINOR; +const MAX_INFLIGHT = NET_MAX_INFLIGHT; +const MAX_REQUEST_BYTES = NET_MAX_REQUEST_BYTES; +const DEFAULT_QUEUE_BYTES = NET_DEFAULT_QUEUE_BYTES; +const MAX_QUEUE_BYTES = NET_MAX_QUEUE_BYTES; +const DEFAULT_AGGREGATE_BYTES = NET_DEFAULT_AGGREGATE_BYTES; +const MAX_AGGREGATE_BYTES = NET_MAX_AGGREGATE_BYTES; +const MAX_EVENTS_PER_TICK = NET_MAX_EVENTS_PER_TICK; +const MAX_TICK_BYTES = NET_MAX_TICK_BYTES; +const MAX_HEADERS = NET_MAX_HEADERS; +const MAX_HEADER_BYTES = NET_MAX_HEADER_BYTES; +const DEFAULT_TIMEOUT_MS = NET_DEFAULT_TIMEOUT_MS; +const MAX_TIMEOUT_MS = NET_MAX_TIMEOUT_MS; +const MAX_REDIRECTS = NET_MAX_REDIRECTS; +const FORBIDDEN_METHODS = new Set(NET_METHODS_FORBIDDEN); +const NULL_BODY_STATUS = new Set(HTTP_NULL_BODY_STATUS); const LIMITS = Object.freeze({ specMajor: SPEC_MAJOR, @@ -49,7 +72,7 @@ const LIMITS = Object.freeze({ defaultTimeoutMs: DEFAULT_TIMEOUT_MS, maxTimeoutMs: MAX_TIMEOUT_MS, maxRedirects: MAX_REDIRECTS, - tlsMinVersion: "1.2", + tlsMinVersion: NET_TLS_MIN_VERSION, features: ["tls"], }); @@ -181,7 +204,20 @@ export function createNetHost(nativeFetch = globalThis.fetch.bind(globalThis)) { end(state); return; } - const reader = response.body.getReader(); + // A BYOB reader bounds every read to the queue's free space, so the + // receive queue is a hard cap (queueBytes) the way a native core's is. + // Bodies that are not byte streams (some runtimes' synthetic responses) + // fall back to the default reader, whose chunks are sized by the + // browser: the host then stops pulling at the cap but the chunk that + // crossed it is held whole (at most one chunk past queueBytes). + let reader; + let byob = false; + try { + reader = response.body.getReader({ mode: "byob" }); + byob = true; + } catch { + reader = response.body.getReader(); + } state.reader = reader; try { for (;;) { @@ -193,7 +229,9 @@ export function createNetHost(nativeFetch = globalThis.fetch.bind(globalThis)) { } if (state.terminal) break; state.armIdle(); - const { done, value } = await reader.read(); + const { done, value } = byob + ? await reader.read(new Uint8Array(Math.min(state.queueBytes - state.queued, 64 * 1024))) + : await reader.read(); if (state.terminal) break; if (done) { end(state); @@ -204,6 +242,7 @@ export function createNetHost(nativeFetch = globalThis.fetch.bind(globalThis)) { fail(state, "response_too_large", `body exceeds ${state.maxBodyBytes} bytes`); break; } + if (value.byteLength === 0) continue; // a BYOB read may fill nothing yet state.chunks.push(value); state.queued += value.byteLength; state.dirty = true; // new bytes: announce `readable` at the next tick diff --git a/package.json b/package.json index 1abbf8a6..2296487f 100644 --- a/package.json +++ b/package.json @@ -243,7 +243,7 @@ "devtools:psp": "bun tools/devtools-psp.ts", "test:tailwind": "bun test tests/tailwind.test.ts", "contract": "bun tests/contract.ts", - "gen": "bun contracts/spec/gen-rust.ts && bun contracts/spec/gen-c.ts && bun tools/gen-exports.ts", + "gen": "bun contracts/spec/gen-rust.ts && bun contracts/spec/gen-c.ts && bun contracts/spec/gen-web.ts && bun tools/gen-exports.ts", "vapor:gb": "bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx --target gb", "vapor:nes": "bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx --target nes", "vapor:esp32": "bun vapor/compiler/cli.ts vapor/examples/todo/todo.tsx --target esp32", diff --git a/site/build.ts b/site/build.ts index 45ae7a4b..e52bb248 100644 --- a/site/build.ts +++ b/site/build.ts @@ -507,6 +507,7 @@ async function main() { // validator. The deployed path is POCKET_MANIFEST_SCHEMA_ID — // /schema/pocket-2.json, independent of where the repo keeps the file. copy(ROOT + "contracts/schema/pocket-2.json", "schema/pocket-2.json"); + copy(ROOT + "contracts/schema/pocket-3.json", "schema/pocket-3.json"); copy(ROOT + "hosts/web/pocketjs.wasm", "pg/pocketjs.wasm"); copy(ROOT + "assets/fonts/Inter-Regular.ttf", "pg/fonts/Inter-Regular.ttf"); copy(ROOT + "assets/fonts/Inter-Bold.ttf", "pg/fonts/Inter-Bold.ttf"); diff --git a/site/content/changelog.md b/site/content/changelog.md index dbdd716d..9d012619 100644 --- a/site/content/changelog.md +++ b/site/content/changelog.md @@ -3,6 +3,46 @@ Engine and site milestones, newest first. Versions track the `@pocketjs/framework` npm package. +## Unreleased + +**The network modules: streaming HTTP client, HTTP server and WebSocket client over one policy the Build Plan owns.** +This is a **breaking migration** of the 0.10.0 `net` module, not an additive +feature, and the first network capability with a hardware-proven native core. + +- **Breaking: `@pocketjs/framework/net` is a support module now.** `fetch`, + `Headers`, `Request`, `Response`, `BodyStream` and `serve` moved to + `@pocketjs/framework/net/http`; the WebSocket client is + `@pocketjs/framework/net/websocket` (`connect`). The root `net` subpath + exports `AbortController`, `AbortSignal`, `URL`, `NetworkError`, + `getNetworkLimits` and the shared types. `fetch` returns a streaming + `Response` (`body.readInto`, `for await`, `text()`/`json()`/`arrayBuffer()` + under an aggregate cap) instead of the whole-response `PocketResponse`; + `NetError` is `NetworkError` with a stable `code`/`category`. + Migration: `import { fetch } from "@pocketjs/framework/net"` → + `import { fetch } from "@pocketjs/framework/net/http"`. +- **Breaking: the capability id `net.http` is gone.** Manifests declare the + role-split ids `network.http.client`, `network.http.client.tls`, + `network.http.server`, `network.http.server.tls`, + `network.websocket.client`, `network.websocket.client.tls`. No stock target + advertises them yet; a target adds an id only when its native host ships + and tests the module. +- **Manifest format 3: `permissions.network` is the single source of the + network policy.** A format-3 `pocket.json` (`"pocket": 3`, + `https://pocketjs.dev/schema/pocket-3.json`) declares connect rules + (protocol, host, port or range), listen rules, host credential ids and the + `localNetwork` / `insecureTransport` / `allowInvalidTlsForDevelopment` + switches. The resolver normalizes them into `ResolvedBuildPlan.network` + (covered by `planHash`); `extractHostBuildInputs()` hands custom hosts the + canonical policy JSON (`POCKETJS_NETWORK_POLICY`) that every network core + enforces on each command. Format 2 stays valid and resolves to the deny-all + policy. The contract, its reference matcher and shared vectors live in + `contracts/spec/network-policy.ts` and `contracts/spec/vectors/`. +- **Portable C core, ESP-IDF host, TLS.** `engine/net` (HTTP/1.1 client and + server, RFC 6455 client, bounded queues with backpressure, tick-boundary + delivery) runs on AtomS3R and Tab5 under ESP-IDF v6.0.2 with ESP-TLS; + `engine/crates/pocket-net` is the Rust HTTP client core for Rust hosts. + `@pocketjs/framework/headless` runs the frame transaction without a UI. + ## 0.10.1 — August 16, 2026 **Three more physical phones run PocketJS, Pocket Vapor compiles a smaller reactive graph, and Pocket3D gains a deterministic systemic world.** diff --git a/site/content/docs/net.md b/site/content/docs/net.md index 67bbc36c..23b607e2 100644 --- a/site/content/docs/net.md +++ b/site/content/docs/net.md @@ -55,8 +55,9 @@ through `response.body`, a `BodyStream` that supports `for await`, `response_too_large` past their cap. Bytes wait in a bounded native queue until the application reads them; **when the queue is full the host stops reading the socket and TCP flow control holds the peer**, so a slow reader -never grows memory. `clone()` creates a bounded tee — cancel the branch you -do not read. +never grows memory past `queueBytes`. `clone()` creates a bounded tee whose +backlog never exceeds the aggregate limit — cancel the branch you do not +read. ## When results arrive @@ -71,12 +72,17 @@ is the same on every host and in a replay. Importing a module grants nothing. Capabilities are split by protocol, role and TLS — `network.http.client`, `network.http.client.tls`, -`network.http.server`, `network.websocket.client`, … — and the host holds an -immutable policy of allowed endpoints (`connect` rules with host, port and -protocol; `listen` rules with address and port; `insecureTransport`; -`localNetwork`) that every command is checked against. No stock target -advertises a network capability yet; a target advertises one only when its -native host ships and tests the module. +`network.http.server`, `network.websocket.client`, … — and the application +declares its endpoints in the manifest (format 3, `permissions.network`: +`connect` rules with protocol, host and port or range; `listen` rules with +address and port; `insecureTransport`; `localNetwork`). The Build Plan +resolver normalizes them into the plan's network policy, the host hands that +policy to its core verbatim, and **every command is checked against it**: +the connect rule before DNS, each resolved address after DNS, the listen rule +before bind, the endpoint rule again on every redirect. A format-2 manifest +resolves to a deny-all policy. No stock target advertises a network +capability yet; a target advertises one only when its native host ships and +tests the module. ## Errors diff --git a/site/content/docs/platform-contracts.md b/site/content/docs/platform-contracts.md index 72f25878..2c7ab80c 100644 --- a/site/content/docs/platform-contracts.md +++ b/site/content/docs/platform-contracts.md @@ -66,6 +66,16 @@ Format 2 is strict JSON data. A PSP-shaped portable app can say: The manifest contains no physical resolution, scale factor, Vita flag, native crate path, or host ABI. Those are framework-owned facts. +**Format 3** (`"pocket": 3`, `https://pocketjs.dev/schema/pocket-3.json`) is +format 2 plus a top-level `permissions` block. Its only member today is +`permissions.network` — the endpoints the app may connect to and listen on, +the host credential ids it may name, and the `localNetwork` / +`insecureTransport` / `allowInvalidTlsForDevelopment` switches +(`contracts/spec/network-policy.ts`). The resolver normalizes it into +`ResolvedBuildPlan.network`, covered by `planHash`, and hosts enforce that +policy on every network command; a format-2 manifest resolves to the +deny-all policy. See the [network](/docs/net/) page. + `requires` is the compatibility floor. Resolution fails before compilation if the selected host does not provide one of those APIs. `enhances` declares an optional API for which the app has a fallback. Its availability becomes a diff --git a/tests/contract.ts b/tests/contract.ts index 7473cdcd..d9fb06d9 100644 --- a/tests/contract.ts +++ b/tests/contract.ts @@ -11,6 +11,7 @@ import { generateC } from "../contracts/spec/gen-c.ts"; import { generateRust } from "../contracts/spec/gen-rust.ts"; +import { generateWeb } from "../contracts/spec/gen-web.ts"; import { withGeneratedExports } from "../tools/gen-exports.ts"; import { abgr, @@ -58,6 +59,16 @@ check( "run `bun contracts/spec/gen-c.ts` and commit the result", ); +// ---- (a3) generated browser-host mirror is in sync -------------------------- + +const webSpecPath = new URL("../hosts/web/net-spec.js", import.meta.url).pathname; +const committedWeb = await Bun.file(webSpecPath).text().catch(() => null); +check( + committedWeb !== null && committedWeb === generateWeb(), + "hosts/web/net-spec.js matches contracts/spec/net.ts", + "run `bun contracts/spec/gen-web.ts` and commit the result", +); + // ---- (c) package.json exports match the subpath registry --------------------- const pkgPath = new URL("../package.json", import.meta.url).pathname; diff --git a/tests/fixtures/plans/portable-psp.plan.json b/tests/fixtures/plans/portable-psp.plan.json index 1d5c7142..1352efa3 100644 --- a/tests/fixtures/plans/portable-psp.plan.json +++ b/tests/fixtures/plans/portable-psp.plan.json @@ -29,5 +29,14 @@ "text.glyphs.baked": true }, "companions": [], - "planHash": "sha256:e3a257d89114161e6faecb37249f3cba37f444034a951c80a8ffcaed5a593ddf" + "network": { + "version": 1, + "connect": [], + "listen": [], + "credentials": [], + "localNetwork": false, + "insecureTransport": false, + "allowInvalidTlsForDevelopment": false + }, + "planHash": "sha256:4c09b588891f5ea2364d46ab57cbedd787017f93141d31242d5ebab061b24c5a" } diff --git a/tests/fixtures/plans/portable-vita.plan.json b/tests/fixtures/plans/portable-vita.plan.json index 1528e858..f6de2f91 100644 --- a/tests/fixtures/plans/portable-vita.plan.json +++ b/tests/fixtures/plans/portable-vita.plan.json @@ -29,5 +29,14 @@ "text.glyphs.baked": true }, "companions": [], - "planHash": "sha256:5b2ab23a3d3b54e0ef54bdd706092cd8350561d978f6cb1280a0c72afa647e96" + "network": { + "version": 1, + "connect": [], + "listen": [], + "credentials": [], + "localNetwork": false, + "insecureTransport": false, + "allowInvalidTlsForDevelopment": false + }, + "planHash": "sha256:85ff09c126edf5758b21166b4e9cad6d1b02bffd49461d1af8605f90a5cec909" } diff --git a/tests/host-build-inputs.test.ts b/tests/host-build-inputs.test.ts index 3a0d030b..98f87a63 100644 --- a/tests/host-build-inputs.test.ts +++ b/tests/host-build-inputs.test.ts @@ -1,4 +1,8 @@ import { describe, expect, test } from "bun:test"; +import { + DENY_ALL_NETWORK_POLICY, + canonicalNetworkPolicyJson, +} from "../contracts/spec/network-policy.ts"; import { extractHostBuildInputs, hostBuildEnvironment, @@ -22,13 +26,67 @@ describe("custom host build boundary", () => { appOutput: "main", target: "psp", hostAbi: 1, + planHash: plan.planHash, viewport: { logical: [480, 272], physical: [480, 272], presentation: "integer-fit", rasterDensity: 1, }, + features: { + "input.analog.left": true, + "input.buttons": true, + "text.glyphs.baked": true, + }, + // A format-2 manifest carries no permissions: the host receives the + // deny-all policy, spelled in the canonical form every core parses. + network: { + policy: DENY_ALL_NETWORK_POLICY, + policyJson: canonicalNetworkPolicyJson(DENY_ALL_NETWORK_POLICY), + }, + }); + expect(extractHostBuildInputs(plan).network.policyJson).toBe( + '{"allowInvalidTlsForDevelopment":false,"connect":[],"credentials":[],"insecureTransport":false,"listen":[],"localNetwork":false,"version":1}', + ); + }); + + test("projects a format-3 network policy verbatim and refuses a tampered one", () => { + const manifest = structuredClone(portableInput) as Record; + manifest.$schema = "https://pocketjs.dev/schema/pocket-3.json"; + manifest.pocket = 3; + manifest.permissions = { + network: { + connect: [ + { protocol: "https", host: "API.Example.com.", port: 443 }, + { protocol: "http", host: "192.168.1.20", port: { min: 8080, max: 8080 } }, + ], + listen: [{ protocol: "http", address: "0:0:0:0:0:0:0:0", port: "ephemeral" }], + credentials: ["device-cert"], + insecureTransport: true, + localNetwork: true, + }, + }; + const result = validateAndResolveBuildPlan(manifest, { target: "psp" }); + expect(result.ok).toBe(true); + if (!result.ok) return; + const inputs = extractHostBuildInputs(result.plan); + expect(inputs.network.policy).toEqual({ + version: 1, + connect: [ + { protocol: "http", host: "192.168.1.20", port: 8080 }, + { protocol: "https", host: "api.example.com", port: 443 }, + ], + listen: [{ protocol: "http", address: "::", port: "ephemeral" }], + credentials: ["device-cert"], + localNetwork: true, + insecureTransport: true, + allowInvalidTlsForDevelopment: false, }); + expect(inputs.network.policyJson).toBe(canonicalNetworkPolicyJson(result.plan.network)); + // Widening the policy after resolution breaks the checksum. + const widened = structuredClone(result.plan) as any; + widened.network.connect.push({ protocol: "https", host: "evil.example", port: 443 }); + expect(() => extractHostBuildInputs(widened)).toThrow("invalid ResolvedBuildPlan checksum"); }); test("rejects a modified plan and an unexpected target", () => { @@ -56,6 +114,8 @@ describe("custom host build boundary", () => { POCKETJS_PHYSICAL_HEIGHT: "272", POCKETJS_PRESENTATION: "integer-fit", POCKETJS_RASTER_DENSITY: "1", + POCKETJS_PLAN_HASH: inputs.planHash, + POCKETJS_NETWORK_POLICY: canonicalNetworkPolicyJson(DENY_ALL_NETWORK_POLICY), }); }); }); diff --git a/tests/http-semantics.test.ts b/tests/http-semantics.test.ts new file mode 100644 index 00000000..82bcaef3 --- /dev/null +++ b/tests/http-semantics.test.ts @@ -0,0 +1,123 @@ +// The SDK, the sim host and the browser dev host against the shared HTTP +// semantics vectors (contracts/spec/vectors/http-semantics.json): method +// acceptance, core-owned request headers, null-body statuses and the +// redirect status table. engine/net (pnet_unit_test) and the Rust core run +// the same file. +import { afterEach, describe, expect, test } from "bun:test"; + +import { + HTTP_BODYLESS_STATUS, + HTTP_NULL_BODY_STATUS, + HTTP_REDIRECT_ANY_TO_GET_STATUS, + HTTP_REDIRECT_POST_TO_GET_STATUS, + HTTP_REDIRECT_STATUS, + NET_ERROR, +} from "../contracts/spec/net.ts"; +import { fetch as pocketFetch, Request, Response, type NetOps } from "../framework/src/net/http.ts"; +import { runServicePumps } from "../framework/src/services.ts"; +import { createSimNetHost } from "../hosts/sim/net.ts"; +// @ts-expect-error — the browser dev host is plain ESM without declarations. +import { createNetHost as createWebNetHost } from "../hosts/web/net.js"; + +interface Vectors { + readonly methods: readonly { method: string; accepted: boolean }[]; + readonly requestHeaders: readonly { name: string; coreOwned: boolean }[]; + readonly status: readonly { status: number; bodylessFraming: boolean; nullBody: boolean }[]; + readonly redirect: readonly { + status: number; + method: string; + followed: boolean; + nextMethod?: string; + keepBody?: boolean; + }[]; +} + +const vectors = (await Bun.file(new URL("../contracts/spec/vectors/http-semantics.json", import.meta.url)).json()) as Vectors; + +afterEach(() => { + delete (globalThis as { net?: NetOps }).net; +}); + +async function ticks(host: { tick(): void }, n = 1): Promise { + for (let i = 0; i < n; i++) { + host.tick(); + runServicePumps(); + for (let j = 0; j < 8; j++) await Promise.resolve(); + } +} + +describe("http semantics vectors", () => { + test("methods: the SDK accepts or refuses before the host; the sim and web hosts decide the same at start()", async () => { + const seen: string[] = []; + const host = createSimNetHost({ + "http://example.test/m": (request) => { + seen.push(request.method); + return { body: "ok" }; + }, + }); + (globalThis as { net?: NetOps }).net = host.ns; + for (const v of vectors.methods) { + if (v.accepted) { + const pending = pocketFetch("http://example.test/m", { method: v.method }); + await ticks(host); + expect((await pending).status, v.method).toBe(200); + } else { + await expect(pocketFetch("http://example.test/m", { method: v.method }), v.method).rejects.toMatchObject({ + code: NET_ERROR.invalidRequest, + }); + } + // The hosts' own check (the SDK normalizes standard tokens, so feed + // them the raw token): valid-but-forbidden tokens refuse with + // invalid_request, accepted tokens start. + const meta = JSON.stringify({ url: "http://example.test/m", method: v.method, headers: {} }); + const simHandle = host.ns.start(meta, null); + expect(simHandle > 0, `sim ${v.method}`).toBe(v.accepted); + if (simHandle > 0) host.ns.cancel(simHandle); + const web = createWebNetHost(async () => new globalThis.Response("x")) as { ns: NetOps }; + const webHandle = web.ns.start(meta, null); + expect(webHandle > 0, `web ${v.method}`).toBe(v.accepted); + if (webHandle > 0) web.ns.cancel(webHandle); + } + // One fetch through the SDK plus one raw start() per accepted token. + expect(seen.length).toBe(2 * vectors.methods.filter((v) => v.accepted).length); + await ticks(host, 2); + }); + + test("request headers: core-owned names are silently dropped on a Request, others kept", () => { + for (const v of vectors.requestHeaders) { + const request = new Request("http://example.test/", { headers: { [v.name]: "value" } }); + expect(request.headers.has(v.name.toLowerCase()), v.name).toBe(!v.coreOwned); + } + }); + + test("statuses: a Response refuses a body exactly for the null-body set; framing constants agree", () => { + for (const v of vectors.status) { + // App-constructed responses take 200..599 (1xx exist only on the wire). + if (v.status >= 200 && v.nullBody) { + expect(() => new Response("x", { status: v.status }), String(v.status)).toThrow(); + expect(new Response(null, { status: v.status }).status).toBe(v.status); + } else if (v.status >= 200) { + expect(new Response("x", { status: v.status }).status).toBe(v.status); + } + const framingBodyless = (v.status >= 100 && v.status < 200) || (HTTP_BODYLESS_STATUS as readonly number[]).includes(v.status); + expect(framingBodyless, `framing ${v.status}`).toBe(v.bodylessFraming); + expect((HTTP_NULL_BODY_STATUS as readonly number[]).includes(v.status), `null ${v.status}`).toBe(v.nullBody); + } + }); + + test("redirects: the followed set and the method rewrite table", () => { + for (const v of vectors.redirect) { + const followed = (HTTP_REDIRECT_STATUS as readonly number[]).includes(v.status); + expect(followed, String(v.status)).toBe(v.followed); + if (followed) { + expect(Response.redirect("http://example.test/next", v.status).status).toBe(v.status); + const toGet = ((HTTP_REDIRECT_ANY_TO_GET_STATUS as readonly number[]).includes(v.status) && v.method !== "HEAD") || + ((HTTP_REDIRECT_POST_TO_GET_STATUS as readonly number[]).includes(v.status) && v.method === "POST"); + expect(toGet ? "GET" : v.method, `${v.status} ${v.method}`).toBe(v.nextMethod!); + expect(!toGet, `${v.status} ${v.method} body`).toBe(v.keepBody!); + } else { + expect(() => Response.redirect("http://example.test/next", v.status)).toThrow(); + } + } + }); +}); diff --git a/tests/net-policy-hosts.test.ts b/tests/net-policy-hosts.test.ts new file mode 100644 index 00000000..940e1a2f --- /dev/null +++ b/tests/net-policy-hosts.test.ts @@ -0,0 +1,135 @@ +// The sim hosts enforce a Build Plan network policy the way the native cores +// do: the connect rule and insecureTransport before any route lookup (and +// before the pump sees a handle), the listen rule before bind, the redirect +// target again. The policies are the shared vectors' documents, so the same +// decisions the C and Rust cores pin here arrive at the SDK as +// `permission_denied`. + +import { afterEach, describe, expect, test } from "bun:test"; + +import { NET_ERROR } from "../contracts/spec/net.ts"; +import { canonicalNetworkPolicyJson, parseNetworkPolicyJson } from "../contracts/spec/network-policy.ts"; +import { fetch as pocketFetch, Response, serve, type HttpdOps, type NetOps } from "../framework/src/net/http.ts"; +import { connect, type WsOps } from "../framework/src/net/websocket.ts"; +import { runServicePumps } from "../framework/src/services.ts"; +import { createSimHttpdHost } from "../hosts/sim/httpd.ts"; +import { createSimNetHost } from "../hosts/sim/net.ts"; +import { createSimWsHost } from "../hosts/sim/ws.ts"; + +const vectors = (await Bun.file(new URL("../contracts/spec/vectors/network-policy.json", import.meta.url)).json()) as { + policies: Record; +}; +const standard = parseNetworkPolicyJson(JSON.stringify(vectors.policies.standard)); +const secureOnly = parseNetworkPolicyJson(JSON.stringify(vectors.policies["secure-only"])); + +type Globals = { net?: NetOps; ws?: WsOps; httpd?: HttpdOps }; +afterEach(() => { + const g = globalThis as Globals; + delete g.net; + delete g.ws; + delete g.httpd; +}); + +async function ticks(host: { tick(): void }, n = 1): Promise { + for (let i = 0; i < n; i++) { + host.tick(); + runServicePumps(); + for (let j = 0; j < 12; j++) await Promise.resolve(); + } +} + +describe("sim hosts enforce the plan's network policy", () => { + test("net: connect rule + insecureTransport decide before routes; the pump never sees a refused handle", async () => { + const routes = { + "http://localhost:8050/ok": { body: "ok" }, + "http://localhost:9000/no": { body: "never" }, + "http://192.168.1.20:8080/ip": { body: "ip" }, + "http://192.168.1.21:8080/other": { body: "never" }, + }; + const host = createSimNetHost(routes, { policy: canonicalNetworkPolicyJson(standard) }); + (globalThis as Globals).net = host.ns; + + const ok = pocketFetch("http://localhost:8050/ok"); + await ticks(host); + expect((await ok).status).toBe(200); + const ip = pocketFetch("http://192.168.1.20:8080/ip"); + await ticks(host); + expect((await ip).status).toBe(200); + + const polls = host.pollCalls(); + // Routed, but outside the policy: refused synchronously. + await expect(pocketFetch("http://localhost:9000/no")).rejects.toMatchObject({ code: NET_ERROR.permissionDenied }); + await expect(pocketFetch("http://192.168.1.21:8080/other")).rejects.toMatchObject({ code: NET_ERROR.permissionDenied }); + await expect(pocketFetch("http://LOCALHOST:8101/x")).rejects.toMatchObject({ code: NET_ERROR.permissionDenied }); + runServicePumps(); + expect(host.pollCalls()).toBe(polls); + expect(host.log.filter((line) => line.startsWith("start"))).toHaveLength(2); + }); + + test("net: insecureTransport=false refuses a matched plaintext rule", async () => { + const host = createSimNetHost( + { "http://api.example.com/x": { body: "x" } }, + { policy: secureOnly }, + ); + (globalThis as Globals).net = host.ns; + await expect(pocketFetch("http://api.example.com/x")).rejects.toMatchObject({ code: NET_ERROR.permissionDenied }); + // Without a policy the same host answers (routes are the allowlist). + const open = createSimNetHost({ "http://api.example.com/x": { body: "x" } }); + (globalThis as Globals).net = open.ns; + const response = pocketFetch("http://api.example.com/x"); + await ticks(open); + expect((await response).status).toBe(200); + }); + + test("net: a redirect target outside the policy fails the exchange with permission_denied", async () => { + const host = createSimNetHost( + { + "http://localhost:8050/go": { url: "http://localhost:9000/landed", redirected: true, body: "landed" }, + "http://localhost:8051/go": { url: "http://localhost:8052/landed", redirected: true, body: "landed" }, + }, + { policy: standard }, + ); + (globalThis as Globals).net = host.ns; + const refused = pocketFetch("http://localhost:8050/go"); + const followed = pocketFetch("http://localhost:8051/go"); + await ticks(host, 2); + await expect(refused).rejects.toMatchObject({ code: NET_ERROR.permissionDenied }); + const response = await followed; + expect(response.redirected).toBe(true); + expect(response.url).toBe("http://localhost:8052/landed"); + }); + + test("ws: the connect rule is checked before the peer table", async () => { + const host = createSimWsHost( + { "ws://echo.example.com/s": {}, "ws://other.example.com/s": {} }, + { policy: standard }, + ); + (globalThis as Globals).ws = host.ns; + await expect(connect("ws://other.example.com/s", { socket: {} })).rejects.toMatchObject({ code: NET_ERROR.permissionDenied }); + const opening = connect("ws://echo.example.com/s", { socket: {} }); + await ticks(host); + const socket = await opening; + expect(socket.readyState).toBe("open"); + socket.terminate(); + await ticks(host); + }); + + test("httpd: listen tuples decide bind; ephemeral only matches port 0", async () => { + const host = createSimHttpdHost({ policy: standard }); + (globalThis as Globals).httpd = host.ns; + await expect(serve({ hostname: "0.0.0.0", port: 8081, fetch: () => new Response("x") })) + .rejects.toMatchObject({ code: NET_ERROR.permissionDenied }); + await expect(serve({ hostname: "127.0.0.1", port: 8080, fetch: () => new Response("x") })) + .rejects.toMatchObject({ code: NET_ERROR.permissionDenied }); + const listening = serve({ hostname: "0.0.0.0", port: 8080, fetch: () => new Response("x") }); + const ephemeral = serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("x") }); + await ticks(host); + const server = await listening; + expect(server.port).toBe(8080); + const eph = await ephemeral; + expect(eph.port).toBeGreaterThan(0); + server.stop(); + eph.stop(); + await ticks(host); + }); +}); diff --git a/tests/net.test.ts b/tests/net.test.ts index 49b4e93b..e61a13f2 100644 --- a/tests/net.test.ts +++ b/tests/net.test.ts @@ -161,6 +161,47 @@ describe("net SDK + deterministic sim host", () => { expect(() => original.clone()).toThrow(NetworkError); }); + test("clone's tee is a hard bound: the lagging branch never holds more than the aggregate limit", async () => { + // 3 KiB body in 1 KiB chunks, a 2 KiB aggregate limit: the branch that + // is not read may buffer at most 2 KiB; the reading branch then waits + // (backpressure on the source) until the other branch drains. + const chunk = "x".repeat(1024); + const host = createSimNetHost({ "http://example.test/tee": { body: [chunk, chunk, chunk], chunkTicks: 1 } }); + mount(host.ns); + const promise = pocketFetch("http://example.test/tee", { limits: { aggregateBytes: 2048 } }); + await ticks(host); + const original = await promise; + const copy = original.clone(); + const reader = original.body!; + // TeeBranch (the runtime class behind the clone's BodyStream) exposes + // its backlog; the test reads it through the class, not the public type. + const lagging = copy.body as unknown as import("../framework/src/net/body.ts").TeeBranch; + let read = 0; + const sink = new Uint8Array(256); + // Drive the leading branch as fast as the sim delivers. + const pump = (async () => { + for (;;) { + const { bytes, done } = await reader.readInto(sink); + read += bytes; + if (done) break; + } + })(); + await ticks(host, 6); + // The leading branch is throttled by the bound: it cannot run ahead of + // the lagging branch by more than 2 KiB, whatever the source offers. + expect(read).toBeLessThanOrEqual(2048); + expect(lagging.available()).toBeLessThanOrEqual(2048); + expect(lagging.available()).toBe(read); + // Draining the lagging branch releases the leading one. + const drained = lagging.readInto(new Uint8Array(4096)); + await ticks(host, 6); + expect((await drained).bytes).toBeGreaterThan(0); + await pump; + expect(read).toBe(3072); + await lagging.cancel(); + await ticks(host, 2); + }); + test("HEAD and 204 responses have a null body and retire on end", async () => { const host = createSimNetHost({ "http://example.test/head": { status: 200, headers: { "content-length": "42" }, length: 42, body: "" }, diff --git a/tests/network-policy.test.ts b/tests/network-policy.test.ts new file mode 100644 index 00000000..080739f5 --- /dev/null +++ b/tests/network-policy.test.ts @@ -0,0 +1,129 @@ +// The TypeScript reference of the network policy contract against the shared +// vectors. engine/net (pnet_unit_test) and engine/crates/pocket-net run the +// same file; a decision that differs between the three is a conformance +// failure, not a host quirk. +import { describe, expect, test } from "bun:test"; +import { + DENY_ALL_NETWORK_POLICY, + canonicalNetworkPolicyJson, + formatNetworkAddress, + networkAddressIsMulticast, + networkAddressIsPublic, + networkPolicyAllowsAddress, + networkPolicyAllowsConnect, + networkPolicyAllowsListen, + parseNetworkAddress, + parseNetworkPolicyJson, + resolveNetworkPolicy, + type ResolvedNetworkPolicy, +} from "../contracts/spec/network-policy.ts"; + +interface Vectors { + readonly policies: Readonly>; + readonly invalid: readonly { name: string; policy: unknown }[]; + readonly connect: readonly { policy: string; protocol: string; host: string; port: number; allowed: boolean }[]; + readonly address: readonly { address: string; public: boolean; multicast: boolean }[]; + readonly listen: readonly { policy: string; protocol: string; address: string; port: number; allowed: boolean }[]; +} + +const vectors = (await Bun.file(new URL("../contracts/spec/vectors/network-policy.json", import.meta.url)).json()) as Vectors; + +const policies = new Map(); +for (const [name, document] of Object.entries(vectors.policies)) { + policies.set(name, parseNetworkPolicyJson(JSON.stringify(document))); +} + +describe("network policy vectors", () => { + test("every vector policy is canonical: parse → canonical JSON reproduces the document", () => { + for (const [name, document] of Object.entries(vectors.policies)) { + const policy = policies.get(name)!; + expect(JSON.parse(canonicalNetworkPolicyJson(policy))).toEqual(document); + // Canonical JSON is a fixed point. + expect(canonicalNetworkPolicyJson(parseNetworkPolicyJson(canonicalNetworkPolicyJson(policy)))).toBe(canonicalNetworkPolicyJson(policy)); + } + expect(policies.get("deny-all")).toEqual(DENY_ALL_NETWORK_POLICY); + }); + + test("invalid documents are refused", () => { + for (const { name, policy } of vectors.invalid) { + expect(() => parseNetworkPolicyJson(JSON.stringify(policy)), name).toThrow(); + } + }); + + test("connect decisions", () => { + for (const v of vectors.connect) { + const policy = policies.get(v.policy)!; + expect(networkPolicyAllowsConnect(policy, v.protocol, v.host, v.port), JSON.stringify(v)).toBe(v.allowed); + } + }); + + test("address classification and the localNetwork gate", () => { + const open = policies.get("standard")!; // localNetwork: true + const closed = policies.get("secure-only")!; // localNetwork: false + for (const v of vectors.address) { + const addr = parseNetworkAddress(v.address); + expect(addr, v.address).not.toBeNull(); + expect(networkAddressIsPublic(addr!), v.address).toBe(v.public); + expect(networkAddressIsMulticast(addr!), v.address).toBe(v.multicast); + expect(networkPolicyAllowsAddress(closed, addr!), v.address).toBe(v.public); + expect(networkPolicyAllowsAddress(open, addr!), v.address).toBe(!v.multicast); + // Canonical text round-trips. + expect(formatNetworkAddress(parseNetworkAddress(formatNetworkAddress(addr!))!)).toBe(formatNetworkAddress(addr!)); + } + }); + + test("listen decisions", () => { + for (const v of vectors.listen) { + const policy = policies.get(v.policy)!; + expect(networkPolicyAllowsListen(policy, v.protocol, v.address, v.port), JSON.stringify(v)).toBe(v.allowed); + } + }); +}); + +describe("network policy resolution", () => { + test("normalizes, sorts and collapses manifest intent", () => { + const result = resolveNetworkPolicy({ + connect: [ + { protocol: "https", host: "B.Example.com.", port: { min: 443, max: 443 } }, + { protocol: "https", host: "a.example.com", port: 443 }, + { protocol: "http", host: "[::1]", port: { min: 1, max: 65535 } }, + ], + listen: [{ protocol: "http", address: "0000:0000:0000:0000:0000:0000:0000:0001", port: "ephemeral" }], + credentials: ["b", "a"], + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.policy).toEqual({ + version: 1, + connect: [ + { protocol: "http", host: "::1", port: { min: 1, max: 65535 } }, + { protocol: "https", host: "a.example.com", port: 443 }, + { protocol: "https", host: "b.example.com", port: 443 }, + ], + listen: [{ protocol: "http", address: "::1", port: "ephemeral" }], + credentials: ["a", "b"], + localNetwork: false, + insecureTransport: false, + allowInvalidTlsForDevelopment: false, + }); + }); + + test("reports every fault with its pointer under the caller's prefix", () => { + const result = resolveNetworkPolicy( + { + connect: [{ protocol: "https", host: "*", port: 443 }, { protocol: "https", host: "ok.example", port: 443 }, { protocol: "https", host: "OK.example", port: 443 }], + listen: [{ protocol: "http", address: "0.0.0.0", port: { min: 2, max: 1 } }], + allowInvalidTlsForDevelopment: true, + }, + { path: "/x" }, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.diagnostics.map((d) => [d.code, d.path])).toEqual([ + ["network.invalidHost", "/x/connect/0/host"], + ["network.duplicateRule", "/x/connect/2"], + ["network.reversedPortRange", "/x/listen/0/port"], + ["network.developmentOnly", "/x/allowInvalidTlsForDevelopment"], + ]); + }); +}); diff --git a/tests/platform-contracts.test.ts b/tests/platform-contracts.test.ts index 583775e7..5e633fb6 100644 --- a/tests/platform-contracts.test.ts +++ b/tests/platform-contracts.test.ts @@ -1,9 +1,12 @@ import { describe, expect, test } from "bun:test"; import { generatePocketManifestV2Schema, + generatePocketManifestV3Schema, POCKET_MANIFEST_SCHEMA_ID, - type PocketManifestV2, + POCKET_MANIFEST_V3_SCHEMA_ID, + type PocketManifest, } from "../contracts/spec/pocket-manifest.ts"; +import { DENY_ALL_NETWORK_POLICY, canonicalNetworkPolicyJson } from "../contracts/spec/network-policy.ts"; import { POCKET_CAPABILITIES, POCKET_PLATFORM_CONTRACTS, @@ -28,7 +31,7 @@ const portableInput: unknown = await Bun.file(fixtureUrl("portable-psp")).json() const invalidExtraInput: unknown = await Bun.file(fixtureUrl("invalid-extra-field")).json(); const touchInput: unknown = await Bun.file(fixtureUrl("requires-touch")).json(); -function manifest(input: unknown): PocketManifestV2 { +function manifest(input: unknown): PocketManifest { const result = validatePocketManifest(input); if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); return result.value; @@ -139,6 +142,172 @@ describe("pocket.json v2 schema", () => { }); }); +describe("pocket.json v3 schema (format 2 + permissions)", () => { + function formatThree(): Record { + const input = structuredClone(portableInput) as Record; + input.$schema = POCKET_MANIFEST_V3_SCHEMA_ID; + input.pocket = 3; + return input; + } + + test("uses its own schema path and the committed JSON Schema is byte-exact", async () => { + expect(POCKET_MANIFEST_V3_SCHEMA_ID).toBe("https://pocketjs.dev/schema/pocket-3.json"); + const committed = await Bun.file(new URL("../contracts/schema/pocket-3.json", import.meta.url)).text(); + expect(committed).toBe(generatePocketManifestV3Schema()); + }); + + test("accepts a format-3 manifest with and without permissions; refuses other formats", () => { + expect(validatePocketManifest(formatThree()).ok).toBe(true); + const withNetwork = formatThree(); + withNetwork.permissions = { + network: { + connect: [{ protocol: "https", host: "api.example.com", port: 443 }], + listen: [{ protocol: "http", address: "127.0.0.1", port: "ephemeral" }], + credentials: ["device-cert"], + localNetwork: false, + insecureTransport: true, + }, + }; + expect(validatePocketManifest(withNetwork).ok).toBe(true); + + // Format 2 stays strict: `permissions` is an unknown field there. + const twoWithPermissions = structuredClone(portableInput) as Record; + twoWithPermissions.permissions = { network: {} }; + const two = validatePocketManifest(twoWithPermissions); + expect(two.ok).toBe(false); + if (!two.ok) expect(two.diagnostics).toContainEqual({ code: "schema.additionalProperty", path: "/permissions", message: "unknown property" }); + + const four = structuredClone(portableInput) as Record; + four.pocket = 4; + const result = validatePocketManifest(four); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.diagnostics).toEqual([{ code: "schema.enum", path: "/pocket", message: "expected one of 2, 3" }]); + }); + + test("rejects malformed network permissions at their JSON Pointer", () => { + const bad = formatThree(); + bad.permissions = { + network: { + connect: [ + { protocol: "ftp", host: "x", port: 21 }, + { protocol: "https", host: "", port: 443 }, + { protocol: "https", host: "api.example.com", port: 70000 }, + { protocol: "https", host: "api.example.com", port: "ephemeral" }, + ], + listen: [{ protocol: "http", address: "0.0.0.0", port: 0 }], + broadcast: true, + }, + }; + const result = validatePocketManifest(bad); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.diagnostics.map((item) => [item.code, item.path])).toEqual(expect.arrayContaining([ + ["schema.enum", "/permissions/network/connect/0/protocol"], + ["schema.minLength", "/permissions/network/connect/1/host"], + ["schema.anyOf", "/permissions/network/connect/2/port"], + ["schema.anyOf", "/permissions/network/connect/3/port"], + ["schema.anyOf", "/permissions/network/listen/0/port"], + ["schema.additionalProperty", "/permissions/network/broadcast"], + ])); + }); + + test("resolves permissions into the plan's canonical network policy", () => { + const input = formatThree(); + input.permissions = { + network: { + connect: [ + { protocol: "https", host: "Api.Example.COM.", port: 443 }, + { protocol: "https", host: "*.Devices.example.com", port: { min: 8443, max: 8443 } }, + { protocol: "http", host: "[2001:DB8:0:0:0:0:0:1]", port: { min: 8000, max: 8100 } }, + ], + listen: [ + { protocol: "http", address: "0.0.0.0", port: 8080 }, + { protocol: "http", address: "127.0.0.1", port: "ephemeral" }, + ], + credentials: ["device-cert", "backup-cert"], + insecureTransport: true, + }, + }; + const result = validateAndResolveBuildPlan(input, { target: "psp" }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.plan.network).toEqual({ + version: 1, + connect: [ + { protocol: "http", host: "2001:db8::1", port: { min: 8000, max: 8100 } }, + { protocol: "https", host: "*.devices.example.com", port: 8443 }, + { protocol: "https", host: "api.example.com", port: 443 }, + ], + listen: [ + { protocol: "http", address: "0.0.0.0", port: 8080 }, + { protocol: "http", address: "127.0.0.1", port: "ephemeral" }, + ], + credentials: ["backup-cert", "device-cert"], + localNetwork: false, + insecureTransport: true, + allowInvalidTlsForDevelopment: false, + }); + expect(verifyPlanHash(result.plan)).toBe(true); + // The same intent in another order yields the same plan hash: the + // policy is canonical, not positional. + const shuffled = structuredClone(input); + shuffled.permissions.network.connect.reverse(); + shuffled.permissions.network.listen.reverse(); + shuffled.permissions.network.credentials.reverse(); + const again = validateAndResolveBuildPlan(shuffled, { target: "psp" }); + expect(again.ok && again.plan.planHash).toBe(result.plan.planHash); + // And a different policy is a different plan. + const wider = structuredClone(input); + wider.permissions.network.localNetwork = true; + const widerPlan = validateAndResolveBuildPlan(wider, { target: "psp" }); + expect(widerPlan.ok && widerPlan.plan.planHash).not.toBe(result.plan.planHash); + }); + + test("format 2 and a permission-less format 3 resolve to the deny-all policy", () => { + const two = validateAndResolveBuildPlan(portableInput, { target: "psp" }); + const three = validateAndResolveBuildPlan(formatThree(), { target: "psp" }); + expect(two.ok && three.ok).toBe(true); + if (!two.ok || !three.ok) return; + expect(two.plan.network).toEqual(DENY_ALL_NETWORK_POLICY); + expect(three.plan.network).toEqual(DENY_ALL_NETWORK_POLICY); + expect(canonicalNetworkPolicyJson(two.plan.network)).toBe(canonicalNetworkPolicyJson(three.plan.network)); + }); + + test("refuses semantic policy faults: bare wildcard, reversed range, duplicates, dev-only TLS", () => { + const input = formatThree(); + input.permissions = { + network: { + connect: [ + { protocol: "https", host: "*", port: 443 }, + { protocol: "https", host: "api.example.com", port: { min: 9000, max: 8000 } }, + { protocol: "https", host: "api.example.com", port: 443 }, + { protocol: "https", host: "API.example.com", port: { min: 443, max: 443 } }, + { protocol: "https", host: "*.1.2.3.4", port: 443 }, + ], + listen: [{ protocol: "http", address: "localhost", port: 8080 }], + allowInvalidTlsForDevelopment: true, + }, + }; + const result = validateAndResolveBuildPlan(input, { target: "psp" }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.diagnostics.map((item) => [item.code, item.path])).toEqual([ + ["network.invalidHost", "/permissions/network/connect/0/host"], + ["network.reversedPortRange", "/permissions/network/connect/1/port"], + ["network.duplicateRule", "/permissions/network/connect/3"], + ["network.invalidHost", "/permissions/network/connect/4/host"], + ["network.invalidAddress", "/permissions/network/listen/0/address"], + ["network.developmentOnly", "/permissions/network/allowInvalidTlsForDevelopment"], + ]); + // A development build admits the dev-only switch. + const devOnly = formatThree(); + devOnly.permissions = { network: { allowInvalidTlsForDevelopment: true } }; + expect(validateAndResolveBuildPlan(devOnly, { target: "psp" }).ok).toBe(false); + const dev = validateAndResolveBuildPlan(devOnly, { target: "psp", development: true }); + expect(dev.ok && dev.plan.network.allowInvalidTlsForDevelopment).toBe(true); + }); +}); + describe("platform registry", () => { test("production advertises only the truthful stock-host profiles", () => { expect(Object.keys(POCKET_TARGETS)).toEqual([ diff --git a/tests/symbian-package.test.ts b/tests/symbian-package.test.ts index dc674e09..f4f23c14 100644 --- a/tests/symbian-package.test.ts +++ b/tests/symbian-package.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { DENY_ALL_NETWORK_POLICY } from "../contracts/spec/network-policy.ts"; import type { ResolvedBuildPlan } from "../framework/src/manifest/plan.ts"; import { symbianDataBaseForEmbeddedBytes, @@ -31,6 +32,7 @@ function plan( }, features: {}, companions: [], + network: DENY_ALL_NETWORK_POLICY, planHash: `sha256:${"0".repeat(64)}`, }; } diff --git a/tests/symbian-runtime.test.ts b/tests/symbian-runtime.test.ts index 9a84d25c..bc2cf6f2 100644 --- a/tests/symbian-runtime.test.ts +++ b/tests/symbian-runtime.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { DENY_ALL_NETWORK_POLICY } from "../contracts/spec/network-policy.ts"; import { existsSync, mkdtempSync, @@ -150,6 +151,7 @@ describe("experimental Nokia E7 runtime profile", () => { }, features: {}, companions: [], + network: DENY_ALL_NETWORK_POLICY, planHash: `sha256:${"0".repeat(64)}`, }, packageBytes: new Uint8Array(bytes), diff --git a/tools/test.ts b/tools/test.ts index cba3450a..0121bd9b 100644 --- a/tools/test.ts +++ b/tools/test.ts @@ -69,6 +69,9 @@ const SUITE: readonly Stage[] = [ "tests/net-httpd.test.ts", "tests/net-websocket.test.ts", "tests/net-web.test.js", + "tests/network-policy.test.ts", + "tests/net-policy-hosts.test.ts", + "tests/http-semantics.test.ts", "tests/vita-package.test.ts", "tests/psp-toolchain.test.ts", "tests/symbian-data.test.ts",