From b503e61ec3b44fa5a614c9ba2482b462f3fa03a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 7 May 2026 19:28:22 +0200 Subject: [PATCH] =?UTF-8?q?feat(streams):=20closes=20#562=20=E2=80=94=20us?= =?UTF-8?q?er=20classes=20can=20extend=20WritableStream=20/=20ReadableStre?= =?UTF-8?q?am=20/=20TransformStream=20(v0.5.655)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canonical Web-Stream-subclassing pattern (s3-lite-client's ObjectUploader / TransformChunkSizes — pass `start`/`pull`/`cancel` / `write`/`close`/`abort` / `transform`/`flush` callbacks to `super({...})`, attach state on `this`, let inherited methods like `pipeTo`/`pipeThrough` work on the subclass instance) now compiles + runs end-to-end. Three coordinated layers: 1. HIR (`perry-hir`): the three Web Stream base classes get `native_extends` set in both `lower_class_decl` arms — alongside `EventEmitter` / `AsyncLocalStorage` / `WebSocketServer`. Both the parent-name (`extends_name`) and the (module, class) tuple (`native_extends`) are populated so the codegen SuperCall arm reaches the parent-name switch instead of falling out at the no- extends gate. New `class_native_extends` registry on `LoweringContext` so `let x = new SubclassOfStream(...)` in `destructuring.rs` can route the local through the parent stream module's dispatch table. New `current_class_super_ident` field for the `super({...})` pre-scan to register `start`/`pull`/`transform`/`flush` callback's controller param as a `readable_stream` native instance — same shape the `new TransformStream({...})` pre-scan in `expr_new.rs` does. 2. Codegen (`perry-codegen`): `Expr::SuperCall` arm dispatches through a new `lower_stream_super_init(ctx, kind, super_args)` helper when `parent_name` is one of the three stream classes (and not in `ctx.classes`). Helper walks `args[0]` via the existing `extract_options_fields` (handles both `Expr::Object` and `__AnonShape_*` forms), lowers each canonical callback (missing → TAG_UNDEFINED), reads `highWaterMark` from `args[1]` if present, then calls one of `js_X_stream_subclass_init(this, ...callbacks, hwm)`. Per-spec leaf-class field initializers run AFTER super() returns — the helper applies `FieldInitMode::SelfOnly` after the runtime call so subclass fields like `seenLengths = []` are initialized before any inherited callback fires. Stream-FFI receivers in `lower_call.rs` (`module == "readable_stream"` recv + `pipeTo` dest + `pipeThrough` transform; `module == "writable_stream"` recv; `module == "transform_stream"` recv) are wrapped through a new `js_stream_unwrap_handle` runtime call so subclass instances and bare numeric handles are interchangeable. 3. Runtime (`perry-stdlib`): three new `*_subclass_init` shims reuse the existing `alloc_readable` / `alloc_writable` / `js_transform_stream_new` paths so registry entries / GC roots / `TRANSFORM_PAIRS` row are identical to a plain `new ReadableStream`. The resulting registry-id is then stashed on the subclass instance via `js_object_set_field_by_name(this, "__perry_stream_handle__", handle)`. New `js_stream_unwrap_handle(value: f64) -> f64` checks for a NaN-boxed object pointer (top16=0x7FFD), reads `__perry_stream_handle__`, returns its f64 numeric value; falls through to the input unchanged for already-numeric handles. Plus two gates in `expr_member.rs` + `expr_call.rs` so subclass- declared user fields (`uploader.uploadedSize` / `uploader.getResult()`) fall through to regular object property / class-method dispatch instead of hitting the streams `NativeMethodCall` arm and returning the receiver-less zero-sentinel. Stream-API methods/properties themselves still route through the streams arms so inherited `pipeTo` / `getWriter` / `cancel` etc. work on subclass instances. End-to-end smoke (`test-files/test_issue_562_stream_subclass.ts`) exercises all three subclass shapes (Readable / Writable / Transform) — each drives chunks through user-provided callbacks correctly. A scaled-up s3-lite-client style test (TransformChunkSizes feeding ObjectUploader; 3 chunks totalling 224 bytes) reports the right `uploadedSize` and `completed` via the user's `getResult()` method. The chained `readable.pipeThrough(t).pipeTo(w)` form requires the inner `pipeTo` to dispatch through `module == "readable_stream"` based on `pipeThrough`'s return type — a separate (pre-existing) limitation tracked outside this issue. The split form (`const piped = readable.pipeThrough(t); await piped.pipeTo(w)`) where the intermediate variable picks up the existing `destructuring.rs:1709` ReadableStream-from-pipeThrough recognition works fully. Sanity tests `test_inheritance`, `test_gap_class_advanced`, `test_gap_closures`, `test_edge_promises`, `test_gap_async_advanced`, `test_gap_fetch_response` continue to match Node byte-for-byte; gap suite holds at 26/28 with the same 2 pre-existing fails (`test_gap_console_methods`, `test_gap_object_methods`). --- CLAUDE.md | 3 +- Cargo.lock | 132 ++++++------- Cargo.toml | 2 +- crates/perry-codegen/src/expr.rs | 165 ++++++++++++++++ crates/perry-codegen/src/lower_call.rs | 41 +++- crates/perry-codegen/src/runtime_decls.rs | 23 +++ crates/perry-hir/src/destructuring.rs | 29 ++- crates/perry-hir/src/lower.rs | 51 +++++ crates/perry-hir/src/lower/expr_call.rs | 192 ++++++++++++++++++- crates/perry-hir/src/lower/expr_member.rs | 85 +++++++- crates/perry-hir/src/lower_decl.rs | 74 ++++++- crates/perry-stdlib/src/streams.rs | 138 ++++++++++++- test-files/test_issue_562_stream_subclass.ts | 78 ++++++++ 13 files changed, 916 insertions(+), 97 deletions(-) create mode 100644 test-files/test_issue_562_stream_subclass.ts diff --git a/CLAUDE.md b/CLAUDE.md index 636a2e27d2..1d0353ef4f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.654 +**Current Version:** 0.5.655 ## TypeScript Parity Status @@ -153,6 +153,7 @@ First-resolved directory cached in `compile_package_dirs`; subsequent imports re One-liners only — full detail in CHANGELOG.md. +- **v0.5.655** — **Closes #562** (user classes can extend `WritableStream` / `ReadableStream` / `TransformStream`): canonical Web-Stream-subclassing pattern (`class ObjectUploader extends WritableStream { constructor(...) { super({ write, close, abort }); this.getResult = () => result; } }` from `@bradenmacdonald/s3-lite-client`) now compiles + runs end-to-end. Pre-fix, `super({...})` to a stream base class fell through to the SuperCall arm's "parent not in ctx.classes, not error_like" branch which lowered the args for side effects then returned undefined — the user's `write` / `close` / `abort` (or `start` / `pull` / `cancel` / `transform` / `flush`) callbacks were dropped, and a subclass instance passed to `pipeTo` / `pipeThrough` was a NaN-boxed object pointer that the existing stream FFIs (`js_writable_stream_*` / `js_readable_stream_*` / `js_transform_stream_*`) interpreted as a registry id, found nothing, and silently no-op'd. **Three coordinated fixes**: (1) `crates/perry-hir/src/lower_decl.rs` — add `ReadableStream` / `WritableStream` / `TransformStream` to both `native_parent` matches alongside the existing `EventEmitter` / `AsyncLocalStorage` / `WebSocketServer` arms, AND populate `extends_name` (parent name) alongside `native_extends` so the codegen SuperCall arm reaches the parent-name switch instead of the no-extends fallthrough. New `class_native_extends` registry on `LoweringContext` so `let x = new SubclassOfStream(...)` in `destructuring.rs` can route the local through the parent stream module's dispatch table. New `current_class_super_ident` field for the `super({...})` pre-scan in `expr_call.rs` to register `start`/`pull`/`transform`/`flush` callback's controller param as a `readable_stream` native instance — same shape the `new TransformStream({...})` pre-scan in `expr_new.rs` does. (2) `crates/perry-codegen/src/expr.rs::Expr::SuperCall` — when `parent_name` is one of the three stream classes (and not in `ctx.classes`), invoke a new `lower_stream_super_init(ctx, kind, super_args)` helper that walks `args[0]` via the existing `extract_options_fields` (handles both `Expr::Object` and `__AnonShape_*` forms), lowers each canonical callback (`start`/`pull`/`cancel` for readable, `write`/`close`/`abort` for writable, `transform`/`flush` for transform; missing → `TAG_UNDEFINED`), reads `highWaterMark` from `args[1]` if present, then dispatches to a runtime `js_X_stream_subclass_init(this, ...callbacks, hwm)` shim. Per JS spec, leaf-class field initializers run AFTER super() returns — the helper applies `FieldInitMode::SelfOnly` after the runtime call so subclass fields like `seenLengths = []` are initialized before any inherited callback fires. (3) `crates/perry-stdlib/src/streams.rs` — three new `*_subclass_init` shims reuse the existing `alloc_readable` / `alloc_writable` / `js_transform_stream_new` paths so the registry entries / GC roots / `TRANSFORM_PAIRS` row are identical to a plain `new ReadableStream(...)`; the resulting registry-id is then stashed on the subclass instance via `js_object_set_field_by_name(this, "__perry_stream_handle__", handle)`. Plus a fourth piece: `js_stream_unwrap_handle(value: f64) -> f64` — checks for a NaN-boxed object pointer (top16=0x7FFD), reads `__perry_stream_handle__`, returns its f64 numeric value; falls through to the input unchanged for already-numeric handles. Wrapped around every stream-FFI receiver and stream-handle argument in `crates/perry-codegen/src/lower_call.rs` (`module == "readable_stream"` recv + `pipeTo` dest + `pipeThrough` transform; `module == "writable_stream"` recv; `module == "transform_stream"` recv) so subclass instances and bare numeric handles are interchangeable at the FFI boundary. Plus two gates in `expr_member.rs` + `expr_call.rs` (matching helper `is_stream_api_member` / `is_stream_api_method` keyed on the same dispatch tables in `lower_call.rs`) so subclass-declared user fields (`uploader.uploadedSize`) and methods (`uploader.getResult()`) fall through to regular object property / class-method dispatch instead of hitting the streams `NativeMethodCall` arm and returning the receiver-less zero-sentinel. Stream-API methods/properties themselves still route through the streams arms so inherited `pipeTo` / `getWriter` / `cancel` etc. work on subclass instances. End-to-end smoke (`test-files/test_issue_562_stream_subclass.ts`) exercises all three subclass shapes (Readable / Writable / Transform); a scaled-up s3-lite-client style test (TransformChunkSizes feeding ObjectUploader, 3 chunks totaling 224 bytes) reports the right `uploadedSize` and `completed` via the user's `getResult()` method. The chained `readable.pipeThrough(t).pipeTo(w)` form requires the inner `pipeTo` to dispatch through `module == "readable_stream"` based on `pipeThrough`'s return type — a separate (pre-existing) limitation tracked outside this issue; the split form (`const piped = readable.pipeThrough(t); await piped.pipeTo(w)`) where the intermediate variable picks up the existing `destructuring.rs:1709` ReadableStream-from-pipeThrough recognition works fully. Sanity tests `test_inheritance`, `test_gap_class_advanced`, `test_gap_closures`, `test_edge_promises`, `test_gap_async_advanced`, `test_gap_fetch_response` continue to match Node byte-for-byte; gap suite holds at 26/28 with the same 2 pre-existing fails (`test_gap_console_methods`, `test_gap_object_methods`). - **v0.5.654** — **Closes #561** (Web Crypto: `crypto.subtle.{digest,importKey,sign,verify}` for AWS SigV4 / JWT / web-push signing chains): pre-fix, every pure-JS S3 / signed-URL / JWT library (s3-lite-client, aws4fetch, jose, oidc-client-ts, web-push) hit the issue-#463 strict-API gate at `crypto.subtle` and refused to compile — `compilePackages` couldn't pull any of them in. New `crates/perry-stdlib/src/webcrypto.rs` ships the symmetric WebCrypto subset: `digest("SHA-1"|"SHA-256"|"SHA-384"|"SHA-512", data)` → `Promise`, `importKey("raw", keyBytes, {name:"HMAC", hash:{name:"SHA-256"}}, extractable, usages)` → `Promise`, `sign("HMAC", key, data)` → `Promise`, `verify("HMAC", key, sig, data)` → `Promise`. `CryptoKey` is a Buffer marked Uint8Array with an entry in a process-global `CRYPTO_KEY_REGISTRY: Mutex>` recording the `(KeyAlgo::Hmac, HashAlgo)` pair so `sign` / `verify` route to the right primitive. The async wrapping is decorative — SHA / HMAC are CPU-bound and resolve synchronously inside the returned Promise (issue's implementation note explicitly says this). `verify` uses constant-time byte comparison so it doesn't leak the position of the first mismatching byte through timing. `bytes_from_jsvalue` accepts strings (StringHeader / SHORT_STRING_TAG inline SSO), Buffer / Uint8Array (BufferHeader registered in BUFFER_REGISTRY), TypedArrayHeader (Uint8Array via the typed-array path), AND TextEncoder-allocated ArrayHeaders — the last one is the subtle case: `enc.encode("abc")` returns an ArrayHeader registered in BUFFER_REGISTRY but with bytes stored as f64 elements at offset 8 (so `instanceof Uint8Array` works while the decoder can recover UTF-8); without the side-table check (`text::is_text_encoder_result`), reading `enc.encode("abc")` as packed u8 yielded the first 3 bytes of each f64's IEEE-754 LE representation (all-zero for small ints) instead of the source bytes — first iteration of the impl produced `709e80c8...` for SHA-256("abc") instead of the spec value `ba7816bf...`. Made `text::is_text_encoder_result` `pub` to expose the registry to webcrypto.rs. New HIR variants `WebCryptoDigest / WebCryptoImportKey / WebCryptoSign / WebCryptoVerify` in `crates/perry-hir/src/ir.rs`; HIR lowering arm in `crates/perry-hir/src/lower/expr_call.rs` recognizes `crypto.subtle.(args)` (3-deep Member chain `Member(Member(Ident("crypto"), "subtle"), "digest")`) BEFORE the generic `mod.X.Y()` arm, dispatches to the right HIR variant by method+arity, and emits a clear "out of scope" error for unsupported subtle methods (encrypt/decrypt/generateKey/wrapKey/unwrapKey/deriveKey — explicitly listed as out of scope in the issue). Codegen at `crates/perry-codegen/src/expr.rs` lowers each variant to the corresponding `js_webcrypto_*` extern that returns `*mut Promise` and NaN-boxes with POINTER_TAG. New runtime decls in `runtime_decls.rs`. New manifest entry `property("crypto", "subtle")` in `crates/perry-api-manifest/src/entries.rs`. Detection arm in `collect_modules.rs::compute_required_features` flips on the `crypto` Cargo feature so auto-optimize rebuilds perry-stdlib with the SHA / HMAC deps available — without it, link fails with `_js_webcrypto_digest` undefined. End-to-end smoke `test-files/test_issue_561_webcrypto.ts` exercises NIST SHA-256("abc") + AWS SigV4 first-step kDate + RFC 4231 HMAC-SHA-256 Test Case 2 + verify round-trip + tampered-sig rejection — all 6 lines match `node --experimental-strip-types` byte-for-byte. Acceptance smoke `test-files/test_issue_561_sigv4_chain.ts` runs the full `kSecret → kDate → kRegion → kService → kSigning` chain shape from `@bradenmacdonald/s3-lite-client::signing.ts:317-330` (the issue's named acceptance criterion); final hex matches Node byte-for-byte. Updated `crates/perry-hir/tests/unimplemented_api_check.rs` — replaced the pre-#561 "crypto.subtle is rejected" test with three new tests: `crypto_subtle_namespace_compiles`, `crypto_subtle_digest_compiles`, `crypto_subtle_encrypt_is_rejected` (covers the out-of-scope path). All 10 tests in the strict-API check suite pass; the 5 webcrypto unit tests (parse_hash_alg, AWS SigV4 vector, SHA-256 empty + abc vectors, constant_time_eq) pass. Out of scope per the issue: asymmetric (`RSA-PSS`, `ECDSA`, `RSA-OAEP`), `generateKey`, `wrapKey` / `unwrapKey`, `deriveKey`, `encrypt` / `decrypt`. - **v0.5.653** — **Refs #486** (try/catch with unused catch binding around recursive async): `async function dispatch(i) { ... try { await dispatch(1); } catch (e) {} ... }` returned `undefined` and never even entered state 0. The async-to-generator pre-pass correctly recorded the catch param `e` (id=K) in its `compute_max_local_id` walk — its `Stmt::Try` arm scans `c.param`. The downstream generator state-machine pass (`crates/perry-transform/src/generator.rs`) ALSO has its own `compute_max_local_id` (it re-walks because the async pre-pass may have allocated new ids), but its `scan_stmt_for_max_local`'s `Stmt::Try` arm only walked `body` / `catch.body` / `finally` — it never read `catch.param`. When the catch body was empty (`catch (e) {}`), no `LocalGet`/`LocalSet` carried the catch param's id, so the body-only scan missed it entirely; the generator transform then allocated `__gen_state` over that same id. Every `LocalSet(__gen_state, N)` clobbered `e`, every read of `e` returned the state counter, and the state machine never advanced — the function appeared to return `undefined` immediately because `__async_step(undefined, false)` saw `done=true` on the very first `next()` call (state was already past every arm). Fix: scan `c.param` in `scan_stmt_for_max_local`'s `Stmt::Try` arm, mirroring the equivalent scan in `crates/perry-transform/src/async_to_generator.rs::scan_stmt`. Repro `try { await dispatch(1); } catch (e) {}` now prints `[d] 0 / [d] 1 / result: 0` matching Node. Hono's `compose()` wraps every middleware in this exact shape (`try { res = await handler(c, () => dispatch(i+1)); } catch (err) { ... }` — the `err` binding is unused on the success path), so this fix unblocks a class of hono failures distinct from the v0.5.649 RegExp / v0.5.643 string+string / v0.5.641 alias work. - **v0.5.652** — **Refs #536** (`@perryts/mysql` connection-pool acquire returned the wrong branch on empty pool + event loop exited before `await sock.on('connect')` resolved when sockets were routed through perry-ext-net): three coordinated fixes. (1) `crates/perry-runtime/src/array.rs::js_array_pop_f64` and `js_array_shift_f64` returned `f64::NAN` (bare NaN bits) for empty arrays — but per ECMAScript §23.1.3.21 / §23.1.3.27, `Array.prototype.pop` / `shift` on an empty array return `undefined`, NOT NaN. Bare NaN compares `!== undefined` (different bit patterns), so connection-pool drivers like `@perryts/mysql` doing `const entry = this.idle.shift(); if (entry !== undefined) { ... } else { /* allocate new */ }` took the truthy branch on an empty pool, attempted to use a NaN-typed entry as a connection, and crashed. Fix returns `TAG_UNDEFINED_F64` (NaN-boxed undefined: `0x7FFC_0000_0000_0001`) which compares correctly against `undefined`. (2) `crates/perry-ext-net/src/lib.rs` — new `js_ext_net_has_active_handles()` exposing perry-ext-net's `pending_events` + `sockets` registries through the same FFI shape as `js_net_has_active_handles` from perry-stdlib's bundled-net. "Live" intentionally counts sockets still establishing TCP — counting only fully-open ones caused early exit before async `connect` ever completed (the open-flag flips inside the spawned task, AFTER `await TcpStream::connect`). (3) `crates/perry-stdlib/src/common/async_bridge.rs::js_stdlib_has_active_handles` — split the existing `feature = "net"` arm into two: `bundled-net` (perry-stdlib's own net impl is compiled in) keeps the direct `crate::net::js_net_has_active_handles()` call; new `external-net-pump` arm (the well-known flip routes `import 'net'` to perry-ext-net) declares `extern fn js_ext_net_has_active_handles()` and consults it. Pre-fix only the bundled gate fired, so programs using TS-source drivers like `@perryts/mysql` (which routes through perry-ext-net) saw `await new Promise(r => sock.on('connect', r))` exit early because perry-stdlib's empty `NET_SOCKETS` map reported no active handles. Plus a docs change: `docs/src/native-libraries/authoring-guide.md` swaps inline ts code blocks for `{{#include ../../examples/_fixtures/native-libraries/...}}` references so the snippets compile-test against real fixture files; matching `crates/perry-doc-tests/src/main.rs` skip-list update adds `_fixtures` (illustrative snippets that may legitimately import packages absent from this tree, drift-protected via PR diff rather than compile-testing). diff --git a/Cargo.lock b/Cargo.lock index b5c71cf01c..13130f0c0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4580,7 +4580,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.654" +version = "0.5.655" dependencies = [ "anyhow", "base64", @@ -4635,14 +4635,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.654" +version = "0.5.655" dependencies = [ "serde", ] [[package]] name = "perry-codegen" -version = "0.5.654" +version = "0.5.655" dependencies = [ "anyhow", "log", @@ -4655,7 +4655,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.654" +version = "0.5.655" dependencies = [ "anyhow", "perry-hir", @@ -4664,7 +4664,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.654" +version = "0.5.655" dependencies = [ "anyhow", "perry-hir", @@ -4672,7 +4672,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.654" +version = "0.5.655" dependencies = [ "anyhow", "perry-dispatch", @@ -4682,7 +4682,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.654" +version = "0.5.655" dependencies = [ "anyhow", "perry-hir", @@ -4691,7 +4691,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.654" +version = "0.5.655" dependencies = [ "anyhow", "base64", @@ -4704,7 +4704,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.654" +version = "0.5.655" dependencies = [ "anyhow", "perry-hir", @@ -4712,7 +4712,7 @@ dependencies = [ [[package]] name = "perry-diagnostics" -version = "0.5.654" +version = "0.5.655" dependencies = [ "serde", "serde_json", @@ -4720,11 +4720,11 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.654" +version = "0.5.655" [[package]] name = "perry-doc-tests" -version = "0.5.654" +version = "0.5.655" dependencies = [ "anyhow", "clap", @@ -4739,7 +4739,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.654" +version = "0.5.655" dependencies = [ "argon2", "perry-ffi", @@ -4747,7 +4747,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.654" +version = "0.5.655" dependencies = [ "perry-ffi", "reqwest", @@ -4756,7 +4756,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.654" +version = "0.5.655" dependencies = [ "bcrypt 0.17.1", "perry-ffi", @@ -4764,7 +4764,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.654" +version = "0.5.655" dependencies = [ "perry-ffi", "rusqlite", @@ -4772,7 +4772,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.654" +version = "0.5.655" dependencies = [ "perry-ffi", "scraper", @@ -4780,14 +4780,14 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.654" +version = "0.5.655" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-cron" -version = "0.5.654" +version = "0.5.655" dependencies = [ "chrono", "cron", @@ -4796,7 +4796,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.654" +version = "0.5.655" dependencies = [ "chrono", "perry-ffi", @@ -4804,7 +4804,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.654" +version = "0.5.655" dependencies = [ "perry-ffi", "rust_decimal", @@ -4812,7 +4812,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.654" +version = "0.5.655" dependencies = [ "perry-ffi", "serde_json", @@ -4820,7 +4820,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.654" +version = "0.5.655" dependencies = [ "perry-ffi", "rand 0.8.6", @@ -4828,21 +4828,21 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.654" +version = "0.5.655" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.654" +version = "0.5.655" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.654" +version = "0.5.655" dependencies = [ "bytes", "http-body-util", @@ -4856,7 +4856,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.654" +version = "0.5.655" dependencies = [ "lazy_static", "perry-ffi", @@ -4867,7 +4867,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.654" +version = "0.5.655" dependencies = [ "lazy_static", "perry-ffi", @@ -4878,7 +4878,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.654" +version = "0.5.655" dependencies = [ "lazy_static", "perry-ffi", @@ -4888,7 +4888,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.654" +version = "0.5.655" dependencies = [ "base64", "jsonwebtoken", @@ -4899,7 +4899,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.654" +version = "0.5.655" dependencies = [ "lru", "perry-ffi", @@ -4907,7 +4907,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.654" +version = "0.5.655" dependencies = [ "chrono", "perry-ffi", @@ -4915,7 +4915,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.654" +version = "0.5.655" dependencies = [ "bson", "futures-util", @@ -4927,7 +4927,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.654" +version = "0.5.655" dependencies = [ "chrono", "perry-ffi", @@ -4937,7 +4937,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.654" +version = "0.5.655" dependencies = [ "nanoid", "perry-ffi", @@ -4946,7 +4946,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.654" +version = "0.5.655" dependencies = [ "perry-ffi", "rustls", @@ -4957,7 +4957,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.654" +version = "0.5.655" dependencies = [ "lettre", "perry-ffi", @@ -4967,7 +4967,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.654" +version = "0.5.655" dependencies = [ "perry-ffi", "sqlx", @@ -4976,7 +4976,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.654" +version = "0.5.655" dependencies = [ "governor", "perry-ffi", @@ -4984,7 +4984,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.654" +version = "0.5.655" dependencies = [ "base64", "image", @@ -4993,14 +4993,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.654" +version = "0.5.655" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.654" +version = "0.5.655" dependencies = [ "lazy_static", "perry-ffi", @@ -5008,7 +5008,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.654" +version = "0.5.655" dependencies = [ "perry-ffi", "uuid", @@ -5016,7 +5016,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.654" +version = "0.5.655" dependencies = [ "perry-ffi", "regex", @@ -5026,7 +5026,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.654" +version = "0.5.655" dependencies = [ "futures-util", "lazy_static", @@ -5037,7 +5037,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.654" +version = "0.5.655" dependencies = [ "flate2", "perry-ffi", @@ -5045,7 +5045,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.654" +version = "0.5.655" dependencies = [ "dashmap 6.1.0", "once_cell", @@ -5054,7 +5054,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.654" +version = "0.5.655" dependencies = [ "anyhow", "perry-api-manifest", @@ -5068,7 +5068,7 @@ dependencies = [ [[package]] name = "perry-jsruntime" -version = "0.5.654" +version = "0.5.655" dependencies = [ "anyhow", "deno_core", @@ -5087,7 +5087,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.654" +version = "0.5.655" dependencies = [ "anyhow", "perry-diagnostics", @@ -5099,7 +5099,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.654" +version = "0.5.655" dependencies = [ "anyhow", "base64", @@ -5122,7 +5122,7 @@ dependencies = [ [[package]] name = "perry-stdlib" -version = "0.5.654" +version = "0.5.655" dependencies = [ "aes", "aes-gcm", @@ -5189,7 +5189,7 @@ dependencies = [ [[package]] name = "perry-transform" -version = "0.5.654" +version = "0.5.655" dependencies = [ "anyhow", "perry-hir", @@ -5199,7 +5199,7 @@ dependencies = [ [[package]] name = "perry-types" -version = "0.5.654" +version = "0.5.655" dependencies = [ "anyhow", "thiserror 1.0.69", @@ -5207,11 +5207,11 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.654" +version = "0.5.655" [[package]] name = "perry-ui-android" -version = "0.5.654" +version = "0.5.655" dependencies = [ "itoa", "jni", @@ -5225,7 +5225,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.654" +version = "0.5.655" dependencies = [ "rand 0.8.6", "serde", @@ -5235,7 +5235,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.654" +version = "0.5.655" dependencies = [ "cairo-rs", "gstreamer", @@ -5252,7 +5252,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.654" +version = "0.5.655" dependencies = [ "block2", "libc", @@ -5267,7 +5267,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.654" +version = "0.5.655" dependencies = [ "block2", "libc", @@ -5285,11 +5285,11 @@ version = "0.1.0" [[package]] name = "perry-ui-testkit" -version = "0.5.654" +version = "0.5.655" [[package]] name = "perry-ui-tvos" -version = "0.5.654" +version = "0.5.655" dependencies = [ "block2", "libc", @@ -5304,7 +5304,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.654" +version = "0.5.655" dependencies = [ "block2", "libc", @@ -5319,7 +5319,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.654" +version = "0.5.655" dependencies = [ "block2", "libc", @@ -5332,7 +5332,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.654" +version = "0.5.655" dependencies = [ "libc", "perry-runtime", @@ -5344,7 +5344,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.654" +version = "0.5.655" dependencies = [ "base64", "ed25519-dalek", diff --git a/Cargo.toml b/Cargo.toml index 55f27b23f3..5a0fc9b596 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -187,7 +187,7 @@ opt-level = "s" # Optimize for size in stdlib opt-level = 3 [workspace.package] -version = "0.5.654" +version = "0.5.655" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/crates/perry-codegen/src/expr.rs b/crates/perry-codegen/src/expr.rs index aef7334d79..bbb98b09c9 100644 --- a/crates/perry-codegen/src/expr.rs +++ b/crates/perry-codegen/src/expr.rs @@ -707,6 +707,142 @@ fn emit_write_barrier(ctx: &mut FnCtx<'_>, parent_bits: &str, child_bits: &str) .call_void("js_write_barrier", &[(I64, parent_bits), (I64, child_bits)]); } +/// Issue #562 — `super({ ... })` for `class X extends ReadableStream`, +/// `WritableStream`, or `TransformStream`. Extracts the underlying +/// source/sink/transformer callbacks from the inline object literal, +/// lowers each one (TAG_UNDEFINED for missing fields), and calls the +/// runtime `*_subclass_init` shim — which allocates the stream registry +/// handle and stashes it on `this` under `__perry_stream_handle__`. +/// +/// `kind` is one of `"readable"` / `"writable"` / `"transform"` — +/// matches the SuperCall arm's `parent_name` switch in expr.rs. +fn lower_stream_super_init( + ctx: &mut FnCtx<'_>, + kind: &str, + super_args: &[Expr], +) -> Result { + let undef_lit = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + + // Pre-extract field exprs so we don't hold a borrow across `lower_expr`. + let opts_props: Option> = super_args + .first() + .and_then(|first| crate::lower_call::extract_options_fields(ctx, first)); + let qstrat_props: Option> = super_args + .get(1) + .and_then(|second| crate::lower_call::extract_options_fields(ctx, second)); + + // Lower the canonical callback set per stream kind. Fields not + // present (or callable arg shape that isn't an inline literal) fall + // back to TAG_UNDEFINED — matches the existing `new ReadableStream + // / WritableStream / TransformStream` lowerings in + // `lower_call/builtin.rs`. + let mut start = undef_lit.clone(); + let mut pull = undef_lit.clone(); + let mut cancel = undef_lit.clone(); + let mut write = undef_lit.clone(); + let mut close = undef_lit.clone(); + let mut abort = undef_lit.clone(); + let mut transform = undef_lit.clone(); + let mut flush = undef_lit.clone(); + + if let Some(props) = opts_props { + for (k, vexpr) in &props { + match (kind, k.as_str()) { + ("readable", "start") => start = lower_expr(ctx, vexpr)?, + ("readable", "pull") => pull = lower_expr(ctx, vexpr)?, + ("readable", "cancel") => cancel = lower_expr(ctx, vexpr)?, + ("writable", "write") => write = lower_expr(ctx, vexpr)?, + ("writable", "close") => close = lower_expr(ctx, vexpr)?, + ("writable", "abort") => abort = lower_expr(ctx, vexpr)?, + ("transform", "transform") => transform = lower_expr(ctx, vexpr)?, + ("transform", "flush") => flush = lower_expr(ctx, vexpr)?, + _ => { + // Lower for side effects (closure-capture collection, + // string-pool registration, etc.) but discard the value. + let _ = lower_expr(ctx, vexpr)?; + } + } + } + } else if let Some(first) = super_args.first() { + // Caller passed something that isn't a recognized shape — lower + // for side effects so closure analysis stays consistent. + let _ = lower_expr(ctx, first)?; + } + + let mut hwm = double_literal(1.0); + if let Some(qprops) = qstrat_props { + for (k, vexpr) in &qprops { + if k == "highWaterMark" { + hwm = lower_expr(ctx, vexpr)?; + } else { + let _ = lower_expr(ctx, vexpr)?; + } + } + } else if let Some(second) = super_args.get(1) { + let _ = lower_expr(ctx, second)?; + } + + // `this` (NaN-boxed pointer) — the runtime shim stashes the handle + // on it via `js_object_set_field_by_name`. + let this_slot = ctx.this_stack.last().cloned(); + let this_box = match this_slot { + Some(slot) => ctx.block().load(DOUBLE, &slot), + None => undef_lit.clone(), + }; + + let runtime_fn = match kind { + "readable" => "js_readable_stream_subclass_init", + "writable" => "js_writable_stream_subclass_init", + "transform" => "js_transform_stream_subclass_init", + _ => unreachable!("lower_stream_super_init: unexpected kind {}", kind), + }; + + let blk = ctx.block(); + match kind { + "readable" => { + blk.call( + DOUBLE, + runtime_fn, + &[ + (DOUBLE, &this_box), + (DOUBLE, &start), + (DOUBLE, &pull), + (DOUBLE, &cancel), + (DOUBLE, &hwm), + ], + ); + } + "writable" => { + blk.call( + DOUBLE, + runtime_fn, + &[ + (DOUBLE, &this_box), + (DOUBLE, &write), + (DOUBLE, &close), + (DOUBLE, &abort), + (DOUBLE, &hwm), + ], + ); + } + "transform" => { + blk.call( + DOUBLE, + runtime_fn, + &[ + (DOUBLE, &this_box), + (DOUBLE, &transform), + (DOUBLE, &flush), + (DOUBLE, &hwm), + ], + ); + } + _ => unreachable!(), + } + + Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))) +} + pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { match expr { // -------- Literals -------- @@ -4179,6 +4315,35 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let parent_class = match ctx.classes.get(&parent_name).copied() { Some(c) => c, None => { + // Issue #562: `class X extends WritableStream/ReadableStream/TransformStream` + // — `super({ ... })` allocates an underlying stream registry handle and + // stashes it on `this` under `__perry_stream_handle__`. Inherited methods + // (`pipeTo`, `getWriter`, etc.) and arguments to `pipeTo`/`pipeThrough` + // route the receiver through `js_stream_unwrap_handle` at the FFI site + // so a subclass instance dispatches to the same FFIs a bare handle does. + let stream_kind = match parent_name.as_str() { + "ReadableStream" => Some("readable"), + "WritableStream" => Some("writable"), + "TransformStream" => Some("transform"), + _ => None, + }; + if let Some(kind) = stream_kind { + let result = lower_stream_super_init(ctx, kind, super_args)?; + // Per JS spec field initializers run AFTER super() + // returns. Without this, `this.foo = []` declared + // on the subclass never executes — instance reads + // see uninitialized slots. Mirrors the equivalent + // call in the user-class super branch below + // (line ~4521). Refs #562. + let current_class_name = + ctx.class_stack.last().cloned().unwrap_or_default(); + crate::lower_call::apply_field_initializers_recursive( + ctx, + ¤t_class_name, + crate::lower_call::FieldInitMode::SelfOnly, + )?; + return Ok(result); + } // Built-in parent (Error, TypeError, RangeError, etc.) // — user classes extending them need `super(message)` to // assign `this.message = args[0]` and `this.name = parent_name` diff --git a/crates/perry-codegen/src/lower_call.rs b/crates/perry-codegen/src/lower_call.rs index 3a86e7ef43..96e04880f3 100644 --- a/crates/perry-codegen/src/lower_call.rs +++ b/crates/perry-codegen/src/lower_call.rs @@ -3917,7 +3917,16 @@ pub(super) fn lower_fetch_native_method( // ───────────────────────────────────────────────────────────────── if module == "readable_stream" { - let recv_handle = lower_expr(ctx, recv)?; + let recv_handle_raw = lower_expr(ctx, recv)?; + // Issue #562: subclass instances stash the handle id under + // `__perry_stream_handle__`; bare numeric handles pass through + // unchanged. Cheap (one runtime call) and applied uniformly so + // the FFIs below see a clean registry id either way. + let recv_handle = ctx.block().call( + DOUBLE, + "js_stream_unwrap_handle", + &[(DOUBLE, &recv_handle_raw)], + ); match method { "getReader" => { let h = ctx.block().call( @@ -3948,11 +3957,15 @@ pub(super) fn lower_fetch_native_method( return Ok(Some(h)); } "pipeTo" => { - let dest = if !args.is_empty() { + let dest_raw = if !args.is_empty() { lower_expr(ctx, &args[0])? } else { double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) }; + // Issue #562: `dest` may be a subclass instance — unwrap. + let dest = ctx + .block() + .call(DOUBLE, "js_stream_unwrap_handle", &[(DOUBLE, &dest_raw)]); let blk = ctx.block(); let promise = blk.call( I64, @@ -3965,11 +3978,17 @@ pub(super) fn lower_fetch_native_method( // pipeThrough(transform) — transform has .readable / .writable. // We need both sub-handles. Lower the transform once, then // call js_transform_stream_writable / _readable to extract. - let transform = if !args.is_empty() { + let transform_raw = if !args.is_empty() { lower_expr(ctx, &args[0])? } else { double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) }; + // Issue #562: `transform` may be a subclass instance — unwrap. + let transform = ctx.block().call( + DOUBLE, + "js_stream_unwrap_handle", + &[(DOUBLE, &transform_raw)], + ); let writable = ctx.block().call( DOUBLE, "js_transform_stream_writable", @@ -4084,7 +4103,13 @@ pub(super) fn lower_fetch_native_method( } if module == "writable_stream" { - let recv_handle = lower_expr(ctx, recv)?; + let recv_handle_raw = lower_expr(ctx, recv)?; + // Issue #562: subclass instances unwrap to a numeric handle. + let recv_handle = ctx.block().call( + DOUBLE, + "js_stream_unwrap_handle", + &[(DOUBLE, &recv_handle_raw)], + ); match method { "getWriter" => { let h = ctx.block().call( @@ -4188,7 +4213,13 @@ pub(super) fn lower_fetch_native_method( } if module == "transform_stream" { - let recv_handle = lower_expr(ctx, recv)?; + let recv_handle_raw = lower_expr(ctx, recv)?; + // Issue #562: subclass instances unwrap to a numeric handle. + let recv_handle = ctx.block().call( + DOUBLE, + "js_stream_unwrap_handle", + &[(DOUBLE, &recv_handle_raw)], + ); match method { "readable" => { let v = ctx.block().call( diff --git a/crates/perry-codegen/src/runtime_decls.rs b/crates/perry-codegen/src/runtime_decls.rs index 3be31c0741..4584a5dbdf 100644 --- a/crates/perry-codegen/src/runtime_decls.rs +++ b/crates/perry-codegen/src/runtime_decls.rs @@ -1135,6 +1135,29 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { module.declare_function("js_transform_stream_new", DOUBLE, &[DOUBLE, DOUBLE, DOUBLE]); module.declare_function("js_transform_stream_readable", DOUBLE, &[DOUBLE]); module.declare_function("js_transform_stream_writable", DOUBLE, &[DOUBLE]); + // Issue #562: stream subclassing (`class X extends WritableStream` etc.). + // The unwrap helper is wrapped around every stream-FFI receiver so a + // subclass instance (NaN-boxed object pointer with the registry id + // stashed under `__perry_stream_handle__`) and a bare numeric + // handle are interchangeable. The `*_subclass_init` shims are + // invoked from `Expr::SuperCall` codegen for the three Web Stream + // base classes. + module.declare_function("js_stream_unwrap_handle", DOUBLE, &[DOUBLE]); + module.declare_function( + "js_readable_stream_subclass_init", + DOUBLE, + &[DOUBLE, DOUBLE, DOUBLE, DOUBLE, DOUBLE], + ); + module.declare_function( + "js_writable_stream_subclass_init", + DOUBLE, + &[DOUBLE, DOUBLE, DOUBLE, DOUBLE, DOUBLE], + ); + module.declare_function( + "js_transform_stream_subclass_init", + DOUBLE, + &[DOUBLE, DOUBLE, DOUBLE, DOUBLE], + ); // ────────────────────────────────────────────────────────────────── // AbortController / AbortSignal — perry-runtime/src/url.rs. diff --git a/crates/perry-hir/src/destructuring.rs b/crates/perry-hir/src/destructuring.rs index 0ac29e5ea2..ac5adbe03d 100644 --- a/crates/perry-hir/src/destructuring.rs +++ b/crates/perry-hir/src/destructuring.rs @@ -1515,7 +1515,34 @@ pub(crate) fn lower_var_decl_with_destructuring( ); ctx.uses_fetch = true; } - _ => {} + other => { + // Issue #562: `let x = new SubclassOfStream()` + // — walk the user class's `native_extends` to + // see if it points at a stream module. If so, + // register `x` under the same module/class + // tag the bare-stream constructor would. The + // codegen FFI sites unwrap the + // `__perry_stream_handle__` field at dispatch + // time, so a subclass instance and a bare + // numeric handle are interchangeable. + if let Some((module, class)) = + ctx.lookup_class_native_extends(other) + { + if matches!( + module, + "readable_stream" + | "writable_stream" + | "transform_stream" + ) { + ctx.register_native_instance( + name.clone(), + module.to_string(), + class.to_string(), + ); + ctx.uses_fetch = true; + } + } + } } } } diff --git a/crates/perry-hir/src/lower.rs b/crates/perry-hir/src/lower.rs index 2906280fa2..be19f2a5ca 100644 --- a/crates/perry-hir/src/lower.rs +++ b/crates/perry-hir/src/lower.rs @@ -63,6 +63,13 @@ pub struct LoweringContext { /// avoiding the creation of shadow fields that cause later index shift bugs after /// inheritance resolution in codegen. pub(crate) class_field_names: Vec<(String, Vec)>, + /// Issue #562: class name → `(module, class)` tuple from + /// `native_extends`. Populated when lowering each class, consumed by + /// `destructuring.rs` to register `let x = new SubclassOfStream()` + /// locals under the parent stream module so subsequent + /// `x.pipeTo(...)` / `x.getWriter()` etc. dispatch through the + /// streams arms in `lower_call.rs`. + pub(crate) class_native_extends: Vec<(String, String, String)>, /// Issue #302 (v0.5.388): instance field types per class so the /// for-of arm can detect `for (const [k, v] of this.someMap)` — /// the iterable is an `ast::Expr::Member { obj: This, prop: "someMap" }`, @@ -219,6 +226,15 @@ pub struct LoweringContext { /// Used to resolve `new.target` to a placeholder object whose `.name` /// returns the class name. None outside any constructor. pub(crate) in_constructor_class: Option, + /// Issue #562 — set to the parent class identifier (e.g. `"WritableStream"`, + /// `"ReadableStream"`, `"TransformStream"`, or any ident from `class X + /// extends Y`) when lowering inside a class declaration. Used by the + /// `super({...})` pre-scan in `expr_call.rs` to register the + /// `start`/`pull`/`transform`/`flush` callback's controller param as + /// a `readable_stream` native instance — same shape the + /// `new TransformStream({...})` pre-scan in `expr_new.rs` does. + /// Saved/restored across nested class declarations. + pub(crate) current_class_super_ident: Option, /// Phase 3 anon-class registry for closed-shape object literals: shape key /// (canonical field-name + type-tag joined) -> synthetic class name. Lets /// identical-shape literals within the same module share one synthesized @@ -274,6 +290,7 @@ impl LoweringContext { classes: Vec::new(), class_statics: Vec::new(), class_field_names: Vec::new(), + class_native_extends: Vec::new(), class_field_types: Vec::new(), enums: Vec::new(), interfaces: Vec::new(), @@ -320,6 +337,7 @@ impl LoweringContext { proxy_target_classes: HashMap::new(), class_expr_aliases: HashMap::new(), in_constructor_class: None, + current_class_super_ident: None, mixin_funcs: HashMap::new(), anon_shape_classes: HashMap::new(), next_anon_shape_id: 0, @@ -537,6 +555,39 @@ impl LoweringContext { self.classes_index.get(name).map(|&idx| self.classes[idx].1) } + /// Issue #562: look up the `(module, class)` tuple from a class's + /// `native_extends` clause (e.g. `class X extends WritableStream` → + /// `Some(("writable_stream", "WritableStream"))`). Used by + /// `destructuring.rs`'s `let x = new SubclassOfStream()` arm to + /// route the local through the parent stream module's dispatch + /// table. + pub(crate) fn lookup_class_native_extends(&self, name: &str) -> Option<(&str, &str)> { + self.class_native_extends + .iter() + .find(|(n, _, _)| n == name) + .map(|(_, m, c)| (m.as_str(), c.as_str())) + } + + /// Companion setter — populated when `lower_class_decl` / + /// `lower_class_from_ast` sees a class with `native_extends` set. + pub(crate) fn register_class_native_extends( + &mut self, + class_name: String, + module: String, + class: String, + ) { + if let Some(entry) = self + .class_native_extends + .iter_mut() + .find(|(n, _, _)| *n == class_name) + { + entry.1 = module; + entry.2 = class; + } else { + self.class_native_extends.push((class_name, module, class)); + } + } + /// Register declared instance field names for a class. Used by subclasses to skip /// re-declaring inherited fields when inferring from ctor body `this.x = ...` assignments. pub(crate) fn register_class_field_names( diff --git a/crates/perry-hir/src/lower/expr_call.rs b/crates/perry-hir/src/lower/expr_call.rs index 02b4e76068..a1ffe42c5b 100644 --- a/crates/perry-hir/src/lower/expr_call.rs +++ b/crates/perry-hir/src/lower/expr_call.rs @@ -77,6 +77,20 @@ pub(super) fn lower_call(ctx: &mut LoweringContext, call: &ast::CallExpr) -> Res return Ok(desugared); } + // Issue #562: `super({ start, pull, transform, flush, ... })` inside + // a class extending `ReadableStream` / `TransformStream` — + // pre-register the controller param of each callback as a + // `readable_stream` native instance BEFORE the args are lowered, so + // `controller.enqueue(...)` inside those callback bodies dispatches + // through the streams arms in `lower_call.rs`. Mirrors the existing + // pre-scan in `expr_new.rs::lower_new` for `new ReadableStream({...})` + // / `new TransformStream({...})`. Idempotent for non-matching shapes. + if matches!(&call.callee, ast::Callee::Super(_)) { + if let Some(parent_ident) = ctx.current_class_super_ident.clone() { + register_super_stream_controller_params(ctx, &parent_ident, call); + } + } + let mut args = call .args .iter() @@ -1116,15 +1130,42 @@ pub(super) fn lower_call(ctx: &mut LoweringContext, call: &ast::CallExpr) -> Res if let Some((module_name, class_name)) = native_instance { if let ast::MemberProp::Ident(method_ident) = &member.prop { let method_name = method_ident.sym.to_string(); - // Get the object expression (the instance variable) - let object_expr = lower_expr(ctx, &member.obj)?; - return Ok(Expr::NativeMethodCall { - module: module_name, - class_name: Some(class_name), // Use the registered class name - object: Some(Box::new(object_expr)), - method: method_name, - args, - }); + // Issue #562: stream subclass instances carry the + // bare-stream module/class tag for inherited-method + // dispatch (`w.pipeTo(...)`, `w.getWriter()`). + // Routing user-declared methods through the + // NativeMethodCall arm misses every dispatcher and + // falls through to the receiver-less zero-sentinel, + // so the method call returns undefined. Only route + // known stream-API methods through NativeMethodCall; + // anything else falls through to the user-class + // method dispatch path further down. Mirrors the + // PropertyGet gate in expr_member.rs. + let is_stream_module = matches!( + module_name.as_str(), + "readable_stream" + | "writable_stream" + | "transform_stream" + | "readable_stream_reader" + | "writable_stream_writer" + ); + if is_stream_module + && !is_stream_api_method(&module_name, &method_name) + { + // Fall through — let the regular method-call + // dispatch further down handle the user-class + // method. + } else { + // Get the object expression (the instance variable) + let object_expr = lower_expr(ctx, &member.obj)?; + return Ok(Expr::NativeMethodCall { + module: module_name, + class_name: Some(class_name), // Use the registered class name + object: Some(Box::new(object_expr)), + method: method_name, + args, + }); + } } } } @@ -5224,3 +5265,136 @@ pub(super) fn lower_call(ctx: &mut LoweringContext, call: &ast::CallExpr) -> Res } } } + +/// Issue #562 — does `method` name a stream-API method on the given +/// stream module? Used to gate the native-instance method rerouting so +/// user-declared subclass methods fall through to regular class method +/// dispatch. Mirrors the methods table in +/// `crates/perry-codegen/src/lower_call.rs`'s `module == ""` +/// arms — see also the parallel `is_stream_api_member` in +/// `expr_member.rs`. +fn is_stream_api_method(module: &str, method: &str) -> bool { + match module { + "readable_stream" => matches!( + method, + "getReader" + | "cancel" + | "tee" + | "pipeTo" + | "pipeThrough" + | "locked" + | "enqueue" + | "close" + | "error" + | "desiredSize" + ), + "readable_stream_reader" => { + matches!(method, "read" | "releaseLock" | "cancel" | "closed") + } + "writable_stream" => matches!(method, "getWriter" | "abort" | "close" | "locked"), + "writable_stream_writer" => matches!( + method, + "write" + | "close" + | "abort" + | "releaseLock" + | "closed" + | "ready" + | "desiredSize" + ), + "transform_stream" => matches!(method, "readable" | "writable"), + _ => false, + } +} + +/// Issue #562 — `class X extends ReadableStream/TransformStream` +/// constructor: register the controller param of each callback in +/// `super({...})` as a `readable_stream` native instance so +/// `controller.enqueue(...)` inside those bodies dispatches through the +/// streams arms in `lower_call.rs`. +/// +/// Mirrors the field-spec table in `expr_new.rs::lower_new` for the +/// `new ReadableStream/TransformStream` form. WritableStream's +/// `write`/`close`/`abort` callbacks don't take a controller — +/// no pre-registration is required for that parent. +fn register_super_stream_controller_params( + ctx: &mut LoweringContext, + parent_ident: &str, + call: &ast::CallExpr, +) { + let field_specs: &[(&'static str, usize, &'static str, &'static str)] = match parent_ident { + "ReadableStream" => &[ + ("start", 0, "readable_stream", "ReadableStream"), + ("pull", 0, "readable_stream", "ReadableStream"), + ], + "TransformStream" => &[ + ("transform", 1, "readable_stream", "ReadableStream"), + ("flush", 0, "readable_stream", "ReadableStream"), + ], + _ => return, + }; + + let Some(first) = call.args.first() else { + return; + }; + let ast::Expr::Object(obj_lit) = first.expr.as_ref() else { + return; + }; + for prop in &obj_lit.props { + let ast::PropOrSpread::Prop(boxed_prop) = prop else { + continue; + }; + match boxed_prop.as_ref() { + ast::Prop::KeyValue(kv) => { + let name = match &kv.key { + ast::PropName::Ident(i) => Some(i.sym.as_ref()), + ast::PropName::Str(s) => s.value.as_str(), + _ => None, + }; + let Some(name) = name else { continue }; + let Some((_, idx, mod_name, class_name)) = + field_specs.iter().find(|(f, _, _, _)| *f == name) + else { + continue; + }; + let pat: Option<&ast::Pat> = match kv.value.as_ref() { + ast::Expr::Arrow(arrow) => arrow.params.get(*idx), + ast::Expr::Fn(fn_expr) => { + fn_expr.function.params.get(*idx).map(|p| &p.pat) + } + _ => None, + }; + if let Some(ast::Pat::Ident(pid)) = pat { + ctx.register_native_instance( + pid.id.sym.to_string(), + mod_name.to_string(), + class_name.to_string(), + ); + } + } + ast::Prop::Method(m) => { + let name = match &m.key { + ast::PropName::Ident(i) => Some(i.sym.as_ref()), + ast::PropName::Str(s) => s.value.as_str(), + _ => None, + }; + let Some(name) = name else { continue }; + let Some((_, idx, mod_name, class_name)) = + field_specs.iter().find(|(f, _, _, _)| *f == name) + else { + continue; + }; + if let Some(param) = m.function.params.get(*idx) { + if let ast::Pat::Ident(pid) = ¶m.pat { + ctx.register_native_instance( + pid.id.sym.to_string(), + mod_name.to_string(), + class_name.to_string(), + ); + } + } + } + _ => {} + } + } +} diff --git a/crates/perry-hir/src/lower/expr_member.rs b/crates/perry-hir/src/lower/expr_member.rs index c9c8ed04cc..efd48773a1 100644 --- a/crates/perry-hir/src/lower/expr_member.rs +++ b/crates/perry-hir/src/lower/expr_member.rs @@ -407,16 +407,41 @@ pub(super) fn lower_member(ctx: &mut LoweringContext, member: &ast::MemberExpr) if let Some(module_name) = native_instance { if let ast::MemberProp::Ident(prop_ident) = &member.prop { let property_name = prop_ident.sym.to_string(); - // For properties that map to FFI functions, generate a NativeMethodCall - // with no args (property getter) - let object_expr = lower_expr(ctx, &member.obj)?; - return Ok(Expr::NativeMethodCall { - module: module_name, - class_name: None, - object: Some(Box::new(object_expr)), - method: property_name, - args: Vec::new(), - }); + // Issue #562: stream subclass instances (e.g. + // `class W extends WritableStream`) carry the bare-stream + // module/class tag for inherited-method dispatch + // (`w.pipeTo(...)` / `w.getWriter()`), but they ALSO + // declare their own fields (`w.seenLengths` / `w.config`). + // Without this gate, every plain field read would route + // through the NativeMethodCall arm in `lower_call.rs`, + // miss the streams' known-method match, fall through to + // the receiver-less zero-sentinel, and read as 0. Only + // route to NativeMethodCall when the property name is a + // known stream API method/property — let everything else + // fall through to regular object property access. + if matches!( + module_name.as_str(), + "readable_stream" + | "writable_stream" + | "transform_stream" + | "readable_stream_reader" + | "writable_stream_writer" + ) && !is_stream_api_member(&module_name, &property_name) + { + // Fall through — let the regular member access path + // below handle the user-declared subclass field. + } else { + // For properties that map to FFI functions, generate a NativeMethodCall + // with no args (property getter) + let object_expr = lower_expr(ctx, &member.obj)?; + return Ok(Expr::NativeMethodCall { + module: module_name, + class_name: None, + object: Some(Box::new(object_expr)), + method: property_name, + args: Vec::new(), + }); + } } } } @@ -587,3 +612,43 @@ pub(super) fn lower_member(ctx: &mut LoweringContext, member: &ast::MemberExpr) } } } + +/// Issue #562 — does `prop` name a stream-API method or property on the +/// given stream module? Used to gate the native-instance property +/// rerouting so subclass-declared fields fall through to regular object +/// property access. Mirrors the methods + accessors hardcoded in +/// `crates/perry-codegen/src/lower_call.rs`'s +/// `module == ""` arms. +fn is_stream_api_member(module: &str, prop: &str) -> bool { + match module { + "readable_stream" => matches!( + prop, + "getReader" + | "cancel" + | "tee" + | "pipeTo" + | "pipeThrough" + | "locked" + | "enqueue" + | "close" + | "error" + | "desiredSize" + ), + "readable_stream_reader" => { + matches!(prop, "read" | "releaseLock" | "cancel" | "closed") + } + "writable_stream" => matches!(prop, "getWriter" | "abort" | "close" | "locked"), + "writable_stream_writer" => matches!( + prop, + "write" + | "close" + | "abort" + | "releaseLock" + | "closed" + | "ready" + | "desiredSize" + ), + "transform_stream" => matches!(prop, "readable" | "writable"), + _ => false, + } +} diff --git a/crates/perry-hir/src/lower_decl.rs b/crates/perry-hir/src/lower_decl.rs index 47f6549066..e8918bb54f 100644 --- a/crates/perry-hir/src/lower_decl.rs +++ b/crates/perry-hir/src/lower_decl.rs @@ -600,6 +600,16 @@ pub(crate) fn lower_class_decl( let old_class = ctx.current_class.take(); ctx.current_class = Some(name.clone()); + // Issue #562: track the parent class identifier so the `super({...})` + // pre-scan in expr_call.rs can register the controller param as a + // readable_stream instance for stream subclass constructors. Set + // here BEFORE constructor lowering so the body lowering picks it up. + let old_super_ident = ctx.current_class_super_ident.take(); + ctx.current_class_super_ident = match class_decl.class.super_class.as_deref() { + Some(ast::Expr::Ident(ident)) => Some(ident.sym.to_string()), + _ => None, + }; + // Extract type parameters from generic class declaration (e.g., class Box) let type_params = class_decl .class @@ -623,10 +633,32 @@ pub(crate) fn lower_class_decl( Some(("async_hooks".to_string(), "AsyncLocalStorage".to_string())) } "WebSocketServer" => Some(("ws".to_string(), "WebSocketServer".to_string())), + // Issue #562: user classes extending the Web Streams + // base classes get a runtime-side subclass-init shim + // wired through `Expr::SuperCall` (codegen). The + // `extends_name` is also retained so the existing + // `native_extends.is_some()` branch below still + // populates it for the inheritance walks elsewhere + // (vtable, hasOwn, etc.) that key on the parent name. + "ReadableStream" => { + Some(("readable_stream".to_string(), "ReadableStream".to_string())) + } + "WritableStream" => { + Some(("writable_stream".to_string(), "WritableStream".to_string())) + } + "TransformStream" => { + Some(("transform_stream".to_string(), "TransformStream".to_string())) + } _ => None, }; if native_parent.is_some() { - (None, None, native_parent) + // Keep `extends_name` populated alongside `native_extends` + // so SuperCall codegen + downstream chain walks still + // see the parent name (mirrors how stream-class + // dispatch resolves through the existing extends_name + // path while the native_extends carries the (module, + // class) tag for the runtime shim). + (None, Some(parent_name), native_parent) } else { // Always capture the parent name for imported classes that may not have a ClassId (ctx.lookup_class(&parent_name), Some(parent_name), None) @@ -1127,8 +1159,18 @@ pub(crate) fn lower_class_decl( // Exit type parameter scope ctx.exit_type_param_scope(); + // Issue #562: stash native_extends so the `let x = new ()` + // path in destructuring.rs can route the local through the parent + // stream module. Done here (not at the call site) so the registry + // lookup is always available regardless of declaration order. + if let Some((module, class)) = native_extends.as_ref() { + ctx.register_class_native_extends(name.clone(), module.clone(), class.clone()); + } + // Restore previous current_class ctx.current_class = old_class; + // Issue #562: restore the prior super-ident slot. + ctx.current_class_super_ident = old_super_ident; // Issue #212: classes nested inside a function may have method bodies // that reference enclosing-fn locals. Walk every instance member @@ -1478,6 +1520,15 @@ pub(crate) fn lower_class_from_ast( let old_class = ctx.current_class.take(); ctx.current_class = Some(name.to_string()); + // Issue #562: same as the parallel `lower_class_decl` arm — track the + // parent class identifier so super({...}) controller-param pre-scan + // fires for stream subclasses. + let old_super_ident = ctx.current_class_super_ident.take(); + ctx.current_class_super_ident = match class.super_class.as_deref() { + Some(ast::Expr::Ident(ident)) => Some(ident.sym.to_string()), + _ => None, + }; + let type_params = class .type_params .as_ref() @@ -1495,10 +1546,21 @@ pub(crate) fn lower_class_from_ast( Some(("async_hooks".to_string(), "AsyncLocalStorage".to_string())) } "WebSocketServer" => Some(("ws".to_string(), "WebSocketServer".to_string())), + // Issue #562: keep in lockstep with the parallel arm in + // `lower_class_decl` above. + "ReadableStream" => { + Some(("readable_stream".to_string(), "ReadableStream".to_string())) + } + "WritableStream" => { + Some(("writable_stream".to_string(), "WritableStream".to_string())) + } + "TransformStream" => { + Some(("transform_stream".to_string(), "TransformStream".to_string())) + } _ => None, }; if native_parent.is_some() { - (None, None, native_parent) + (None, Some(parent_name), native_parent) } else { (ctx.lookup_class(&parent_name), Some(parent_name), None) } @@ -1659,7 +1721,15 @@ pub(crate) fn lower_class_from_ast( } ctx.exit_type_param_scope(); + // Issue #562: see the parallel site in `lower_class_decl` — register + // native_extends so subclass instances of the three Web Stream base + // classes route through the parent stream module's dispatch table. + if let Some((module, class)) = native_extends.as_ref() { + ctx.register_class_native_extends(name.to_string(), module.clone(), class.clone()); + } ctx.current_class = old_class; + // Issue #562: restore prior super-ident slot. + ctx.current_class_super_ident = old_super_ident; // Phase 4.1: register method + getter return types — see the parallel // site in lower_class_decl. diff --git a/crates/perry-stdlib/src/streams.rs b/crates/perry-stdlib/src/streams.rs index e685b6c3bf..cdf107d379 100644 --- a/crates/perry-stdlib/src/streams.rs +++ b/crates/perry-stdlib/src/streams.rs @@ -26,8 +26,9 @@ use perry_runtime::{ js_array_alloc, js_array_push, js_closure_call0, js_closure_call1, js_closure_call2, - js_object_alloc, js_object_set_field, js_object_set_keys, js_promise_new, js_promise_reject, - js_promise_resolve, js_string_from_bytes, ClosureHeader, JSValue, Promise, + js_object_alloc, js_object_get_field_by_name, js_object_set_field, js_object_set_field_by_name, + js_object_set_keys, js_promise_new, js_promise_reject, js_promise_resolve, js_string_from_bytes, + ClosureHeader, JSValue, ObjectHeader, Promise, }; use std::collections::{HashMap, VecDeque}; use std::sync::Mutex; @@ -1312,6 +1313,139 @@ pub unsafe extern "C" fn js_streams_throw_byte_length_not_implemented() -> f64 { // Public helpers used by other crates / tests // ───────────────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────── +// Subclass support (issue #562) +// +// User classes extending `WritableStream` / `ReadableStream` / +// `TransformStream` get an underlying-stream registry handle allocated +// at `super({ ... })` time and stashed on `this` under the hidden field +// `__perry_stream_handle__`. The dispatch arms in `lower_call.rs` route +// the receiver / destination through `js_stream_unwrap_handle` before +// the FFI call so subclass instances and bare handles are +// interchangeable. +// ───────────────────────────────────────────────────────────────────── + +/// Hidden field name used to stash the underlying-stream registry id on +/// a subclass instance. Read by `js_stream_unwrap_handle`, written by +/// the three `*_subclass_init` helpers below. +const SUBCLASS_HANDLE_FIELD: &[u8] = b"__perry_stream_handle__"; + +unsafe fn subclass_handle_key() -> *const perry_runtime::StringHeader { + js_string_from_bytes(SUBCLASS_HANDLE_FIELD.as_ptr(), SUBCLASS_HANDLE_FIELD.len() as u32) +} + +unsafe fn this_object_ptr(this_bits: f64) -> Option<*mut ObjectHeader> { + let bits = this_bits.to_bits(); + let top16 = bits >> 48; + if top16 != 0x7FFD { + return None; + } + let raw = (bits & POINTER_MASK) as *mut ObjectHeader; + if raw.is_null() || (raw as usize) < 0x10000 { + return None; + } + Some(raw) +} + +unsafe fn attach_handle_to_this(this_bits: f64, handle_id: usize) { + if let Some(obj) = this_object_ptr(this_bits) { + let key = subclass_handle_key(); + // Stored as plain f64 numeric — same ABI the rest of the stream + // FFIs use for handles. `js_stream_unwrap_handle` reads it back. + js_object_set_field_by_name(obj, key, handle_id as f64); + } +} + +/// Resolve a stream receiver / argument to a numeric registry handle. +/// +/// For raw numeric handles (the value `js_writable_stream_new` etc. +/// return) the input is returned unchanged. For NaN-boxed pointer-tagged +/// JS objects (subclass instances), reads the hidden +/// `__perry_stream_handle__` field. Falls back to the input when the +/// field is missing — caller's downstream FFI will then no-op on a +/// 0-or-bogus handle exactly as it did pre-#562. +#[no_mangle] +pub unsafe extern "C" fn js_stream_unwrap_handle(value: f64) -> f64 { + let bits = value.to_bits(); + let top16 = bits >> 48; + if top16 != 0x7FFD { + return value; + } + let Some(obj) = this_object_ptr(value) else { + return value; + }; + let key = subclass_handle_key(); + let result = js_object_get_field_by_name(obj, key); + let result_bits = result.bits(); + if result_bits == TAG_UNDEFINED || result_bits == TAG_NULL { + return value; + } + f64::from_bits(result_bits) +} + +/// `super({ start, pull, cancel })` dispatch for `class X extends ReadableStream`. +/// Allocates the underlying readable handle, stashes it on `this`, runs +/// the user `start` callback synchronously (mirrors `js_readable_stream_new`). +#[no_mangle] +pub unsafe extern "C" fn js_readable_stream_subclass_init( + this_bits: f64, + start_bits: f64, + pull_bits: f64, + cancel_bits: f64, + hwm: f64, +) -> f64 { + ensure_gc_registered(); + let id = alloc_readable( + closure_from_bits(start_bits.to_bits()), + closure_from_bits(pull_bits.to_bits()), + closure_from_bits(cancel_bits.to_bits()), + hwm, + ); + attach_handle_to_this(this_bits, id); + invoke_start(id); + maybe_pull(id); + f64::from_bits(TAG_UNDEFINED) +} + +/// `super({ write, close, abort })` dispatch for `class X extends WritableStream`. +#[no_mangle] +pub unsafe extern "C" fn js_writable_stream_subclass_init( + this_bits: f64, + write_bits: f64, + close_bits: f64, + abort_bits: f64, + hwm: f64, +) -> f64 { + ensure_gc_registered(); + let id = alloc_writable( + closure_from_bits(write_bits.to_bits()), + closure_from_bits(close_bits.to_bits()), + closure_from_bits(abort_bits.to_bits()), + hwm, + ); + attach_handle_to_this(this_bits, id); + f64::from_bits(TAG_UNDEFINED) +} + +/// `super({ transform, flush })` dispatch for `class X extends TransformStream`. +/// Allocates the transform-stream pair (readable + writable + the +/// dispatcher row in `TRANSFORM_PAIRS`) — same shape as +/// `js_transform_stream_new` — and stashes the transform handle id on +/// `this`. `pipeThrough(subclass)` then calls `js_transform_stream_writable` +/// / `_readable` after `js_stream_unwrap_handle`, finding the same +/// readable / writable sub-handles. +#[no_mangle] +pub unsafe extern "C" fn js_transform_stream_subclass_init( + this_bits: f64, + transform_bits: f64, + flush_bits: f64, + hwm: f64, +) -> f64 { + let handle = js_transform_stream_new(transform_bits, flush_bits, hwm); + attach_handle_to_this(this_bits, handle as usize); + f64::from_bits(TAG_UNDEFINED) +} + /// Read every queued chunk into a Vec, draining the stream. Used by /// `new Response(stream)` / `new Request(url, { body: stream })` — we /// drain the buffered chunks at construction time so the resulting diff --git a/test-files/test_issue_562_stream_subclass.ts b/test-files/test_issue_562_stream_subclass.ts new file mode 100644 index 0000000000..27259d3e9d --- /dev/null +++ b/test-files/test_issue_562_stream_subclass.ts @@ -0,0 +1,78 @@ +// Regression for issue #562 — user classes can extend +// WritableStream / ReadableStream / TransformStream and the inherited +// `pipeTo` / `pipeThrough` / underlying-sink callbacks all work. + +class MyWritable extends WritableStream { + public seenLengths: number[] = []; + public closed: boolean = false; + constructor() { + super({ + write: (chunk: any): void => { + this.seenLengths.push(chunk.length); + }, + close: (): void => { + this.closed = true; + }, + }); + } +} + +class IdentityTransform extends TransformStream { + constructor() { + super({ + transform(chunk: any, controller: any): void { + controller.enqueue(chunk); + }, + }); + } +} + +class MyReadable extends ReadableStream { + constructor() { + super({ + start(controller: any): void { + controller.enqueue(new Uint8Array([1, 2, 3])); + controller.enqueue(new Uint8Array([4, 5])); + controller.close(); + }, + }); + } +} + +async function main(): Promise { + // ── 1. pipeTo into a WritableStream subclass ── + const w = new MyWritable(); + const r1 = new ReadableStream({ + start(c: any): void { + c.enqueue(new Uint8Array([10, 20, 30])); + c.enqueue(new Uint8Array([40, 50])); + c.close(); + }, + }); + await r1.pipeTo(w); + console.log("subclass-writable lengths: " + w.seenLengths.join(",")); + console.log("subclass-writable closed: " + w.closed); + + // ── 2. pipeThrough a TransformStream subclass ── + const t = new IdentityTransform(); + const r2 = new ReadableStream({ + start(c: any): void { + c.enqueue(new Uint8Array([100, 101, 102])); + c.close(); + }, + }); + const downstream = r2.pipeThrough(t); + const reader = downstream.getReader(); + const out = await reader.read(); + console.log("subclass-transform done: " + out.done); + console.log("subclass-transform first len: " + out.value.length); + + // ── 3. ReadableStream subclass producing into a WritableStream subclass ── + const w2 = new MyWritable(); + const r3 = new MyReadable(); + await r3.pipeTo(w2); + console.log("subclass-readable->subclass-writable lengths: " + w2.seenLengths.join(",")); + console.log("subclass-readable->subclass-writable closed: " + w2.closed); +} + +main();