From 9f050abe61cf01d98fd2d8348c7e5d9dbec733fc Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:13:18 +0800 Subject: [PATCH] refactor(net): fold the UTF-8 codecs into bytes.ts and make mount a droppable feature Two alignment moves from the #246 review, matching the conventions the data modules pinned: - framework/src/bytes.ts is the one home for module-SDK byte codecs: it gains stringToUtf8 (lone surrogates become U+FFFD), and net-api.ts plus hosts/sim/net.ts drop their hand-rolled UTF-8 encoder/decoder/length copies for the shared spellings. - pocket-net follows the pocket-fs feature split: `mount` (default) carries the pocket-mod adapter and NetSurface; `default-features = false` drops pocket-mod and compiles NetCore alone for firmware with its own QuickJS wiring. The unused direct rquickjs dependency is gone (the adapter uses pocket_mod::qjs). Verified: gate 11/11, tsc clean, cargo test -p pocket-net 5/5, clippy -D warnings clean with and without default features, cargo check --workspace clean. Co-Authored-By: Claude Fable 5 --- docs/NET.md | 5 ++ engine/Cargo.lock | 1 - engine/crates/pocket-net/Cargo.toml | 13 ++++- engine/crates/pocket-net/src/lib.rs | 34 +++++++++-- framework/src/bytes.ts | 35 +++++++++++- framework/src/net-api.ts | 89 +++-------------------------- hosts/sim/net.ts | 21 +------ 7 files changed, 87 insertions(+), 111 deletions(-) diff --git a/docs/NET.md b/docs/NET.md index 3895062d..dd16c767 100644 --- a/docs/NET.md +++ b/docs/NET.md @@ -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`, the host loop is: ```text diff --git a/engine/Cargo.lock b/engine/Cargo.lock index d33204ab..3ce5f614 100644 --- a/engine/Cargo.lock +++ b/engine/Cargo.lock @@ -1674,7 +1674,6 @@ dependencies = [ "anyhow", "pocket-mod", "pocketjs-core", - "rquickjs", "serde", "serde_json", ] diff --git a/engine/crates/pocket-net/Cargo.toml b/engine/crates/pocket-net/Cargo.toml index a32421b5..465ea6b9 100644 --- a/engine/crates/pocket-net/Cargo.toml +++ b/engine/crates/pocket-net/Cargo.toml @@ -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 } diff --git a/engine/crates/pocket-net/src/lib.rs b/engine/crates/pocket-net/src/lib.rs index af3e658c..21d41688 100644 --- a/engine/crates/pocket-net/src/lib.rs +++ b/engine/crates/pocket-net/src/lib.rs @@ -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}; @@ -316,12 +316,32 @@ impl NetCore { } } +// --------------------------------------------------------------------------- +// 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 { inner: Rc>>, } +#[cfg(feature = "mount")] impl Clone for NetSurface { fn clone(&self) -> Self { Self { @@ -330,6 +350,7 @@ impl Clone for NetSurface { } } +#[cfg(feature = "mount")] impl NetSurface { pub fn new(transport: T) -> Self { Self { @@ -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(); diff --git a/framework/src/bytes.ts b/framework/src/bytes.ts index 681d64d7..18d00a5c 100644 --- a/framework/src/bytes.ts +++ b/framework/src/bytes.ts @@ -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). @@ -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 { diff --git a/framework/src/net-api.ts b/framework/src/net-api.ts index b48ba86a..5d034327 100644 --- a/framework/src/net-api.ts +++ b/framework/src/net-api.ts @@ -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 { @@ -92,7 +93,11 @@ export class PocketResponse { } async text(): Promise { - return decodeUtf8(this.data); + try { + return utf8ToString(this.data); + } catch { + throw new Error("net: response is not valid UTF-8"); + } } async json(): Promise { @@ -245,7 +250,7 @@ function normalizeHeaders(input: Readonly> | 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"); } @@ -256,7 +261,7 @@ function normalizeHeaders(input: Readonly> | 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"); @@ -325,81 +330,3 @@ export function fetch(url: string, options: FetchOptions = {}): Promise 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/hosts/sim/net.ts b/hosts/sim/net.ts index 0d57b8f9..f2214b3b 100644 --- a/hosts/sim/net.ts +++ b/hosts/sim/net.ts @@ -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 { @@ -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>): SimNetHost {