Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion contracts/spec/gen-rust.ts
Original file line number Diff line number Diff line change
@@ -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
//
Expand All @@ -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,
Expand Down Expand Up @@ -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";
}
Expand Down
123 changes: 123 additions & 0 deletions contracts/spec/net.ts
Original file line number Diff line number Diff line change
@@ -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];
5 changes: 5 additions & 0 deletions contracts/spec/platforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
134 changes: 134 additions & 0 deletions docs/NET.md
Original file line number Diff line number Diff line change
@@ -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/<host>/` 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<TransportCompletion>);
}
```

`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<T>`, 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.
1 change: 1 addition & 0 deletions docs/RUNTIMES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down
12 changes: 12 additions & 0 deletions engine/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions engine/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
resolver = "2"
members = [
"crates/pocket-mod",
"crates/pocket-net",
"crates/pocket-ui-surface",
"crates/pocket-ui-wgpu",
"crates/pocket-vrm",
Expand Down Expand Up @@ -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" }
Expand Down
Loading
Loading