From 7a9b19863bdb3163d4f47b6fbc3025d08146b1c4 Mon Sep 17 00:00:00 2001 From: Jerry Yuan Date: Fri, 7 Aug 2026 13:09:03 +0800 Subject: [PATCH] feat(net): add bounded fetch module --- contracts/spec/gen-rust.ts | 43 +- contracts/spec/net.ts | 123 ++++++ contracts/spec/platforms.ts | 5 + docs/NET.md | 134 ++++++ docs/RUNTIMES.md | 1 + engine/Cargo.lock | 12 + engine/Cargo.toml | 2 + engine/core/src/spec.rs | 34 ++ engine/crates/pocket-net/Cargo.toml | 15 + engine/crates/pocket-net/src/lib.rs | 660 ++++++++++++++++++++++++++++ framework/compiler/subpaths.ts | 1 + framework/src/index-octane.ts | 2 + framework/src/index-vue-vapor.ts | 2 + framework/src/index.ts | 2 + framework/src/net-api.ts | 405 +++++++++++++++++ framework/src/services.ts | 22 + hosts/sim/net.ts | 176 ++++++++ hosts/web/engine.js | 8 + hosts/web/net.js | 224 ++++++++++ package.json | 3 + site/content/docs/concepts.md | 21 +- site/content/docs/net.md | 90 ++++ site/nav.ts | 2 +- tests/net-web.test.js | 57 +++ tests/net.test.ts | 150 +++++++ tools/test.ts | 2 + 26 files changed, 2186 insertions(+), 10 deletions(-) create mode 100644 contracts/spec/net.ts create mode 100644 docs/NET.md create mode 100644 engine/crates/pocket-net/Cargo.toml create mode 100644 engine/crates/pocket-net/src/lib.rs create mode 100644 framework/src/net-api.ts create mode 100644 framework/src/services.ts create mode 100644 hosts/sim/net.ts create mode 100644 hosts/web/net.js create mode 100644 site/content/docs/net.md create mode 100644 tests/net-web.test.js create mode 100644 tests/net.test.ts diff --git a/contracts/spec/gen-rust.ts b/contracts/spec/gen-rust.ts index b19bd9f6..f6fcc38c 100644 --- a/contracts/spec/gen-rust.ts +++ b/contracts/spec/gen-rust.ts @@ -1,4 +1,4 @@ -// Deterministic codegen: contracts/spec/{spec,audio}.ts -> engine/core/src/spec.rs. +// Deterministic codegen: contracts/spec/{spec,audio,net}.ts -> engine/core/src/spec.rs. // // Run from PocketJS/: bun contracts/spec/gen-rust.ts // @@ -16,6 +16,21 @@ import { AUDIO_RING_FRAMES, audioFramesForTick, } from "./audio.ts"; +import { + NET_DEFAULT_RESPONSE_BYTES, + NET_DEFAULT_TIMEOUT_MS, + NET_ERROR, + NET_EVENT, + NET_MAX_HEADER_BYTES, + NET_MAX_HEADERS, + NET_MAX_INFLIGHT, + NET_MAX_REDIRECTS, + NET_MAX_REQUEST_BYTES, + NET_MAX_RESPONSE_BYTES, + NET_MAX_TIMEOUT_MS, + NET_METHODS, + NET_OP, +} from "./net.ts"; import { ANALOG_CENTER, ANIMATABLE, @@ -462,6 +477,32 @@ export function generateRust(): string { put(` pub const EVENT_${screaming(name)}: &str = ${JSON.stringify(v)};`); } put("}"); + put(""); + + // --- net module --------------------------------------------------------------- + put("/// NET module boundary (contracts/spec/net.ts — `globalThis.net`)."); + put("/// Bounded whole-response HTTP; completions batch to tick boundaries."); + put("pub mod net {"); + 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 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(", ")}];`); + for (const [name, v] of Object.entries(NET_EVENT)) { + put(` pub const EVENT_${screaming(name)}: &str = ${JSON.stringify(v)};`); + } + for (const [name, v] of Object.entries(NET_ERROR)) { + put(` pub const ERROR_${screaming(name)}: &str = ${JSON.stringify(v)};`); + } + put("}"); return L.join("\n") + "\n"; } diff --git a/contracts/spec/net.ts b/contracts/spec/net.ts new file mode 100644 index 00000000..974c4bdb --- /dev/null +++ b/contracts/spec/net.ts @@ -0,0 +1,123 @@ +// PocketJS net spec — the boundary of the NET 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 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 +// 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 +// +// 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. +// +// 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). + +// --------------------------------------------------------------------------- +// Net ops (the `net.*` native contract) +// --------------------------------------------------------------------------- +// +// 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. +// cancel(handle) +// Best-effort transport cancellation and unconditional core cleanup. +// 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. +// lastError() -> string +// Portable `code: message` for the most recent synchronous refusal. + +export const NET_OP = { + start: 1, + take: 2, + cancel: 3, + poll: 4, + lastError: 5, +} 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":"…"} +// +// 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. + +export const NET_EVENT = { + done: "done", + error: "error", +} 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; + +export type NetMethod = (typeof NET_METHODS)[number]; + +// --------------------------------------------------------------------------- +// Bounded whole-response contract +// --------------------------------------------------------------------------- + +/** 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; + +/** Request bodies are copied out of the guest during start(). */ +export const NET_MAX_REQUEST_BYTES = 64 * 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_DEFAULT_TIMEOUT_MS = 30_000; +export const NET_MAX_TIMEOUT_MS = 120_000; +export const NET_MAX_REDIRECTS = 3; + +/** Portable errors. A transport maps platform/library failures into these + * codes before crossing the module boundary. */ +export const NET_ERROR = { + unavailable: "unavailable", + invalidRequest: "invalid_request", + busy: "busy", + dns: "dns", + connect: "connect", + tls: "tls", + timeout: "timeout", + redirect: "redirect", + responseTooLarge: "response_too_large", + protocol: "protocol", + cancelled: "cancelled", + other: "other", +} as const; + +export type NetErrorCode = (typeof NET_ERROR)[keyof typeof NET_ERROR]; diff --git a/contracts/spec/platforms.ts b/contracts/spec/platforms.ts index 88ece438..36ba2a88 100644 --- a/contracts/spec/platforms.ts +++ b/contracts/spec/platforms.ts @@ -144,6 +144,11 @@ 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", // Copy/cut/paste round-trips with the OS clipboard. "host.clipboard", // The logical viewport is runtime-mutable: the app is told about live diff --git a/docs/NET.md b/docs/NET.md new file mode 100644 index 00000000..3895062d --- /dev/null +++ b/docs/NET.md @@ -0,0 +1,134 @@ +# NET module + +The NET module gives a guest one bounded HTTP client API: + +```ts +import { fetch } from "@pocketjs/framework/net"; + +const response = await fetch("https://api.example.com/items", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "Pocket" }), + timeoutMs: 5_000, + maxBytes: 64 * 1024, +}); + +if (!response.ok) throw new Error(`HTTP ${response.status}`); +const value = await response.json(); +``` + +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. + +## Module ownership + +| 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); +} +``` + +`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. + +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 +``` + +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. diff --git a/docs/RUNTIMES.md b/docs/RUNTIMES.md index 725005e9..81211004 100644 --- a/docs/RUNTIMES.md +++ b/docs/RUNTIMES.md @@ -146,6 +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-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/Cargo.lock b/engine/Cargo.lock index 4138dfe9..759752d9 100644 --- a/engine/Cargo.lock +++ b/engine/Cargo.lock @@ -1597,6 +1597,18 @@ dependencies = [ "rquickjs", ] +[[package]] +name = "pocket-net" +version = "0.1.0" +dependencies = [ + "anyhow", + "pocket-mod", + "pocketjs-core", + "rquickjs", + "serde", + "serde_json", +] + [[package]] name = "pocket-stage" version = "0.1.0" diff --git a/engine/Cargo.toml b/engine/Cargo.toml index d4a627f8..71f759c3 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -12,6 +12,7 @@ resolver = "2" members = [ "crates/pocket-mod", + "crates/pocket-net", "crates/pocket-ui-surface", "crates/pocket-ui-wgpu", "crates/pocket-vrm", @@ -43,6 +44,7 @@ repository = "https://github.com/pocket-stack/pocketjs" pocket3d = { path = "pocket3d/crates/pocket3d" } pocket3d-bsp = { path = "pocket3d/crates/pocket3d-bsp" } pocket-mod = { path = "crates/pocket-mod" } +pocket-net = { path = "crates/pocket-net" } pocket-ui-surface = { path = "crates/pocket-ui-surface" } pocket-ui-wgpu = { path = "crates/pocket-ui-wgpu" } pocket-vrm = { path = "crates/pocket-vrm" } diff --git a/engine/core/src/spec.rs b/engine/core/src/spec.rs index f5d6ac2e..67d5a46a 100644 --- a/engine/core/src/spec.rs +++ b/engine/core/src/spec.rs @@ -498,3 +498,37 @@ pub mod audio { pub const EVENT_UNDERRUN: &str = "underrun"; pub const EVENT_ENDED: &str = "ended"; } + +/// NET module boundary (contracts/spec/net.ts — `globalThis.net`). +/// Bounded whole-response HTTP; completions batch to tick boundaries. +pub mod net { + 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 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 EVENT_ERROR: &str = "error"; + pub const ERROR_UNAVAILABLE: &str = "unavailable"; + pub const ERROR_INVALID_REQUEST: &str = "invalid_request"; + pub const ERROR_BUSY: &str = "busy"; + pub const ERROR_DNS: &str = "dns"; + pub const ERROR_CONNECT: &str = "connect"; + pub const ERROR_TLS: &str = "tls"; + pub const ERROR_TIMEOUT: &str = "timeout"; + 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_CANCELLED: &str = "cancelled"; + pub const ERROR_OTHER: &str = "other"; +} diff --git a/engine/crates/pocket-net/Cargo.toml b/engine/crates/pocket-net/Cargo.toml new file mode 100644 index 00000000..a32421b5 --- /dev/null +++ b/engine/crates/pocket-net/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "pocket-net" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Transport-neutral bounded HTTP core and PocketJS net module surface" + +[dependencies] +pocket-mod = { workspace = true } +pocketjs-core = { workspace = true } +rquickjs = { workspace = true } +anyhow = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/engine/crates/pocket-net/src/lib.rs b/engine/crates/pocket-net/src/lib.rs new file mode 100644 index 00000000..af3e658c --- /dev/null +++ b/engine/crates/pocket-net/src/lib.rs @@ -0,0 +1,660 @@ +//! `pocket-net` — the transport-neutral core and mounted surface for the +//! PocketJS NET module (`contracts/spec/net.ts`). +//! +//! 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 [`NetSurface::begin_tick`] is +//! the only point at which its completions enter the single-threaded core. + +use std::cell::RefCell; +use std::collections::{BTreeMap, HashMap}; +use std::rc::Rc; + +use anyhow::Result; +use pocket_mod::Guest; +use pocket_mod::qjs::{ArrayBuffer, Function}; +use pocketjs_core::spec::net as spec; +use serde::{Deserialize, Serialize}; + +/// 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. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HttpRequest { + pub handle: i32, + pub url: String, + pub method: String, + pub headers: BTreeMap, + pub body: Vec, + pub timeout_ms: u32, + pub max_bytes: usize, + pub max_redirects: usize, +} + +/// Normalized failure crossing from a host transport into the core. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NetFailure { + pub code: String, + pub message: String, +} + +impl NetFailure { + pub fn new(code: impl Into, message: impl Into) -> Self { + Self { + code: normalize_error_code(&code.into()).to_string(), + message: message.into(), + } + } +} + +/// 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. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TransportCompletion { + Done { + handle: i32, + status: u16, + url: String, + headers: BTreeMap, + body: Vec, + }, + Error { + handle: i32, + failure: NetFailure, + }, +} + +/// 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>; + fn cancel(&mut self, handle: i32); + fn drain(&mut self, completions: &mut Vec); +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RequestMeta { + url: String, + method: String, + headers: BTreeMap, + timeout_ms: u32, + max_bytes: usize, +} + +#[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, + }, +} + +struct Inflight { + max_bytes: usize, +} + +/// 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, + next_handle: i32, + last_error: String, +} + +impl NetCore { + pub fn new(transport: T) -> Self { + Self { + transport, + inflight: HashMap::new(), + bodies: HashMap::new(), + visible: Vec::new(), + next_handle: 1, + last_error: String::new(), + } + } + + /// 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 + } + + 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 + } + } + } + + 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), + )); + } + if body.len() > spec::MAX_REQUEST_BYTES { + return Err(invalid("request body exceeds 64 KiB")); + } + 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, + body: body.to_vec(), + timeout_ms: meta.timeout_ms, + max_bytes: meta.max_bytes, + max_redirects: spec::MAX_REDIRECTS, + }; + 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); + } + Ok(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) { + 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); + } + } + } + + fn push_error(&mut self, handle: i32, code: &str, message: impl Into) { + self.visible.push(GuestEvent::Error { + handle, + code: normalize_error_code(code).to_string(), + message: message.into(), + }); + } + + 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 + } + }); + } + + pub fn take(&mut self, handle: i32) -> Option> { + self.bodies.remove(&handle) + } + + 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; + } + 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. + 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")) + } + + pub fn last_error(&self) -> &str { + &self.last_error + } +} + +/// Clone-cheap mounted NET module. The host keeps a copy and calls +/// [`begin_tick`](Self::begin_tick); the namespace closures share the core. +pub struct NetSurface { + inner: Rc>>, +} + +impl Clone for NetSurface { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } +} + +impl NetSurface { + pub fn new(transport: T) -> Self { + Self { + inner: Rc::new(RefCell::new(NetCore::new(transport))), + } + } + + pub fn begin_tick(&self) { + self.inner.borrow_mut().begin_tick(); + } + + 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`. + 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); + return -1; + }; + 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) + })?, + )?; + + let core = self.inner.clone(); + ns.set( + "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( + "lastError", + Function::new(ctx.clone(), move || core.borrow().last_error().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, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::VecDeque; + + #[derive(Default)] + struct FixtureTransport { + started: Vec, + cancelled: Vec, + completions: VecDeque, + } + + impl HttpTransport for FixtureTransport { + fn start(&mut self, request: HttpRequest) -> std::result::Result<(), NetFailure> { + 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 meta(max_bytes: usize) -> String { + format!( + r#"{{"url":"https://example.test/a","method":"GET","headers":{{}},"timeoutMs":30000,"maxBytes":{max_bytes}}}"# + ) + } + + #[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 + ); + + 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()); + 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}]"# + ); + assert_eq!(core.take(handle).as_deref(), Some(&b"hello"[..])); + assert!(core.take(handle).is_none()); + } + + #[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) + ); + assert!(core.take(handle).is_none()); + } + + #[test] + fn rejects_invalid_and_excess_inflight_requests_synchronously() { + let mut core = NetCore::new(FixtureTransport::default()); + assert_eq!(core.start("{}", &[]), -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)); + } + + #[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"), + }); + core.begin_tick(); + assert!(core.poll().is_none()); + assert_eq!(core.transport_mut().cancelled, vec![handle]); + } + + #[test] + fn mounted_surface_copies_into_guest_owned_arraybuffer() { + 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], + }); + }); + 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);", + ) + .unwrap(); + let values: (i32, i32, i32) = guest.with(|ctx| { + let g = ctx.globals(); + ( + g.get("copied").unwrap(), + g.get("first").unwrap(), + g.get("again").unwrap(), + ) + }); + assert_eq!(values, (3, 7, -1)); + } +} diff --git a/framework/compiler/subpaths.ts b/framework/compiler/subpaths.ts index 451dad23..db52ed00 100644 --- a/framework/compiler/subpaths.ts +++ b/framework/compiler/subpaths.ts @@ -80,6 +80,7 @@ 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 }, 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/index-octane.ts b/framework/src/index-octane.ts index d534dd0f..73b3ad8a 100644 --- a/framework/src/index-octane.ts +++ b/framework/src/index-octane.ts @@ -33,6 +33,7 @@ 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 { 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"; @@ -206,6 +207,7 @@ export function render(code: OctaneRenderRoot, opts: RenderOptions = {}): () => __advanceClock(); __setAnalog(analog); __setTouches(touches); + runServicePumps(); __drainEffects(); // 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. diff --git a/framework/src/index-vue-vapor.ts b/framework/src/index-vue-vapor.ts index 81a5590f..7715591a 100644 --- a/framework/src/index-vue-vapor.ts +++ b/framework/src/index-vue-vapor.ts @@ -32,6 +32,7 @@ import { __setAnalog, resetFrameHooks, runFrameHooks } from "./frame-vue-vapor.t import { __resetTouches, __setTouches } from "./touch.ts"; import { __advanceClock, resetClock } from "./clock.ts"; import { __drainEffects, resetEffects } from "./effects.ts"; +import { runServicePumps } from "./services.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"; @@ -205,6 +206,7 @@ export function render(code: VaporRenderRoot, opts: RenderOptions = {}): () => v __advanceClock(); __setAnalog(analog); __setTouches(touches); + runServicePumps(); __drainEffects(); runFrameHooks(buttons); handleFrame(buttons); diff --git a/framework/src/index.ts b/framework/src/index.ts index 4972cd5b..0d79aead 100644 --- a/framework/src/index.ts +++ b/framework/src/index.ts @@ -47,6 +47,7 @@ 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 { 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"; @@ -269,6 +270,7 @@ export function render(code: () => unknown, opts: RenderOptions = {}): () => voi __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 __runGestures(); // contact lifecycles resolve before app hooks read them runFrameHooks(buttons); // app lifecycle callbacks: onFrame/onButtonPress/etc. diff --git a/framework/src/net-api.ts b/framework/src/net-api.ts new file mode 100644 index 00000000..b48ba86a --- /dev/null +++ b/framework/src/net-api.ts @@ -0,0 +1,405 @@ +// 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 { 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 { + return decodeUtf8(this.data); + } + + 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 += utf8Length(name) + utf8Length(value) + 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 encodeUtf8(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)); + } +} + +function utf8Length(s: string): number { + let n = 0; + for (let i = 0; i < s.length; i++) { + const code = s.codePointAt(i)!; + if (code > 0xffff) i++; + n += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4; + } + return n; +} + +function encodeUtf8(s: string): Uint8Array { + const out = new Uint8Array(utf8Length(s)); + let o = 0; + for (let i = 0; i < s.length; i++) { + let code = s.codePointAt(i)!; + if (code > 0xffff) i++; + else if (code >= 0xd800 && code <= 0xdfff) code = 0xfffd; + if (code < 0x80) out[o++] = code; + else if (code < 0x800) { + out[o++] = 0xc0 | (code >> 6); + out[o++] = 0x80 | (code & 0x3f); + } else if (code < 0x10000) { + out[o++] = 0xe0 | (code >> 12); + out[o++] = 0x80 | ((code >> 6) & 0x3f); + out[o++] = 0x80 | (code & 0x3f); + } else { + out[o++] = 0xf0 | (code >> 18); + out[o++] = 0x80 | ((code >> 12) & 0x3f); + out[o++] = 0x80 | ((code >> 6) & 0x3f); + out[o++] = 0x80 | (code & 0x3f); + } + } + return out; +} + +function decodeUtf8(bytes: Uint8Array): string { + let out = ""; + let i = 0; + while (i < bytes.length) { + const a = bytes[i++]; + if (a < 0x80) { + out += String.fromCharCode(a); + continue; + } + let code: number; + let extra: number; + if ((a & 0xe0) === 0xc0) { + code = a & 0x1f; + extra = 1; + } else if ((a & 0xf0) === 0xe0) { + code = a & 0x0f; + extra = 2; + } else if ((a & 0xf8) === 0xf0) { + code = a & 0x07; + extra = 3; + } else throw new Error("net: response is not valid UTF-8"); + if (i + extra > bytes.length) throw new Error("net: response is not valid UTF-8"); + for (let k = 0; k < extra; k++) { + const b = bytes[i++]; + if ((b & 0xc0) !== 0x80) throw new Error("net: response is not valid UTF-8"); + code = (code << 6) | (b & 0x3f); + } + if ( + code > 0x10ffff || + (code >= 0xd800 && code <= 0xdfff) || + (extra === 1 && code < 0x80) || + (extra === 2 && code < 0x800) || + (extra === 3 && code < 0x10000) + ) throw new Error("net: response is not valid UTF-8"); + if (code < 0x10000) out += String.fromCharCode(code); + else { + code -= 0x10000; + out += String.fromCharCode(0xd800 + (code >> 10), 0xdc00 + (code & 0x3ff)); + } + } + return out; +} diff --git a/framework/src/services.ts b/framework/src/services.ts new file mode 100644 index 00000000..e90f044c --- /dev/null +++ b/framework/src/services.ts @@ -0,0 +1,22 @@ +// Framework-neutral per-tick service pumps. UI lifecycle hooks are component +// scoped and differ between Solid, Vue Vapor and Octane; module Promise +// delivery is realm scoped and must not depend on any of them. +// +// The set is normally empty. A module registers only while it has pending +// work, so the idle frame cost is one empty-set iteration and no native op. + +type ServicePump = () => void; + +const pumps = new Set(); + +export function registerServicePump(pump: ServicePump): () => void { + pumps.add(pump); + return () => pumps.delete(pump); +} + +export function runServicePumps(): void { + if (pumps.size === 0) return; + // A pump may remove itself while running; Set iteration safely advances to + // the next entry without a snapshot allocation on this per-frame path. + for (const pump of pumps) pump(); +} diff --git a/hosts/sim/net.ts b/hosts/sim/net.ts new file mode 100644 index 00000000..0d57b8f9 --- /dev/null +++ b/hosts/sim/net.ts @@ -0,0 +1,176 @@ +// 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. + +import { + NET_ERROR, + NET_MAX_INFLIGHT, + NET_MAX_RESPONSE_BYTES, +} from "../../contracts/spec/net.ts"; +import type { NetOps } from "../../framework/src/net-api.ts"; + +export interface SimNetRequest { + readonly url: string; + readonly method: string; + readonly headers: Readonly>; + readonly body: Uint8Array; + readonly timeoutMs: number; + readonly maxBytes: number; +} + +export interface SimNetResponse { + readonly status?: number; + readonly url?: string; + readonly headers?: Readonly>; + readonly body?: string | Uint8Array; + /** Virtual ticks after start before the completion is visible. Default 1. */ + readonly delayTicks?: number; + readonly error?: { readonly code: string; readonly message: string }; +} + +export type SimNetRoute = SimNetResponse | ((request: SimNetRequest) => SimNetResponse); + +interface PendingRequest { + readonly handle: number; + readonly readyTick: number; + readonly request: SimNetRequest; + readonly response: SimNetResponse; +} + +export interface SimNetHost { + readonly ns: NetOps; + tick(): void; + readonly log: string[]; + readonly pollCalls: () => number; +} + +function bytes(value: string | Uint8Array | undefined): Uint8Array { + if (value instanceof Uint8Array) return value.slice(); + const s = value ?? ""; + const out: number[] = []; + for (let i = 0; i < s.length; i++) { + let code = s.codePointAt(i)!; + if (code > 0xffff) i++; + if (code < 0x80) out.push(code); + else if (code < 0x800) out.push(0xc0 | (code >> 6), 0x80 | (code & 63)); + else if (code < 0x10000) { + out.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 63), 0x80 | (code & 63)); + } else { + out.push( + 0xf0 | (code >> 18), + 0x80 | ((code >> 12) & 63), + 0x80 | ((code >> 6) & 63), + 0x80 | (code & 63), + ); + } + } + return Uint8Array.from(out); +} + +export function createSimNetHost(routes: Readonly>): SimNetHost { + const pending = new Map(); + const bodies = new Map(); + const visible: object[] = []; + const log: string[] = []; + let nextHandle = 1; + let now = 0; + let lastError = ""; + let polls = 0; + + const ns: NetOps = { + start(metaJson: string, bodyBuffer: ArrayBuffer): number { + let meta: Omit; + try { + meta = JSON.parse(metaJson) as typeof meta; + } catch { + lastError = `${NET_ERROR.invalidRequest}: malformed metadata`; + return -1; + } + if (pending.size >= NET_MAX_INFLIGHT) { + lastError = `${NET_ERROR.busy}: at most ${NET_MAX_INFLIGHT} requests may be in flight`; + return -1; + } + const route = routes[meta.url]; + if (!route) { + lastError = `${NET_ERROR.invalidRequest}: no deterministic route for ${meta.url}`; + return -1; + } + const request: SimNetRequest = { ...meta, body: new Uint8Array(bodyBuffer).slice() }; + 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}`); + 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); + } + log.push(`cancel ${handle}`); + }, + poll(): string | undefined { + polls++; + if (visible.length === 0) return undefined; + const batch = JSON.stringify(visible.splice(0)); + log.push(`poll ${batch}`); + return batch; + }, + lastError(): string { + return lastError; + }, + }; + + 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, + }); + 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`, + }); + 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, + }); + } + }, + log, + pollCalls: () => polls, + }; +} diff --git a/hosts/web/engine.js b/hosts/web/engine.js index b1090cb1..7956ecba 100644 --- a/hosts/web/engine.js +++ b/hosts/web/engine.js @@ -17,6 +17,7 @@ import { createWasmUi, FB_W as DEFAULT_FB_W, FB_H as DEFAULT_FB_H } from "./wasm-ops.js"; import { drawHud, wasmMemoryBytes } from "./hud.js"; import { createAudioHost } from "./audio.js"; +import { createNetHost } from "./net.js"; const query = new URLSearchParams(location.search); function positiveIntParam(name, fallback, max = 32000) { @@ -104,6 +105,7 @@ let acc = 0; let last = 0; let frameCb = null; let audioHost = null; // hosts/web/audio.js — created on first load() +let netHost = null; // hosts/web/net.js — browser fetch behind the NET contract // Virtual clock policy (docs/DETERMINISM.md): virtual frames per second. One // frame(buttons) transaction + 60/simHz core ticks per virtual frame, so // ms-based animations cover the same VIRTUAL time at every rate. ?hz=2 @@ -216,6 +218,7 @@ function safeFrame() { // Audio module: fold the audio clock's facts into this tick's event // batch BEFORE the guest's single turn (poll() drains them inside it). if (audioHost) audioHost.beginFrame(); + if (netHost) netHost.beginFrame(); // JS: one virtual-frame transaction (input, effects, sweep) frameCb(held, packedAnalog()); const ticks = 60 / simHz; @@ -357,6 +360,11 @@ export async function load(name, opts = {}) { if (!audioHost) audioHost = createAudioHost(); audioHost.reset(); globalThis.audio = audioHost.ns; + // NET module: browser fetch is transport-only; guest code sees the same + // bounded globalThis.net contract as native runtimes. + if (!netHost) netHost = createNetHost(); + netHost.reset(); + globalThis.net = netHost.ns; globalThis.__simHz = simHz; // clock policy — before eval, like __pak // DevTools: identity + transport BEFORE eval; render() picks them up. globalThis.__pocketApp = name; diff --git a/hosts/web/net.js b/hosts/web/net.js new file mode 100644 index 00000000..3eefe75f --- /dev/null +++ b/hosts/web/net.js @@ -0,0 +1,224 @@ +// 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. + +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 MAX_TIMEOUT_MS = 120_000; +const MAX_REDIRECTS = 3; +const METHODS = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]); + +function headerBytes(headers) { + let bytes = 0; + const encoder = new TextEncoder(); + for (const [name, value] of Object.entries(headers)) { + bytes += name.length + encoder.encode(value).byteLength + 4; + } + return bytes; +} + +function validHeaders(headers) { + const entries = Object.entries(headers); + return entries.length <= MAX_HEADERS && + headerBytes(headers) <= MAX_HEADER_BYTES && + entries.every(([name, value]) => + typeof value === "string" && + /^[!#$%&'*+.^_`|~0-9a-z-]+$/.test(name) && + !/[\r\n]/.test(value), + ); +} + +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; + } + } +} + +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 completed = []; // async transport facts, not guest-visible yet + const visible = []; // facts frozen at beginFrame() + + function refuse(code, message) { + lastError = `${code}: ${message}`; + return -1; + } + + const ns = { + start(metaJson, bodyBuffer) { + let meta; + try { + meta = JSON.parse(metaJson); + } catch { + return refuse("invalid_request", "malformed request metadata"); + } + if (!meta || typeof meta !== "object" || !(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"); + 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 ((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 (!meta.headers || typeof meta.headers !== "object" || !validHeaders(meta.headers)) { + return refuse("invalid_request", "invalid headers"); + } + + 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); + }); + 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); + }, + poll() { + return visible.length ? JSON.stringify(visible.splice(0)) : undefined; + }, + lastError() { + return lastError; + }, + }; + + return { + ns, + beginFrame() { + visible.push(...completed.splice(0)); + }, + reset() { + for (const handle of [...pending.keys()]) ns.cancel(handle); + bodies.clear(); + completed.length = 0; + visible.length = 0; + }, + }; +} diff --git a/package.json b/package.json index de52aa40..8b0439af 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "./kinetics": "./framework/src/kinetics.ts", "./launcher": "./framework/src/launcher.ts", "./manifest": "./framework/src/manifest/index.ts", + "./net": "./framework/src/net-api.ts", "./osk": "./framework/src/osk.tsx", "./package": "./contracts/spec/pocket-package.ts", "./platform": "./framework/src/platform.ts", @@ -117,6 +118,7 @@ "./vue-vapor/effects": "./framework/src/effects.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/renderer": "./framework/src/renderer-vue-vapor.ts", "./octane": "./framework/src/index-octane.ts", "./octane/animation": "./framework/src/animation.ts", @@ -126,6 +128,7 @@ "./octane/effects": "./framework/src/effects.ts", "./octane/lifecycle": "./framework/src/lifecycle-octane.ts", "./octane/input": "./framework/src/input-api.ts", + "./octane/net": "./framework/src/net-api.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.", diff --git a/site/content/docs/concepts.md b/site/content/docs/concepts.md index 4dff9d66..d88ff391 100644 --- a/site/content/docs/concepts.md +++ b/site/content/docs/concepts.md @@ -16,8 +16,8 @@ Runtime = Host + mounted Modules + Guest ┌────────────────────────── Runtime ──────────────────────────┐ │ Guest product code (QuickJS bundle / wasm host eval) │ │ ───────── one namespace per mounted module ───────────── │ -│ Modules ui audio strike (OpenStrike) │ -│ core+spec core+spec core+spec │ +│ Modules ui audio net strike │ +│ core+spec core+spec core+spec core+spec │ │ Substrate pocket3d · platform drivers (no guest API) │ │ Host PSP EBOOT · Vita · browser · headless sim │ └──────────────────────────────────────────────────────────────┘ @@ -45,15 +45,17 @@ Core native side: owns the domain's state and clock 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 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. +`ui`, `decodeWav` and a `WavPlayer` for `audio`, `fetch` and buffered +responses for `net`, 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. `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. +host implemented it. `net` applies the same shape to bounded HTTP while +leaving sockets, TLS, and the concrete client library in each host. ## Spec @@ -106,7 +108,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` | the same bundles, byte-for-byte | +| Headless CI | Bun sim | `ui` + virtual `audio` + fixture `net` | the same bundles, byte-for-byte | ## The three laws @@ -156,3 +158,6 @@ changing only host code and one line of its target profile. The spec, the 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/). diff --git a/site/content/docs/net.md b/site/content/docs/net.md new file mode 100644 index 00000000..17097780 --- /dev/null +++ b/site/content/docs/net.md @@ -0,0 +1,90 @@ +# Networking + +PocketJS provides a small, bounded HTTP client through the NET module. It is +fetch-shaped without importing the browser's complete networking stack. + +```ts +import { fetch } from "@pocketjs/framework/net"; + +const response = await fetch("https://api.example.com/items", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "Pocket" }), + timeoutMs: 5_000, + maxBytes: 64 * 1024, +}); + +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 + +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 | + +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. + +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. + +## 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. diff --git a/site/nav.ts b/site/nav.ts index 0a69eaf6..a0413713 100644 --- a/site/nav.ts +++ b/site/nav.ts @@ -31,6 +31,7 @@ export const DOC_NAV: DocSection[] = [ { slug: "input-focus", title: "Input & focus" }, { slug: "app-shell", title: "App shell & overlays" }, { slug: "devtools", title: "DevTools" }, + { slug: "net", title: "Networking" }, ], }, { @@ -176,4 +177,3 @@ export const BLOG_POSTS: BlogPost[] = [ author: { name: 'Yifeng "Evan" Wang', url: "https://github.com/doodlewind" }, }, ]; - diff --git a/tests/net-web.test.js b/tests/net-web.test.js new file mode 100644 index 00000000..d8c259b7 --- /dev/null +++ b/tests/net-web.test.js @@ -0,0 +1,57 @@ +import { expect, test } from "bun:test"; + +import { fetch as pocketFetch } from "../framework/src/net-api.ts"; +import { runServicePumps } from "../framework/src/services.ts"; +import { createNetHost } from "../hosts/web/net.js"; + +test("browser net adapter uses native fetch but delivers only at beginFrame", async () => { + const calls = []; + const host = createNetHost(async (url, options) => { + calls.push({ url, options }); + return new Response("web transport", { + status: 200, + headers: { "content-type": "text/plain" }, + }); + }); + globalThis.net = host.ns; + try { + 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); + runServicePumps(); + await Promise.resolve(); + expect(settled).toBe(false); + + host.beginFrame(); + runServicePumps(); + const response = await promise; + expect(await response.text()).toBe("web transport"); + expect(calls).toHaveLength(1); + expect(calls[0].options.credentials).toBe("omit"); + expect(calls[0].options.redirect).toBe("manual"); + } finally { + host.reset(); + delete globalThis.net; + } +}); + +test("browser net adapter enforces response maxBytes 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); + host.beginFrame(); + runServicePumps(); + await expect(promise).rejects.toMatchObject({ code: "response_too_large" }); + } finally { + host.reset(); + delete globalThis.net; + } +}); diff --git a/tests/net.test.ts b/tests/net.test.ts new file mode 100644 index 00000000..882ab2b6 --- /dev/null +++ b/tests/net.test.ts @@ -0,0 +1,150 @@ +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 { runServicePumps } from "../framework/src/services.ts"; +import { createSimNetHost } from "../hosts/sim/net.ts"; + +function mount(ns: NetOps): void { + (globalThis as { net?: NetOps }).net = ns; +} + +afterEach(() => { + delete (globalThis as { net?: NetOps }).net; +}); + +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": { + status: 200, + headers: { "content-type": "application/json" }, + body: '{"message":"你好"}', + }, + }); + mount(host.ns); + + runServicePumps(); + expect(host.pollCalls()).toBe(0); // no pending Promise: no native poll + + let settled = false; + const promise = pocketFetch("https://example.test/message").then((response) => { + settled = true; + return response; + }); + runServicePumps(); + await Promise.resolve(); + expect(settled).toBe(false); // transport has not crossed a tick boundary + expect(host.pollCalls()).toBe(1); + + host.tick(); + runServicePumps(); + 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: "你好" }); + + const pollsAfterSettle = host.pollCalls(); + runServicePumps(); + runServicePumps(); + expect(host.pollCalls()).toBe(pollsAfterSettle); // pump unregistered itself + }); + + test("one poll drains every completion visible in the tick", async () => { + const host = createSimNetHost({ + "https://example.test/a": { body: "a" }, + "https://example.test/b": { body: "b" }, + }); + mount(host.ns); + const a = pocketFetch("https://example.test/a"); + const b = pocketFetch("https://example.test/b"); + host.tick(); + runServicePumps(); + + 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("request metadata and body cross as owned bounded data", 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" }; + }, + }); + 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 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 + }); + + test("whole-response cap rejects before oversized data reaches guest", async () => { + const host = createSimNetHost({ + "https://example.test/large": { body: new Uint8Array(5) }, + }); + 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); + }); + + test("the third concurrent request is refused with busy", 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 }, + }); + 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(); + runServicePumps(); + await Promise.all([a, b]); + }); + + test("invalid portable requests fail without entering the host", async () => { + const host = createSimNetHost({ + "https://example.test/a": { body: "unused" }, + }); + 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([]); + }); + + 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, + }); + }); +}); diff --git a/tools/test.ts b/tools/test.ts index 82bd6dd2..7aa54781 100644 --- a/tools/test.ts +++ b/tools/test.ts @@ -59,6 +59,8 @@ const SUITE: readonly Stage[] = [ "tests/kinetics.test.ts", "tests/osk-controller.test.ts", "tests/audio.test.ts", + "tests/net.test.ts", + "tests/net-web.test.js", "tests/vita-package.test.ts", "tests/psp-toolchain.test.ts", "tests/symbian-data.test.ts",