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
5 changes: 5 additions & 0 deletions docs/NET.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ tick boundary. Network threads never call QuickJS. The reference core turns
drained completions into one JSON event batch; the guest consumes that batch
during its next normal turn.

`NetSurface` — the one-line `globalThis.net` install on `pocket-mod` hosts —
is the crate's `mount` feature (default). A host with its own QuickJS wiring
depends with `default-features = false` and drives `NetCore` directly, so the
MCU build never compiles an engine it doesn't use (the `pocket-fs` pattern).

For a runtime using `NetSurface<T>`, the host loop is:

```text
Expand Down
1 change: 0 additions & 1 deletion engine/Cargo.lock

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

13 changes: 10 additions & 3 deletions engine/crates/pocket-net/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,17 @@ license.workspace = true
repository.workspace = true
description = "Transport-neutral bounded HTTP core and PocketJS net module surface"

[features]
# `mount` brings pocket-mod (and its QuickJS embedding) for the one-line
# globalThis.net install. A device host with its own QuickJS wiring turns
# it off (`default-features = false`) and drives NetCore directly — the
# MCU build then never compiles an engine it doesn't use.
default = ["mount"]
mount = ["dep:pocket-mod", "dep:anyhow"]

[dependencies]
pocket-mod = { workspace = true }
pocket-mod = { workspace = true, optional = true }
pocketjs-core = { workspace = true }
rquickjs = { workspace = true }
anyhow = { workspace = true }
anyhow = { workspace = true, optional = true }
serde = { workspace = true }
serde_json = { workspace = true }
34 changes: 28 additions & 6 deletions engine/crates/pocket-net/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@
//! 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 transport may work on other threads, but [`NetCore::begin_tick`] is
//! the only point at which its completions enter the single-threaded core.
//!
//! Feature `mount` (default) adds [`NetSurface`], the pocket-mod adapter that
//! installs the five ops as `globalThis.net`. A host with its own QuickJS
//! wiring turns it off (`default-features = false`) and drives [`NetCore`]
//! directly — the MCU build then never compiles an engine it doesn't use.

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};

Expand Down Expand Up @@ -316,12 +316,32 @@ impl<T: HttpTransport> NetCore<T> {
}
}

// ---------------------------------------------------------------------------
// Mount
// ---------------------------------------------------------------------------

#[cfg(feature = "mount")]
use std::cell::RefCell;
#[cfg(feature = "mount")]
use std::rc::Rc;

#[cfg(feature = "mount")]
use anyhow::Result;
#[cfg(feature = "mount")]
use pocket_mod::Guest;
#[cfg(feature = "mount")]
use pocket_mod::qjs::{ArrayBuffer, Function};

/// Clone-cheap mounted NET module. The host keeps a copy and calls
/// [`begin_tick`](Self::begin_tick); the namespace closures share the core.
/// Feature `mount` (default); a host with its own QuickJS wiring turns it
/// off and drives [`NetCore`] directly, spelling the five ops itself.
#[cfg(feature = "mount")]
pub struct NetSurface<T: HttpTransport> {
inner: Rc<RefCell<NetCore<T>>>,
}

#[cfg(feature = "mount")]
impl<T: HttpTransport> Clone for NetSurface<T> {
fn clone(&self) -> Self {
Self {
Expand All @@ -330,6 +350,7 @@ impl<T: HttpTransport> Clone for NetSurface<T> {
}
}

#[cfg(feature = "mount")]
impl<T: HttpTransport + 'static> NetSurface<T> {
pub fn new(transport: T) -> Self {
Self {
Expand Down Expand Up @@ -610,6 +631,7 @@ mod tests {
assert_eq!(core.transport_mut().cancelled, vec![handle]);
}

#[cfg(feature = "mount")]
#[test]
fn mounted_surface_copies_into_guest_owned_arraybuffer() {
let guest = Guest::new().unwrap();
Expand Down
35 changes: 34 additions & 1 deletion framework/src/bytes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Byte codecs shared by the data-module SDKs (db, fs). Internal — not a
// Byte codecs shared by the module SDKs (db, fs, net). Internal — not a
// framework subpath. QuickJS has no btoa/Buffer/TextEncoder/TextDecoder, so
// the codecs are spelled out; every caller is a cold path (payloads cross
// the boundary far less often than draw ops).
Expand Down Expand Up @@ -38,6 +38,39 @@ export function base64ToBytes(s: string): Uint8Array {
return out;
}

/** UTF-8 encode. Lone surrogates become U+FFFD, so the output is always
* well-formed UTF-8 (the byte shape every module boundary requires). */
export function stringToUtf8(s: string): Uint8Array {
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;
}
const out = new Uint8Array(n);
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;
}

/** UTF-8 decode, strict: malformed sequences throw (a file that fails
* .text() is a bytes file — read it with .bytes()). */
export function utf8ToString(bytes: Uint8Array): string {
Expand Down
89 changes: 8 additions & 81 deletions framework/src/net-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type NetErrorCode,
type NetMethod,
} from "../../contracts/spec/net.ts";
import { stringToUtf8, utf8ToString } from "./bytes.ts";
import { registerServicePump } from "./services.ts";

export {
Expand Down Expand Up @@ -92,7 +93,11 @@ export class PocketResponse {
}

async text(): Promise<string> {
return decodeUtf8(this.data);
try {
return utf8ToString(this.data);
} catch {
throw new Error("net: response is not valid UTF-8");
}
}

async json<T = unknown>(): Promise<T> {
Expand Down Expand Up @@ -245,7 +250,7 @@ function normalizeHeaders(input: Readonly<Record<string, string>> | undefined):
throw new NetError(NET_ERROR.invalidRequest, `net: invalid header ${rawName}`);
}
count++;
bytes += utf8Length(name) + utf8Length(value) + 4;
bytes += stringToUtf8(name).byteLength + stringToUtf8(value).byteLength + 4;
if (count > NET_MAX_HEADERS || bytes > NET_MAX_HEADER_BYTES) {
throw new NetError(NET_ERROR.invalidRequest, "net: request headers exceed limits");
}
Expand All @@ -256,7 +261,7 @@ function normalizeHeaders(input: Readonly<Record<string, string>> | undefined):

function requestBody(body: FetchOptions["body"]): Uint8Array {
if (body === undefined) return new Uint8Array(0);
if (typeof body === "string") return encodeUtf8(body);
if (typeof body === "string") return stringToUtf8(body);
if (body instanceof Uint8Array) return body.slice();
if (body instanceof ArrayBuffer) return new Uint8Array(body.slice(0));
throw new NetError(NET_ERROR.invalidRequest, "net: body must be string or bytes");
Expand Down Expand Up @@ -325,81 +330,3 @@ export function fetch(url: string, options: FetchOptions = {}): Promise<PocketRe
: 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;
}
21 changes: 2 additions & 19 deletions hosts/sim/net.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
NET_MAX_INFLIGHT,
NET_MAX_RESPONSE_BYTES,
} from "../../contracts/spec/net.ts";
import { stringToUtf8 } from "../../framework/src/bytes.ts";
import type { NetOps } from "../../framework/src/net-api.ts";

export interface SimNetRequest {
Expand Down Expand Up @@ -46,25 +47,7 @@ export interface SimNetHost {

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);
return stringToUtf8(value ?? "");
}

export function createSimNetHost(routes: Readonly<Record<string, SimNetRoute>>): SimNetHost {
Expand Down