diff --git a/CLAUDE.md b/CLAUDE.md index 18b39ed09d..10c50edd67 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.1609 +**Current Version:** 0.5.1610 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 2b5e7d8499..ae9c2e20c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2505,12 +2505,6 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" -[[package]] -name = "futures-timer" -version = "3.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" - [[package]] name = "futures-util" version = "0.3.34" @@ -2846,29 +2840,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "governor" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" -dependencies = [ - "cfg-if", - "dashmap 6.2.1", - "futures-sink", - "futures-timer", - "futures-util", - "getrandom 0.3.4", - "hashbrown 0.16.1", - "nonzero_ext", - "parking_lot", - "portable-atomic", - "quanta", - "rand 0.9.4", - "smallvec", - "spinning_top", - "web-time", -] - [[package]] name = "graphene-rs" version = "0.20.10" @@ -4939,12 +4910,6 @@ dependencies = [ "nom 8.0.0", ] -[[package]] -name = "nonzero_ext" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" - [[package]] name = "noop_proc_macro" version = "0.3.0" @@ -5614,7 +5579,7 @@ checksum = "1473d470930ed48574515a25df34900f3af89c6fa422d903e019121312a9f13e" [[package]] name = "perry" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "anyhow", "base64 0.22.1", @@ -5678,7 +5643,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "perry-dispatch", "serde", @@ -5686,7 +5651,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "cc", "libc", @@ -5695,7 +5660,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "aho-corasick", "anyhow", @@ -5712,7 +5677,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "anyhow", "perry-hir", @@ -5720,7 +5685,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "anyhow", "perry-hir", @@ -5728,7 +5693,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "anyhow", "perry-dispatch", @@ -5737,7 +5702,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "anyhow", "perry-hir", @@ -5745,7 +5710,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "anyhow", "base64 0.22.1", @@ -5757,7 +5722,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "anyhow", "perry-hir", @@ -5765,7 +5730,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "async-trait", "clap", @@ -5789,14 +5754,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "serde", "serde_json", @@ -5804,7 +5769,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1609" +version = "0.5.1610" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5815,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "anyhow", "clap", @@ -5830,7 +5795,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "block2", "objc2", @@ -5840,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "argon2", "perry-ffi", @@ -5849,7 +5814,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "bcrypt", "perry-ffi", @@ -5857,7 +5822,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "perry-ffi", "rusqlite", @@ -5865,7 +5830,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "perry-ffi", "scraper", @@ -5873,7 +5838,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "perry-ffi", "perry-runtime", @@ -5881,7 +5846,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "chrono", "cron", @@ -5889,17 +5854,9 @@ dependencies = [ "perry-runtime", ] -[[package]] -name = "perry-ext-dayjs" -version = "0.5.1609" -dependencies = [ - "chrono", - "perry-ffi", -] - [[package]] name = "perry-ext-decimal" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "perry-ffi", "rust_decimal", @@ -5907,7 +5864,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5915,7 +5872,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "perry-ffi", "perry-runtime", @@ -5923,31 +5880,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "perry-ffi", ] -[[package]] -name = "perry-ext-fastify" -version = "0.5.1609" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "perry-ext-ws", - "perry-ffi", - "perry-runtime", - "serde_json", - "socket2", - "tokio", - "tokio-tungstenite", -] - [[package]] name = "perry-ext-fetch" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "bytes", "lazy_static", @@ -5960,7 +5900,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "base64 0.22.1", "bytes", @@ -5992,7 +5932,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "lazy_static", "perry-ffi", @@ -6002,7 +5942,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "lru", "perry-ffi", @@ -6011,7 +5951,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "chrono", "perry-ffi", @@ -6019,7 +5959,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "bson", "futures-util", @@ -6031,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "chrono", "perry-ffi", @@ -6043,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "bytes", "perry-ffi", @@ -6058,7 +5998,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "const-oid 0.10.2", "der 0.8.2", @@ -6077,7 +6017,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "lettre", "perry-ffi", @@ -6087,7 +6027,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "notify", "perry-ffi", @@ -6099,7 +6039,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "perry-ffi", "printpdf", @@ -6107,33 +6047,16 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "perry-ffi", "sqlx", "tokio", ] -[[package]] -name = "perry-ext-qs" -version = "0.5.1609" -dependencies = [ - "perry-ffi", - "perry-runtime", - "serde_json", -] - -[[package]] -name = "perry-ext-ratelimit" -version = "0.5.1609" -dependencies = [ - "governor", - "perry-ffi", -] - [[package]] name = "perry-ext-sharp" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "fast_image_resize", "image", @@ -6144,7 +6067,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "lazy_static", "perry-ffi", @@ -6153,7 +6076,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "anyhow", "perry-ffi", @@ -6173,7 +6096,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "perry-ffi", "perry-runtime", @@ -6182,7 +6105,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "futures-util", "lazy_static", @@ -6195,7 +6118,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "brotli", "flate2", @@ -6205,7 +6128,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6215,7 +6138,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "anyhow", "perry-api-manifest", @@ -6235,11 +6158,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1609" +version = "0.5.1610" [[package]] name = "perry-parser" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "anyhow", "perry-diagnostics", @@ -6252,7 +6175,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "perex", "regex", @@ -6260,7 +6183,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "ahash", "base64 0.22.1", @@ -6318,14 +6241,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6349,7 +6272,6 @@ dependencies = [ "flate2", "futures-util", "ghash 0.6.0", - "governor", "hex", "hkdf 0.13.0", "hmac 0.13.0", @@ -6411,21 +6333,21 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "perry-hir", ] [[package]] name = "perry-ui" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "dirs", "perry-ffi", @@ -6435,7 +6357,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "base64 0.22.1", "jni", @@ -6450,7 +6372,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "rand 0.10.2", "serde", @@ -6460,7 +6382,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6483,7 +6405,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "base64 0.22.1", "block2", @@ -6500,7 +6422,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "base64 0.22.1", "block2", @@ -6517,7 +6439,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1609" +version = "0.5.1610" [[package]] name = "perry-ui-test" @@ -6528,11 +6450,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1609" +version = "0.5.1610" [[package]] name = "perry-ui-tvos" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "base64 0.22.1", "block2", @@ -6549,7 +6471,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "base64 0.22.1", "block2", @@ -6566,7 +6488,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "block2", "libc", @@ -6580,7 +6502,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "base64 0.22.1", "libc", @@ -6599,7 +6521,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "base64 0.22.1", "libc", @@ -6612,7 +6534,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "anyhow", "base64 0.22.1", @@ -6627,7 +6549,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1609" +version = "0.5.1610" dependencies = [ "wasmi", ] @@ -7084,21 +7006,6 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142" -[[package]] -name = "quanta" -version = "0.12.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" -dependencies = [ - "crossbeam-utils", - "libc", - "once_cell", - "raw-cpuid", - "wasi", - "web-sys", - "winapi", -] - [[package]] name = "quick-error" version = "1.2.3" @@ -7346,15 +7253,6 @@ dependencies = [ "rgb", ] -[[package]] -name = "raw-cpuid" -version = "11.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" -dependencies = [ - "bitflags 2.12.1", -] - [[package]] name = "rayon" version = "1.12.0" @@ -8356,15 +8254,6 @@ dependencies = [ "lock_api", ] -[[package]] -name = "spinning_top" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" -dependencies = [ - "lock_api", -] - [[package]] name = "spki" version = "0.7.3" diff --git a/Cargo.toml b/Cargo.toml index 79fdacfac5..91e85f109f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,12 +20,9 @@ members = [ "crates/perry-ext-exponential-backoff", "crates/perry-ext-events", "crates/perry-ext-decimal", - "crates/perry-ext-dayjs", - "crates/perry-ext-qs", "crates/perry-ext-moment", "crates/perry-ext-cheerio", "crates/perry-ext-sharp", - "crates/perry-ext-ratelimit", "crates/perry-ext-commander", "crates/perry-ext-ethers", "crates/perry-ext-nodemailer", @@ -40,7 +37,6 @@ members = [ "crates/perry-ext-net", "crates/perry-ext-http", "crates/perry-ext-streams", - "crates/perry-ext-fastify", "crates/perry-ext-pdf", "crates/perry-ext-ads", "crates/perry-ext-node-forge", @@ -218,8 +214,6 @@ codegen-units = 1 # needs 2+ of these wrapper crates simultaneously, because perry's # strip-dedup mechanism assumes same-named codegen units are byte-identical # and safe to drop duplicates from — an assumption this mismatch violated. -[profile.release.package.perry-ext-fastify] -codegen-units = 1 [profile.release.package.perry-ext-http] codegen-units = 1 [profile.release.package.perry-ext-ioredis] @@ -317,8 +311,6 @@ codegen-units = 1 # Issue #5928: mirrors the [profile.release.package.perry-ext-*] block above # — see its comment for why matching `codegen-units` across every crate in # the "shared tokio" (#507) cargo invocation is required. -[profile.dist.package.perry-ext-fastify] -codegen-units = 1 [profile.dist.package.perry-ext-http] codegen-units = 1 [profile.dist.package.perry-ext-ioredis] @@ -331,7 +323,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1609" +version = "0.5.1610" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" @@ -479,12 +471,9 @@ perry-ext-zlib = { path = "crates/perry-ext-zlib" } perry-ext-exponential-backoff = { path = "crates/perry-ext-exponential-backoff" } perry-ext-events = { path = "crates/perry-ext-events" } perry-ext-decimal = { path = "crates/perry-ext-decimal" } -perry-ext-dayjs = { path = "crates/perry-ext-dayjs" } -perry-ext-qs = { path = "crates/perry-ext-qs" } perry-ext-moment = { path = "crates/perry-ext-moment" } perry-ext-cheerio = { path = "crates/perry-ext-cheerio" } perry-ext-sharp = { path = "crates/perry-ext-sharp" } -perry-ext-ratelimit = { path = "crates/perry-ext-ratelimit" } perry-ext-commander = { path = "crates/perry-ext-commander" } perry-ext-ethers = { path = "crates/perry-ext-ethers" } perry-ext-nodemailer = { path = "crates/perry-ext-nodemailer" } @@ -499,7 +488,6 @@ perry-ext-ws = { path = "crates/perry-ext-ws" } perry-ext-net = { path = "crates/perry-ext-net" } perry-ext-http = { path = "crates/perry-ext-http" } perry-ext-streams = { path = "crates/perry-ext-streams" } -perry-ext-fastify = { path = "crates/perry-ext-fastify" } perry-ext-pdf = { path = "crates/perry-ext-pdf" } perry-ext-ads = { path = "crates/perry-ext-ads" } perry-ext-node-forge = { path = "crates/perry-ext-node-forge" } diff --git a/changelog.d/10765-remove-6-native-bindings.md b/changelog.d/10765-remove-6-native-bindings.md new file mode 100644 index 0000000000..53b1324245 --- /dev/null +++ b/changelog.d/10765-remove-6-native-bindings.md @@ -0,0 +1,45 @@ +Removed six native npm-package bindings so real npm source compiles instead +of a hand-written Rust reimplementation: `qs` (6.15.3), `fastify` (5.10.0), +`dayjs` (1.11.21), `date-fns` (4.4.0), `rate-limiter-flexible` (11.2.0), and +`node-cron` (4.6.0). Each package's primary documented use compiles with no +`perry.compilePackages` entry and matches `node --experimental-strip-types` +byte-for-byte on its pinned version. + +`dayjs`/`date-fns` shared one crate (`perry-ext-dayjs`, deleted) and had a +hidden stdlib-side duplicate (`perry-stdlib/src/dayjs.rs`, #10678-shaped — +also deleted, along with the `bundled-dayjs` feature). `rate-limiter-flexible` +had the same duplicate pattern (`perry-ext-ratelimit` + +`perry-stdlib/src/ratelimit.rs` + the `bundled-ratelimit`/`rate-limit` +features + the now-unused `governor` dependency); its orphaned +`js_ratelimit_create` stub (declared and stubbed everywhere, implemented +nowhere) went with it. `node-cron` shares `perry-ext-cron` with the `cron` +package, which is unaffected and stays — only node-cron's own +well-known-binding entry, manifest rows, and `("node-cron", "schedule") => +"CronJob"` HIR tagging were removed; the deleted `test_parity_cron.ts` had a +known-failure entry documenting that `cron.validate`/`cron.schedule` resolved +as `undefined` when imported from `"node-cron"` — a real, tracked defect in +the native path this removes. `fastify` had no in-stdlib fallback but did +have dynamic-dispatch adapters (`external-fastify-pump`) that are now dead +weight and removed with it. + +`typescript` was in an earlier revision of this change and has been **pulled +back out**. Its binding, crate, manifest rows and HIR enum folding are all +retained unchanged. Removing it makes perry execute real `typescript.js` for +the first time — `well_known.rs` routes `import 'X'` to the bundled wrapper +even when `node_modules/X` is on disk, so that path is unreachable today — +and real `typescript.js` hits a compiler defect (`TypeError: Cannot convert +undefined or null to object` inside `InferredProject.addRoot`). The defect +predates this change and is not caused by it, but a path that works for users +today would start throwing, so the removal waits for the compiler fix. + +Recomputed from the resolved tree (rebased onto v0.5.1608, after #10693's +nanoid removal): workspace members 76→72, `externalize` 27→23, `keep` +unchanged at 44 (four crates deleted; `perry-ext-typescript` keeps its +`externalize`/`compile-source` classification). API manifest 3006→2923 entries across 132→126 modules +(`docs/api/perry.d.ts` 2064→2043 across 130→124), regenerated from the +built binary's `--print-api-manifest`, not hand-edited. `native_result_ledger.py` 376→369 +rows / 326→320 providers. `unrooted_local_shape_baseline.json` 578→562 and +`string_payload_access_baseline.txt` regenerated (decreases only). +`binding_governance.py` 30 extension crates classified; `binding_pins.mjs` +26 pinned, lock-step holds (unchanged — `typescript`'s upstream pin is +retained along with its binding). diff --git a/crates/perry-api-manifest/src/entries.rs b/crates/perry-api-manifest/src/entries.rs index 1ac5de2cb5..ec2fcac9db 100644 --- a/crates/perry-api-manifest/src/entries.rs +++ b/crates/perry-api-manifest/src/entries.rs @@ -32,7 +32,6 @@ pub const NATIVE_MODULES: &[&str] = &[ "mysql2", // MySQL/MariaDB client "mysql2/promise", // mysql2's promise-API subpath "pg", // PostgreSQL client - "qs", // nested query-string parser/stringifier (Stripe dependency) "bcrypt", // bcrypt password hashing (replaces the N-API addon) "argon2", // Argon2 password hashing (replaces the N-API addon) "ioredis", // Redis/Valkey client @@ -53,7 +52,6 @@ pub const NATIVE_MODULES: &[&str] = &[ "bun:jsc", "ffi", // node:ffi (the node: prefix is normalized away) "bun:sqlite", // Bun facade over Perry's native SQLite engine - "node-cron", // cron-style scheduler (npm node-cron; aliases `cron`) "nodemailer", // SMTP email sending // ── Node.js builtin modules ── "http", // HTTP client + server @@ -95,13 +93,10 @@ pub const NATIVE_MODULES: &[&str] = &[ "bignumber.js", // arbitrary-precision big numbers "exponential-backoff", // retry-with-backoff helper "lodash", // general utility library - "dayjs", // date/time library - "date-fns", // functional date utilities "moment", // (legacy) date/time library "sharp", // image processing (replaces the N-API addon) "cheerio", // server-side jQuery-style HTML parsing "cron", // cron scheduler (aliases node-cron) - "fastify", // HTTP server framework // ── Node.js builtins (cont.) ── "async_hooks", // async context tracking // #2875: internal module backing DisposableStack/AsyncDisposableStack @@ -147,8 +142,7 @@ pub const NATIVE_MODULES: &[&str] = &[ "perry/audio", // audio surface "perry/background", // background-task surface // ── More third-party npm packages ── - "redis", // npm `redis` client (aliases ioredis) - "rate-limiter-flexible", // rate limiting + "redis", // npm `redis` client (aliases ioredis) // `undici` (#466) — served by perry's native fetch stack via the // bundled perry-ext-undici wrapper (ProxyAgent / Agent / // setGlobalDispatcher / getGlobalDispatcher / fetch subset). diff --git a/crates/perry-api-manifest/src/entries/part_1.rs b/crates/perry-api-manifest/src/entries/part_1.rs index 05bc326238..9e22539e6e 100644 --- a/crates/perry-api-manifest/src/entries/part_1.rs +++ b/crates/perry-api-manifest/src/entries/part_1.rs @@ -13,57 +13,6 @@ pub(crate) const API_MANIFEST_PART_1: &[ApiEntry] = &[ // (extracted from crates/perry-codegen/src/lower_call.rs; // drift guarded by perry-codegen's manifest_consistency test) // =========================================================== - method_sig( - "fastify", - "default", - false, - None, - &[p_any("p0")], - TypeSpec::Any, - ), - method("fastify", "get", true, None), - method("fastify", "post", true, None), - method("fastify", "put", true, None), - method("fastify", "delete", true, None), - method("fastify", "patch", true, None), - method("fastify", "head", true, None), - method("fastify", "options", true, None), - method("fastify", "all", true, None), - method("fastify", "route", true, None), - method("fastify", "addHook", true, None), - method("fastify", "setErrorHandler", true, None), - method("fastify", "register", true, None), - method("fastify", "listen", true, None), - method("fastify", "close", true, None), - // #1113 — `app.server` is a Node-compatible getter returning the - // FastifyApp handle (pointer-tagged) so `typeof app.server === - // "object"`. Lowered as a zero-arg NativeMethodCall by the HIR - // property-as-method path; the runtime side is - // `js_fastify_app_server`. `app.server.on(event, cb)` then - // dispatches against the same handle (the `"on"` arm below). - // Today only `"upgrade"` is stored; bidirectional WebSocket - // upgrade through hyper is the tracked follow-up. - method("fastify", "server", true, None), - method("fastify", "on", true, None), - method("fastify", "method", true, None), - method("fastify", "url", true, None), - // Manifest-consistency catch-up (release-sweep gate). - method("fastify", "type", true, None), - method("fastify", "params", true, None), - method("fastify", "param", true, None), - method("fastify", "query", true, None), - method("fastify", "rawBody", true, None), - method("fastify", "headers", true, None), - method("fastify", "header", true, None), - method("fastify", "user", true, None), - method("fastify", "status", true, None), - method("fastify", "code", true, None), - method("fastify", "send", true, None), - method("fastify", "text", true, None), - method("fastify", "html", true, None), - method("fastify", "redirect", true, None), - method("fastify", "json", true, None), - method("fastify", "body", true, None), method_sig( "mysql2", "createConnection", diff --git a/crates/perry-api-manifest/src/entries/part_2.rs b/crates/perry-api-manifest/src/entries/part_2.rs index 9868491512..d312fcf31c 100644 --- a/crates/perry-api-manifest/src/entries/part_2.rs +++ b/crates/perry-api-manifest/src/entries/part_2.rs @@ -239,54 +239,6 @@ pub(crate) const API_MANIFEST_PART_2: &[ApiEntry] = &[ &[p_any("p0"), p_any("p1")], TypeSpec::Number, ), - // Factory takes an optional input (string | number | undefined) — - // matches the 1-slot NA_JSV dispatch row (js_dayjs_factory). - method_sig( - "dayjs", - "default", - false, - None, - &[ParamSpec::Named { - name: "input", - ty: TypeSpec::Any, - optional: true, - }], - TypeSpec::Any, - ), - method_sig( - "dayjs", - "dayjs", - false, - None, - &[ParamSpec::Named { - name: "input", - ty: TypeSpec::Any, - optional: true, - }], - TypeSpec::Any, - ), - method("dayjs", "format", true, None), - method("dayjs", "year", true, None), - method("dayjs", "month", true, None), - method("dayjs", "date", true, None), - method("dayjs", "day", true, None), - method("dayjs", "hour", true, None), - method("dayjs", "minute", true, None), - method("dayjs", "second", true, None), - method("dayjs", "millisecond", true, None), - method("dayjs", "valueOf", true, None), - method("dayjs", "unix", true, None), - method("dayjs", "toISOString", true, None), - method("dayjs", "add", true, None), - method("dayjs", "subtract", true, None), - method("dayjs", "startOf", true, None), - method("dayjs", "endOf", true, None), - method("dayjs", "isBefore", true, None), - method("dayjs", "isAfter", true, None), - method("dayjs", "isSame", true, None), - method("dayjs", "isValid", true, None), - method("dayjs", "diff", true, None), - method("dayjs", "clone", true, None), // moment factory + instance methods (wired to the same handle-based // date runtime as dayjs; see native_table/dates.rs moment rows). method_sig( diff --git a/crates/perry-api-manifest/src/entries/part_4.rs b/crates/perry-api-manifest/src/entries/part_4.rs index ea6315342a..19e043aada 100644 --- a/crates/perry-api-manifest/src/entries/part_4.rs +++ b/crates/perry-api-manifest/src/entries/part_4.rs @@ -306,9 +306,6 @@ pub(crate) const API_MANIFEST_PART_4: &[ApiEntry] = &[ // --- bignumber.js — alias surface for decimal.js. The wrapper // dispatches to the same perry-ext-decimal implementation. --- class("bignumber.js", "BigNumber"), - // --- node-cron — alias for the cron wrapper. - method("node-cron", "schedule", false, None), - method("node-cron", "validate", false, None), // --- perry/ui constructors + setters. Auto-derivable from // PERRY_UI_TABLE in crates/perry-dispatch/src/lib.rs. The // reverse drift test enforces parity in both directions. --- @@ -710,34 +707,6 @@ pub(crate) const API_MANIFEST_PART_4: &[ApiEntry] = &[ // (module_has_any_entries) and mirror the `redis` alias above. --- class("iovalkey", "Redis"), method("iovalkey", "createClient", false, None), - // --- date-fns — alias for dayjs (well-known routes both to - // perry-ext-dayjs). Surface methods are the date-fns - // functional API exposed by the wrapper. --- - method("date-fns", "format", false, None), - method("date-fns", "parseISO", false, None), - method("date-fns", "addDays", false, None), - method("date-fns", "addMonths", false, None), - method("date-fns", "addYears", false, None), - method("date-fns", "differenceInDays", false, None), - method("date-fns", "differenceInHours", false, None), - method("date-fns", "differenceInMinutes", false, None), - method("date-fns", "isAfter", false, None), - method("date-fns", "isBefore", false, None), - method("date-fns", "startOfDay", false, None), - method("date-fns", "endOfDay", false, None), - // --- rate-limiter-flexible — perry-ext-ratelimit. Surface mirrors - // the npm package's RateLimiterMemory class. Construction is a - // lower_builtin_new arm (js_ratelimit_new_from_options); the - // instance methods dispatch via the NATIVE_MODULE_TABLE rows in - // lower_call/native_table/extras.rs. --- - class("rate-limiter-flexible", "RateLimiterMemory"), - class("rate-limiter-flexible", "RateLimiterAbstract"), - method("rate-limiter-flexible", "consume", true, None), - method("rate-limiter-flexible", "get", true, None), - method("rate-limiter-flexible", "delete", true, None), - method("rate-limiter-flexible", "block", true, None), - method("rate-limiter-flexible", "penalty", true, None), - method("rate-limiter-flexible", "reward", true, None), // --- fetch — well-known alias for perry-ext-fetch. Same surface // as node-fetch (the more common alias above). --- method("fetch", "default", false, None), @@ -1148,23 +1117,4 @@ pub(crate) const API_MANIFEST_PART_4: &[ApiEntry] = &[ property("bun", "semver"), property("bun", "JSONL"), property("bun", "ant"), - // --- qs (issue #8751) --- - // Native nested query-string codec. This keeps Stripe's request encoder - // off qs' legacy get-intrinsic/ES-shims dependency chain. - method_sig( - "qs", - "stringify", - false, - None, - &[p_any("value"), p_any("options")], - TypeSpec::String, - ), - method_sig( - "qs", - "parse", - false, - None, - &[p_str("input"), p_any("options")], - TypeSpec::Any, - ), ]; diff --git a/crates/perry-codegen/src/ext_registry.rs b/crates/perry-codegen/src/ext_registry.rs index 9017d4fdd8..bbcc0cc8e6 100644 --- a/crates/perry-codegen/src/ext_registry.rs +++ b/crates/perry-codegen/src/ext_registry.rs @@ -663,7 +663,6 @@ const EXT_PREFIX_REGISTRY: &[(&str, &str)] = &[ // Bun runtime transpilation/build subset shares the pinned SWC wrapper. ("js_bun_transpiler_", "typescript"), ("js_bun_build", "typescript"), - ("js_qs_", "qs"), ]; /// Process-wide collector of provider keys observed during codegen. @@ -1262,8 +1261,9 @@ mod tests { ("js_node_forge_create_certificate", "node-forge"), ("js_parcel_watcher_subscribe", "@parcel/watcher"), ("js_parcel_watcher_get_events_since", "@parcel/watcher"), - ("js_qs_stringify", "qs"), - ("js_qs_parse", "qs"), + ("js_typescript_transpile_module", "typescript"), + ("js_bun_transpiler_new", "typescript"), + ("js_bun_build", "typescript"), ] { assert_symbol_routes_to(symbol, OwnerKind::WellKnown(binding)); } diff --git a/crates/perry-codegen/src/lower_call/builtin.rs b/crates/perry-codegen/src/lower_call/builtin.rs index d976f3e675..1714214fca 100644 --- a/crates/perry-codegen/src/lower_call/builtin.rs +++ b/crates/perry-codegen/src/lower_call/builtin.rs @@ -123,8 +123,7 @@ pub(super) fn lower_builtin_new<'a>( "Redis" => Some(&["ioredis", "redis", "iovalkey"]), "MongoClient" => Some(&["mongodb"]), "Decimal" => Some(&["decimal.js"]), - "RateLimiterMemory" => Some(&["rate-limiter-flexible"]), - "CronJob" => Some(&["cron", "node-cron"]), + "CronJob" => Some(&["cron"]), "Transpiler" => Some(&["bun"]), _ => None, }; @@ -828,26 +827,6 @@ pub(super) fn lower_builtin_new<'a>( let handle = blk.call(I64, "js_ioredis_new", &[(I64, "0")]); Ok(Some(nanbox_pointer_inline(blk, &handle))) } - // rate-limiter-flexible `new RateLimiterMemory({ points, duration })`. - // Gated on the import source above. The options object crosses as - // raw NaN-box bits (i64) so the runtime parses `points`/`duration` - // by name; missing arg → TAG_UNDEFINED → npm defaults (4 points / - // 1 s). Pre-fix this fell to the js_object_alloc(0,0) placeholder - // and every method call dispatched against `{}`. Instance methods - // (consume/get/delete/block/penalty/reward) are wired in - // NATIVE_MODULE_TABLE for module "rate-limiter-flexible". - "RateLimiterMemory" => { - // #6986: `options` was held across the discard loop's lowering. - let options = adopt_leading_arg_discard_rest(ctx, args, group)?; - let blk = ctx.block(); - let options_bits = blk.bitcast_double_to_i64(&options); - let handle = blk.call( - I64, - "js_ratelimit_new_from_options", - &[(I64, &options_bits)], - ); - Ok(Some(nanbox_pointer_inline(blk, &handle))) - } // npm `cron` package: `new CronJob(cronTime, onTick, onComplete?, // start?)`. Gated on the import source above. Unlike node-cron's // `schedule()` factory (which auto-starts), a CronJob only begins diff --git a/crates/perry-codegen/src/lower_call/native_module_dispatch.rs b/crates/perry-codegen/src/lower_call/native_module_dispatch.rs index fe32b4b5b1..721a49c5f6 100644 --- a/crates/perry-codegen/src/lower_call/native_module_dispatch.rs +++ b/crates/perry-codegen/src/lower_call/native_module_dispatch.rs @@ -405,39 +405,6 @@ mod ffi_return_type_tests { assert!(returns_string); // also true — but i64_str branch fires first } - #[test] - fn fastify_header_routes_by_receiver_class() { - // Regression: the request getter `request.header(name)` and the reply - // setter `reply.header(name, value)` are distinct rows in FASTIFY_ROWS - // sharing method "header". When both were `class_filter: None`, the - // arity-blind generic pass matched BOTH to whichever row came first - // (the request getter), so `reply.header(...)` silently no-op'd. Tagging - // them Some("Request") / Some("Reply") lets the exact-class first pass - // route each by receiver. (Fastify handler params are registered as - // ("fastify","Request") / ("fastify","Reply") by position.) - let req = super::native_module_lookup("fastify", true, "header", Some("Request")) - .expect("request.header must resolve"); - assert_eq!( - req.runtime, "js_fastify_req_header", - "request.header(name) must route to the 1-arg getter" - ); - assert_eq!(req.args.len(), 1, "request getter takes one arg"); - - let reply = super::native_module_lookup("fastify", true, "header", Some("Reply")) - .expect("reply.header must resolve"); - assert_eq!( - reply.runtime, "js_fastify_reply_header", - "reply.header(name, value) must route to the 2-arg setter, not the getter" - ); - assert_eq!(reply.args.len(), 2, "reply setter takes two args"); - - // The two receiver classes must NOT collide on the same runtime symbol. - assert_ne!( - req.runtime, reply.runtime, - "request and reply `.header` must resolve to different runtime symbols" - ); - } - #[test] fn net_socket_write_and_end_match_their_float_ffi_abi() { for method in ["write", "end"] { diff --git a/crates/perry-codegen/src/lower_call/native_table/dates.rs b/crates/perry-codegen/src/lower_call/native_table/dates.rs index e8d27aa7fd..4ecbf10a56 100644 --- a/crates/perry-codegen/src/lower_call/native_table/dates.rs +++ b/crates/perry-codegen/src/lower_call/native_table/dates.rs @@ -1,348 +1,6 @@ use super::*; pub(super) const DATES_ROWS: &[NativeModSig] = &[ - // ========== dayjs ========== - // Factory: `import dayjs from 'dayjs'; dayjs()` → method:"default". - // Named import: `import { dayjs } from 'dayjs'; dayjs()` → method:"dayjs". - // The factory takes the raw NaN-boxed first arg (NA_JSV; pads to - // TAG_UNDEFINED when absent): undefined → now, string → ISO parse, - // number → epoch ms. The old rows called js_dayjs_now with args:&[] - // so `dayjs('2024-01-15')` silently ignored its argument (the extra - // arg was passed as a trailing f64 the runtime never read). - // Instance methods: handle is a small i64 stored in f64 bits; unbox_to_i64 - // does bitcast+mask which is identity for small values, so has_receiver:true works. - // dayjs handle args (isBefore/isAfter/diff) use NA_JSV (bitcast, no mask). - NativeModSig { - module: "dayjs", - has_receiver: false, - method: "default", - class_filter: None, - runtime: "js_dayjs_factory", - args: &[NA_JSV], - ret: NR_F64, - }, - NativeModSig { - module: "dayjs", - has_receiver: false, - method: "dayjs", - class_filter: None, - runtime: "js_dayjs_factory", - args: &[NA_JSV], - ret: NR_F64, - }, - NativeModSig { - module: "dayjs", - has_receiver: true, - method: "format", - class_filter: None, - runtime: "js_dayjs_format", - args: &[NA_STR], - ret: NR_STR, - }, - NativeModSig { - module: "dayjs", - has_receiver: true, - method: "year", - class_filter: None, - runtime: "js_dayjs_year", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "dayjs", - has_receiver: true, - method: "month", - class_filter: None, - runtime: "js_dayjs_month", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "dayjs", - has_receiver: true, - method: "date", - class_filter: None, - runtime: "js_dayjs_date", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "dayjs", - has_receiver: true, - method: "day", - class_filter: None, - runtime: "js_dayjs_day", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "dayjs", - has_receiver: true, - method: "hour", - class_filter: None, - runtime: "js_dayjs_hour", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "dayjs", - has_receiver: true, - method: "minute", - class_filter: None, - runtime: "js_dayjs_minute", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "dayjs", - has_receiver: true, - method: "second", - class_filter: None, - runtime: "js_dayjs_second", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "dayjs", - has_receiver: true, - method: "millisecond", - class_filter: None, - runtime: "js_dayjs_millisecond", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "dayjs", - has_receiver: true, - method: "valueOf", - class_filter: None, - runtime: "js_dayjs_value_of", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "dayjs", - has_receiver: true, - method: "unix", - class_filter: None, - runtime: "js_dayjs_unix", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "dayjs", - has_receiver: true, - method: "toISOString", - class_filter: None, - runtime: "js_dayjs_to_iso_string", - args: &[], - ret: NR_STR, - }, - NativeModSig { - module: "dayjs", - has_receiver: true, - method: "add", - class_filter: None, - runtime: "js_dayjs_add", - args: &[NA_F64, NA_STR], - ret: NR_F64, - }, - NativeModSig { - module: "dayjs", - has_receiver: true, - method: "subtract", - class_filter: None, - runtime: "js_dayjs_subtract", - args: &[NA_F64, NA_STR], - ret: NR_F64, - }, - NativeModSig { - module: "dayjs", - has_receiver: true, - method: "startOf", - class_filter: None, - runtime: "js_dayjs_start_of", - args: &[NA_STR], - ret: NR_F64, - }, - NativeModSig { - module: "dayjs", - has_receiver: true, - method: "endOf", - class_filter: None, - runtime: "js_dayjs_end_of", - args: &[NA_STR], - ret: NR_F64, - }, - NativeModSig { - module: "dayjs", - has_receiver: true, - method: "isBefore", - class_filter: None, - runtime: "js_dayjs_is_before", - args: &[NA_JSV], - ret: NR_F64, - }, - NativeModSig { - module: "dayjs", - has_receiver: true, - method: "isAfter", - class_filter: None, - runtime: "js_dayjs_is_after", - args: &[NA_JSV], - ret: NR_F64, - }, - NativeModSig { - module: "dayjs", - has_receiver: true, - method: "isSame", - class_filter: None, - runtime: "js_dayjs_is_same", - args: &[NA_JSV], - ret: NR_F64, - }, - NativeModSig { - module: "dayjs", - has_receiver: true, - method: "isValid", - class_filter: None, - runtime: "js_dayjs_is_valid", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "dayjs", - has_receiver: true, - method: "diff", - class_filter: None, - runtime: "js_dayjs_diff", - args: &[NA_JSV, NA_STR], - ret: NR_F64, - }, - NativeModSig { - module: "dayjs", - has_receiver: true, - method: "clone", - class_filter: None, - runtime: "js_dayjs_value_of", - args: &[], - ret: NR_F64, - }, - // ========== date-fns ========== - // date-fns exports free functions: `format(date, pattern)`, - // `addDays(date, n)`, etc. The first argument is a Date (NaN-boxed - // f64 timestamp from `new Date(...)`). The manifest entries surface - // these as receiver-less NativeMethodCalls on module "date-fns". - // Without these rows the dispatch returns None and the call falls - // through to undefined. Refs date-fns format() blocker. - NativeModSig { - module: "date-fns", - has_receiver: false, - method: "format", - class_filter: None, - runtime: "js_datefns_format", - args: &[NA_F64, NA_STR], - ret: NR_STR, - }, - NativeModSig { - module: "date-fns", - has_receiver: false, - method: "parseISO", - class_filter: None, - runtime: "js_datefns_parse_iso", - args: &[NA_STR], - ret: NR_F64, - }, - NativeModSig { - module: "date-fns", - has_receiver: false, - method: "addDays", - class_filter: None, - runtime: "js_datefns_add_days", - args: &[NA_F64, NA_F64], - ret: NR_F64, - }, - NativeModSig { - module: "date-fns", - has_receiver: false, - method: "addMonths", - class_filter: None, - runtime: "js_datefns_add_months", - args: &[NA_F64, NA_F64], - ret: NR_F64, - }, - NativeModSig { - module: "date-fns", - has_receiver: false, - method: "addYears", - class_filter: None, - runtime: "js_datefns_add_years", - args: &[NA_F64, NA_F64], - ret: NR_F64, - }, - NativeModSig { - module: "date-fns", - has_receiver: false, - method: "differenceInDays", - class_filter: None, - runtime: "js_datefns_difference_in_days", - args: &[NA_F64, NA_F64], - ret: NR_F64, - }, - NativeModSig { - module: "date-fns", - has_receiver: false, - method: "differenceInHours", - class_filter: None, - runtime: "js_datefns_difference_in_hours", - args: &[NA_F64, NA_F64], - ret: NR_F64, - }, - NativeModSig { - module: "date-fns", - has_receiver: false, - method: "differenceInMinutes", - class_filter: None, - runtime: "js_datefns_difference_in_minutes", - args: &[NA_F64, NA_F64], - ret: NR_F64, - }, - NativeModSig { - module: "date-fns", - has_receiver: false, - method: "isAfter", - class_filter: None, - runtime: "js_datefns_is_after", - args: &[NA_F64, NA_F64], - ret: NR_F64, - }, - NativeModSig { - module: "date-fns", - has_receiver: false, - method: "isBefore", - class_filter: None, - runtime: "js_datefns_is_before", - args: &[NA_F64, NA_F64], - ret: NR_F64, - }, - NativeModSig { - module: "date-fns", - has_receiver: false, - method: "startOfDay", - class_filter: None, - runtime: "js_datefns_start_of_day", - args: &[NA_F64], - ret: NR_F64, - }, - NativeModSig { - module: "date-fns", - has_receiver: false, - method: "endOfDay", - class_filter: None, - runtime: "js_datefns_end_of_day", - args: &[NA_F64], - ret: NR_F64, - }, // ========== moment ========== // Same handle scheme as dayjs: the factory returns // f64::from_bits(handle) and instance methods take the handle as an diff --git a/crates/perry-codegen/src/lower_call/native_table/extras.rs b/crates/perry-codegen/src/lower_call/native_table/extras.rs index e9d53bd11d..93271fc84a 100644 --- a/crates/perry-codegen/src/lower_call/native_table/extras.rs +++ b/crates/perry-codegen/src/lower_call/native_table/extras.rs @@ -387,67 +387,6 @@ pub(super) const EXTRAS_ROWS: &[NativeModSig] = &[ args: &[], ret: NR_F64, }, - // ========== rate-limiter-flexible ========== - // `new RateLimiterMemory({...})` is constructed by the - // lower_builtin_new arm (js_ratelimit_new_from_options); these rows - // wire the instance methods. All async methods resolve (or, for an - // exceeded quota, reject) a RateLimiterRes-shaped object. The key is - // NA_STR (numbers stringify, matching npm's string keys); points is - // NA_F64 and pads to undefined → runtime default 1. - NativeModSig { - module: "rate-limiter-flexible", - has_receiver: true, - method: "consume", - class_filter: None, - runtime: "js_ratelimit_consume", - args: &[NA_STR, NA_F64], - ret: NR_PROMISE, - }, - NativeModSig { - module: "rate-limiter-flexible", - has_receiver: true, - method: "get", - class_filter: None, - runtime: "js_ratelimit_get", - args: &[NA_STR], - ret: NR_PROMISE, - }, - NativeModSig { - module: "rate-limiter-flexible", - has_receiver: true, - method: "delete", - class_filter: None, - runtime: "js_ratelimit_delete", - args: &[NA_STR], - ret: NR_PROMISE, - }, - NativeModSig { - module: "rate-limiter-flexible", - has_receiver: true, - method: "block", - class_filter: None, - runtime: "js_ratelimit_block", - args: &[NA_STR, NA_F64], - ret: NR_PROMISE, - }, - NativeModSig { - module: "rate-limiter-flexible", - has_receiver: true, - method: "penalty", - class_filter: None, - runtime: "js_ratelimit_penalty", - args: &[NA_STR, NA_F64], - ret: NR_PROMISE, - }, - NativeModSig { - module: "rate-limiter-flexible", - has_receiver: true, - method: "reward", - class_filter: None, - runtime: "js_ratelimit_reward", - args: &[NA_STR, NA_F64], - ret: NR_PROMISE, - }, // ========== node-pty (#6563: runtime-native pty) ========== // `spawn(file, args, options)` → IPty (already NaN-boxed by the runtime). // NA_JSV keeps `undefined` slots distinguishable from numeric 0 across diff --git a/crates/perry-codegen/src/lower_call/native_table/fastify.rs b/crates/perry-codegen/src/lower_call/native_table/fastify.rs deleted file mode 100644 index 23980b36db..0000000000 --- a/crates/perry-codegen/src/lower_call/native_table/fastify.rs +++ /dev/null @@ -1,394 +0,0 @@ -use super::*; - -pub(super) const FASTIFY_ROWS: &[NativeModSig] = &[ - // ========== Fastify HTTP Framework ========== - NativeModSig { - module: "fastify", - has_receiver: false, - method: "default", - class_filter: None, - runtime: "js_fastify_create_with_opts", - args: &[NA_F64], - ret: NR_HANDLE_ID, - }, - NativeModSig { - module: "fastify", - has_receiver: true, - method: "get", - class_filter: None, - runtime: "js_fastify_get", - args: &[NA_STR, NA_PTR], - ret: NR_I32, - }, - NativeModSig { - module: "fastify", - has_receiver: true, - method: "post", - class_filter: None, - runtime: "js_fastify_post", - args: &[NA_STR, NA_PTR], - ret: NR_I32, - }, - NativeModSig { - module: "fastify", - has_receiver: true, - method: "put", - class_filter: None, - runtime: "js_fastify_put", - args: &[NA_STR, NA_PTR], - ret: NR_I32, - }, - NativeModSig { - module: "fastify", - has_receiver: true, - method: "delete", - class_filter: None, - runtime: "js_fastify_delete", - args: &[NA_STR, NA_PTR], - ret: NR_I32, - }, - NativeModSig { - module: "fastify", - has_receiver: true, - method: "patch", - class_filter: None, - runtime: "js_fastify_patch", - args: &[NA_STR, NA_PTR], - ret: NR_I32, - }, - NativeModSig { - module: "fastify", - has_receiver: true, - method: "head", - class_filter: None, - runtime: "js_fastify_head", - args: &[NA_STR, NA_PTR], - ret: NR_I32, - }, - NativeModSig { - module: "fastify", - has_receiver: true, - method: "options", - class_filter: None, - runtime: "js_fastify_options", - args: &[NA_STR, NA_PTR], - ret: NR_I32, - }, - NativeModSig { - module: "fastify", - has_receiver: true, - method: "all", - class_filter: None, - runtime: "js_fastify_all", - args: &[NA_STR, NA_PTR], - ret: NR_I32, - }, - NativeModSig { - module: "fastify", - has_receiver: true, - method: "route", - class_filter: None, - runtime: "js_fastify_route", - args: &[NA_STR, NA_STR, NA_PTR], - ret: NR_I32, - }, - NativeModSig { - module: "fastify", - has_receiver: true, - method: "addHook", - class_filter: None, - runtime: "js_fastify_add_hook", - args: &[NA_STR, NA_PTR], - ret: NR_I32, - }, - NativeModSig { - module: "fastify", - has_receiver: true, - method: "setErrorHandler", - class_filter: None, - runtime: "js_fastify_set_error_handler", - args: &[NA_PTR], - ret: NR_I32, - }, - NativeModSig { - module: "fastify", - has_receiver: true, - method: "register", - class_filter: None, - runtime: "js_fastify_register", - args: &[NA_PTR, NA_F64], - ret: NR_I32, - }, - NativeModSig { - module: "fastify", - has_receiver: true, - method: "listen", - class_filter: None, - runtime: "js_fastify_listen", - args: &[NA_F64, NA_PTR], - ret: NR_VOID, - }, - // `app.close()` — shut down every server bound to this - // FastifyApp. Pre-fix, this method had no entry in the dispatch - // table at all, so codegen for the `NativeMethodCall` shape fell - // through to the "unknown native method" arm and emitted a no-op - // 0.0 return. With `listen()` now non-blocking, the program - // doesn't exit until something marks the server as no-longer- - // listening — `app.close()` is how user code does that. The - // runtime fn walks the handle registry for matching - // `FastifyServerHandle` rows and clears the listening flag. - NativeModSig { - module: "fastify", - has_receiver: true, - method: "close", - class_filter: None, - runtime: "js_fastify_app_close", - args: &[], - ret: NR_VOID, - }, - // #1113 — `app.server` (property access, lowered to a zero-arg - // NativeMethodCall by the HIR property-as-method path). Pre-fix, - // this fell through to the "unknown native method" sentinel - // (`double 0.0`), so `typeof app.server` was `"number"` and - // `app.server.on("upgrade", …)` threw `(number).on is not a - // function` at boot. Returns the same FastifyApp handle id - // pointer-tagged (NR_PTR) so `typeof` resolves to `"object"` and - // `.on(…)` routes through HANDLE_METHOD_DISPATCH back into the - // FastifyApp arm. See `js_fastify_app_server` in - // perry-ext-fastify (crates/perry-ext-fastify/src/app.rs) for the - // rationale — fastify is served by that external crate (the in-stdlib - // adapter was removed). - NativeModSig { - module: "fastify", - has_receiver: true, - method: "server", - class_filter: None, - runtime: "js_fastify_app_server", - args: &[], - ret: NR_HANDLE_ID, - }, - // #1113 — `app.server.on(event, cb)`. `app.server` returns the - // FastifyApp handle (pointer-tagged), so `.on(…)` lowers as a - // 2-arg NativeMethodCall on the same module. The runtime fn - // records the callback for the recognised event names - // (currently just `"upgrade"`); other names are silently - // accepted so handlers like `app.server.on("error", …)` - // registered at boot don't crash. Full EventEmitter parity is a - // tracked follow-up. - NativeModSig { - module: "fastify", - has_receiver: true, - method: "on", - class_filter: None, - runtime: "js_fastify_app_on", - args: &[NA_STR, NA_PTR], - ret: NR_VOID, - }, - // Fastify request methods - NativeModSig { - module: "fastify", - has_receiver: true, - method: "method", - class_filter: None, - runtime: "js_fastify_req_method", - args: &[], - ret: NR_STR, - }, - NativeModSig { - module: "fastify", - has_receiver: true, - method: "url", - class_filter: None, - runtime: "js_fastify_req_url", - args: &[], - ret: NR_STR, - }, - NativeModSig { - module: "fastify", - has_receiver: true, - method: "params", - class_filter: None, - // Returns the parsed path-params object (e.g. `{id: "42"}` for /users/:id), - // not the raw JSON string — `request.params.id` must be the value, not - // undefined. `js_fastify_req_params` (string) is still available via - // the lower-level FFI but isn't reachable from TypeScript. - runtime: "js_fastify_req_params_object", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "fastify", - has_receiver: true, - method: "param", - class_filter: None, - runtime: "js_fastify_req_param", - args: &[NA_JSV], - ret: NR_STR, - }, - NativeModSig { - module: "fastify", - has_receiver: true, - method: "query", - class_filter: None, - runtime: "js_fastify_req_query_object", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "fastify", - has_receiver: true, - method: "rawBody", - class_filter: None, - runtime: "js_fastify_req_body", - args: &[], - ret: NR_STR, - }, - NativeModSig { - module: "fastify", - has_receiver: true, - method: "headers", - class_filter: None, - runtime: "js_fastify_req_headers", - args: &[], - ret: NR_JS_VALUE, - }, - NativeModSig { - module: "fastify", - has_receiver: true, - method: "header", - // Receiver-scoped so `request.header(name)` (1-arg getter) and - // `reply.header(name, value)` (2-arg setter) don't collide: both - // rows previously had `class_filter: None`, so the arity-blind - // first-match-wins generic lookup routed BOTH at this request - // getter and `reply.header` silently no-op'd (`Location` / CSP - // headers set via the method form evaporated). Fastify handler - // params are registered as ("fastify","Request") / ("fastify", - // "Reply") by position (`pre_scan_fastify_handler_params`), so the - // matcher's exact-class first pass now disambiguates them. - class_filter: Some("Request"), - runtime: "js_fastify_req_header", - args: &[NA_JSV], - ret: NR_STR, - }, - NativeModSig { - module: "fastify", - has_receiver: true, - method: "user", - class_filter: None, - runtime: "js_fastify_req_get_user_data", - args: &[], - ret: NR_F64, - }, - // Fastify reply methods - NativeModSig { - module: "fastify", - has_receiver: true, - method: "status", - class_filter: None, - runtime: "js_fastify_reply_status", - args: &[NA_F64], - ret: NR_HANDLE_ID, - }, - // `reply.code(N)` is an alias for `reply.status(N)` in npm Fastify. Without - // this row, `reply.code(201)` silently no-op'd and the HTTP status stayed 200. - NativeModSig { - module: "fastify", - has_receiver: true, - method: "code", - class_filter: None, - runtime: "js_fastify_reply_status", - args: &[NA_F64], - ret: NR_HANDLE_ID, - }, - NativeModSig { - module: "fastify", - has_receiver: true, - method: "send", - class_filter: None, - runtime: "js_fastify_reply_send", - args: &[NA_F64], - ret: NR_I32, - }, - // `reply.header(name, value)` — chainable. Without this dispatch - // entry, every `reply.header(...)` call silently no-op'd; the runtime - // function existed in `runtime_decls.rs` but no NativeModSig routed - // user code at it. CORS hooks, Cache-Control, and content-type - // overrides all evaporated. - // - // `ret: NR_HANDLE_ID` is critical — the Rust impl returns `Handle` (i64). - // Previously `NR_F64` caused chained `.header(...).send(...)` to read - // an uninitialized XMM0/D0 register as the receiver, producing - // `(number).send is not a function` errors (#1048). - NativeModSig { - module: "fastify", - has_receiver: true, - method: "header", - // Receiver-scoped (see the request `.header` getter row above): - // tagging this reply setter `Some("Reply")` lets the matcher's - // exact-class first pass pick it for `reply.header(name, value)` - // instead of the arity-blind generic pass falling through to the - // request getter (which silently dropped the set header). - class_filter: Some("Reply"), - runtime: "js_fastify_reply_header", - args: &[NA_JSV, NA_JSV], - ret: NR_HANDLE_ID, - }, - // `reply.type(value)` — Fastify alias for setting `content-type`. - // Routes to `js_fastify_reply_type` (thin wrapper over reply_header). - // `ret: NR_HANDLE_ID` for the same reason as `reply.header` above (#1048). - NativeModSig { - module: "fastify", - has_receiver: true, - method: "type", - class_filter: None, - runtime: "js_fastify_reply_type", - args: &[NA_JSV], - ret: NR_HANDLE_ID, - }, - // Fastify context methods (Hono-style) - NativeModSig { - module: "fastify", - has_receiver: true, - method: "text", - class_filter: None, - runtime: "js_fastify_ctx_text", - args: &[NA_JSV, NA_F64], - ret: NR_F64, - }, - NativeModSig { - module: "fastify", - has_receiver: true, - method: "html", - class_filter: None, - runtime: "js_fastify_ctx_html", - args: &[NA_JSV, NA_F64], - ret: NR_F64, - }, - NativeModSig { - module: "fastify", - has_receiver: true, - method: "redirect", - class_filter: None, - runtime: "js_fastify_ctx_redirect", - args: &[NA_JSV, NA_F64], - ret: NR_F64, - }, - NativeModSig { - module: "fastify", - has_receiver: true, - method: "json", - class_filter: None, - runtime: "js_fastify_ctx_json", - args: &[NA_F64, NA_F64], - ret: NR_F64, - }, - NativeModSig { - module: "fastify", - has_receiver: true, - method: "body", - class_filter: None, - runtime: "js_fastify_req_json", - args: &[], - ret: NR_F64, - }, -]; diff --git a/crates/perry-codegen/src/lower_call/native_table/mod.rs b/crates/perry-codegen/src/lower_call/native_table/mod.rs index 18cfadf122..35bb315df7 100644 --- a/crates/perry-codegen/src/lower_call/native_table/mod.rs +++ b/crates/perry-codegen/src/lower_call/native_table/mod.rs @@ -18,7 +18,6 @@ mod databases; mod dates; mod events_dispatch_parity_tests; mod extras; -mod fastify; mod http_client; mod http_http2; mod http_server; @@ -33,7 +32,6 @@ mod node_dns; mod node_domain; mod node_misc; mod parcel_watcher; -mod qs; mod thread_lodash; mod tls_events; mod tui; @@ -180,7 +178,6 @@ pub(super) static NATIVE_MODULE_TABLE: LazyLock> = LazyLock::n v.extend_from_slice(node_core_util::NODE_CORE_UTIL_ROWS); v.extend_from_slice(node_dns::NODE_DNS_ROWS); v.extend_from_slice(node_domain::NODE_DOMAIN_ROWS); - v.extend_from_slice(fastify::FASTIFY_ROWS); v.extend_from_slice(databases::DATABASES_ROWS); v.extend_from_slice(ws_events::WS_EVENTS_ROWS); v.extend_from_slice(net_events::NET_EVENTS_ROWS); @@ -194,7 +191,6 @@ pub(super) static NATIVE_MODULE_TABLE: LazyLock> = LazyLock::n v.extend_from_slice(media::MEDIA_ROWS); v.extend_from_slice(native_profile::NATIVE_PROFILE_ROWS); v.extend_from_slice(parcel_watcher::PARCEL_WATCHER_ROWS); - v.extend_from_slice(qs::QS_ROWS); v.extend_from_slice(tui::TUI_ROWS); v.extend_from_slice(typescript::TYPESCRIPT_ROWS); v.extend_from_slice(yoga::YOGA_ROWS); diff --git a/crates/perry-codegen/src/lower_call/native_table/qs.rs b/crates/perry-codegen/src/lower_call/native_table/qs.rs deleted file mode 100644 index a6fefc9562..0000000000 --- a/crates/perry-codegen/src/lower_call/native_table/qs.rs +++ /dev/null @@ -1,22 +0,0 @@ -use super::*; - -pub(super) const QS_ROWS: &[NativeModSig] = &[ - NativeModSig { - module: "qs", - has_receiver: false, - method: "stringify", - class_filter: None, - runtime: "js_qs_stringify", - args: &[NA_F64, NA_F64], - ret: NR_STR, - }, - NativeModSig { - module: "qs", - has_receiver: false, - method: "parse", - class_filter: None, - runtime: "js_qs_parse", - args: &[NA_STR, NA_F64], - ret: NR_OBJ_FROM_JSON_STR, - }, -]; diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/streams_events.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/streams_events.rs index a7b765f0ce..9b7c51395c 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/streams_events.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/streams_events.rs @@ -3,7 +3,7 @@ //! (extracted from stdlib_ffi.rs). use crate::module::LlModule; -use crate::types::{DOUBLE, I32, I64, PTR, VOID}; +use crate::types::{DOUBLE, I32, I64, PTR}; pub(crate) fn declare_streams_events(module: &mut LlModule) { // ========== node:stream stubs (issue #631) ========== @@ -222,75 +222,8 @@ pub(crate) fn declare_streams_events(module: &mut LlModule) { &[DOUBLE, DOUBLE, DOUBLE, DOUBLE], ); - // ========== Fastify ========== - module.declare_function("js_fastify_add_hook", I32, &[I64, I64, I64]); - module.declare_function("js_fastify_all", I32, &[I64, I64, I64]); - module.declare_function("js_fastify_create", I64, &[]); - module.declare_function("js_fastify_create_with_opts", I64, &[DOUBLE]); - module.declare_function("js_fastify_ctx_html", DOUBLE, &[I64, I64, DOUBLE]); - module.declare_function("js_fastify_ctx_json", DOUBLE, &[I64, DOUBLE, DOUBLE]); - module.declare_function("js_fastify_ctx_redirect", DOUBLE, &[I64, I64, DOUBLE]); - module.declare_function("js_fastify_ctx_text", DOUBLE, &[I64, I64, DOUBLE]); - module.declare_function("js_fastify_delete", I32, &[I64, I64, I64]); - module.declare_function("js_fastify_get", I32, &[I64, I64, I64]); - module.declare_function("js_fastify_head", I32, &[I64, I64, I64]); - module.declare_function("js_fastify_listen", VOID, &[I64, DOUBLE, I64]); - // `app.close()` — shuts every server bound to this FastifyApp. - // Declared so the dispatch-table arm in lower_call.rs can emit a - // call site. Returns void (Rust signature returns bool, but the - // codegen-side caller discards the result). - module.declare_function("js_fastify_app_close", VOID, &[I64]); - // #1113: `app.server` getter — returns the same FastifyApp handle - // id (raw i64). The `NATIVE_MODULE_TABLE` arm at - // `module: "fastify", method: "server"` declares the return as - // NR_PTR so the codegen NaN-boxes it with POINTER_TAG before it - // reaches the JS world, making `typeof app.server === "object"` - // and routing `.on(…)` back into the FastifyApp method dispatch. - module.declare_function("js_fastify_app_server", I64, &[I64]); - // #1113: `app.server.on(event, cb)` — registers an event handler. - // `event` arrives as a NaN-boxed string pointer (i64); `cb` as a - // raw ClosureHeader pointer (i64). Returns void at the C ABI - // (the FastifyApp dispatch wraps it to return the handle for - // chaining, matching Node's `EventEmitter.on` contract). - module.declare_function("js_fastify_app_on", VOID, &[I64, I64, I64]); - module.declare_function("js_fastify_options", I32, &[I64, I64, I64]); - module.declare_function("js_fastify_patch", I32, &[I64, I64, I64]); - module.declare_function("js_fastify_post", I32, &[I64, I64, I64]); - module.declare_function("js_fastify_put", I32, &[I64, I64, I64]); - module.declare_function("js_fastify_register", I32, &[I64, I64, DOUBLE]); - module.declare_function("js_fastify_reply_header", I64, &[I64, I64, I64]); - module.declare_function("js_fastify_reply_send", I32, &[I64, DOUBLE]); - module.declare_function("js_fastify_reply_status", I64, &[I64, DOUBLE]); - module.declare_function("js_fastify_reply_type", I64, &[I64, I64]); - module.declare_function("js_fastify_req_body", I64, &[I64]); - module.declare_function("js_fastify_req_get_user_data", DOUBLE, &[I64]); - module.declare_function("js_fastify_req_header", I64, &[I64, I64]); - module.declare_function("js_fastify_req_headers", I64, &[I64]); - module.declare_function("js_fastify_req_json", DOUBLE, &[I64]); - module.declare_function("js_fastify_req_method", I64, &[I64]); - module.declare_function("js_fastify_req_param", I64, &[I64, I64]); - module.declare_function("js_fastify_req_params", I64, &[I64]); - module.declare_function("js_fastify_req_query", I64, &[I64]); - module.declare_function("js_fastify_req_query_object", DOUBLE, &[I64]); - module.declare_function("js_fastify_req_set_user_data", VOID, &[I64, DOUBLE]); - module.declare_function("js_fastify_req_url", I64, &[I64]); - module.declare_function("js_fastify_route", I32, &[I64, I64, I64, I64]); - module.declare_function("js_fastify_set_error_handler", I32, &[I64, I64]); - // ========== Nodemailer ========== module.declare_function("js_nodemailer_create_transport", DOUBLE, &[I64]); module.declare_function("js_nodemailer_send_mail", I64, &[I64, I64]); module.declare_function("js_nodemailer_verify", I64, &[I64]); - - // ========== Rate limit ========== - module.declare_function("js_ratelimit_block", I64, &[I64, I64, DOUBLE]); - module.declare_function("js_ratelimit_consume", I64, &[I64, I64, DOUBLE]); - module.declare_function("js_ratelimit_create", I64, &[I64]); - module.declare_function("js_ratelimit_delete", I64, &[I64, I64]); - module.declare_function("js_ratelimit_get", I64, &[I64, I64]); - // `new RateLimiterMemory({...})` ctor arm in lower_call/builtin.rs — - // takes the raw NaN-boxed options object bits. - module.declare_function("js_ratelimit_new_from_options", I64, &[I64]); - module.declare_function("js_ratelimit_penalty", I64, &[I64, I64, DOUBLE]); - module.declare_function("js_ratelimit_reward", I64, &[I64, I64, DOUBLE]); } diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/utilities.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/utilities.rs index cf739da7f8..369dbbdaea 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/utilities.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/utilities.rs @@ -1,5 +1,5 @@ //! Utility-package stdlib FFI declarations (extracted from stdlib_ffi.rs): -//! @perryts/pdf, commander, dotenv, date libs (dayjs/datefns/moment), +//! @perryts/pdf, commander, date libs (moment), //! decimal.js, ethers, lodash, lru-cache. use crate::module::LlModule; @@ -35,48 +35,7 @@ pub(crate) fn declare_utilities(module: &mut LlModule) { module.declare_function("js_commander_required_option", I64, &[I64, I64, I64, I64]); module.declare_function("js_commander_version", I64, &[I64, I64]); - // ========== Date libs (dayjs/datefns/moment) ========== - module.declare_function("js_datefns_add_days", DOUBLE, &[DOUBLE, DOUBLE]); - module.declare_function("js_datefns_add_months", DOUBLE, &[DOUBLE, DOUBLE]); - module.declare_function("js_datefns_add_years", DOUBLE, &[DOUBLE, DOUBLE]); - module.declare_function("js_datefns_difference_in_days", DOUBLE, &[DOUBLE, DOUBLE]); - module.declare_function("js_datefns_difference_in_hours", DOUBLE, &[DOUBLE, DOUBLE]); - module.declare_function( - "js_datefns_difference_in_minutes", - DOUBLE, - &[DOUBLE, DOUBLE], - ); - module.declare_function("js_datefns_end_of_day", DOUBLE, &[DOUBLE]); - module.declare_function("js_datefns_format", I64, &[DOUBLE, I64]); - module.declare_function("js_datefns_is_after", DOUBLE, &[DOUBLE, DOUBLE]); - module.declare_function("js_datefns_is_before", DOUBLE, &[DOUBLE, DOUBLE]); - module.declare_function("js_datefns_parse_iso", DOUBLE, &[I64]); - module.declare_function("js_datefns_start_of_day", DOUBLE, &[DOUBLE]); - module.declare_function("js_dayjs_add", DOUBLE, &[I64, DOUBLE, I64]); - module.declare_function("js_dayjs_date", DOUBLE, &[I64]); - module.declare_function("js_dayjs_day", DOUBLE, &[I64]); - module.declare_function("js_dayjs_diff", DOUBLE, &[I64, I64, I64]); - module.declare_function("js_dayjs_end_of", DOUBLE, &[I64, I64]); - module.declare_function("js_dayjs_factory", DOUBLE, &[I64]); - module.declare_function("js_dayjs_format", I64, &[I64, I64]); - module.declare_function("js_dayjs_from_timestamp", DOUBLE, &[DOUBLE]); - module.declare_function("js_dayjs_hour", DOUBLE, &[I64]); - module.declare_function("js_dayjs_is_after", DOUBLE, &[I64, I64]); - module.declare_function("js_dayjs_is_before", DOUBLE, &[I64, I64]); - module.declare_function("js_dayjs_is_same", DOUBLE, &[I64, I64]); - module.declare_function("js_dayjs_is_valid", DOUBLE, &[I64]); - module.declare_function("js_dayjs_millisecond", DOUBLE, &[I64]); - module.declare_function("js_dayjs_minute", DOUBLE, &[I64]); - module.declare_function("js_dayjs_month", DOUBLE, &[I64]); - module.declare_function("js_dayjs_now", DOUBLE, &[]); - module.declare_function("js_dayjs_parse", DOUBLE, &[I64]); - module.declare_function("js_dayjs_second", DOUBLE, &[I64]); - module.declare_function("js_dayjs_start_of", DOUBLE, &[I64, I64]); - module.declare_function("js_dayjs_subtract", DOUBLE, &[I64, DOUBLE, I64]); - module.declare_function("js_dayjs_to_iso_string", I64, &[I64]); - module.declare_function("js_dayjs_unix", DOUBLE, &[I64]); - module.declare_function("js_dayjs_value_of", DOUBLE, &[I64]); - module.declare_function("js_dayjs_year", DOUBLE, &[I64]); + // ========== Date libs (moment) ========== // moment: same handle scheme as dayjs — the factory returns the // handle as f64 bits (DOUBLE), instance methods take the handle as // an I64 first arg. Methods returning a new moment return DOUBLE diff --git a/crates/perry-ext-dayjs/Cargo.toml b/crates/perry-ext-dayjs/Cargo.toml deleted file mode 100644 index 6eb60022f9..0000000000 --- a/crates/perry-ext-dayjs/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "perry-ext-dayjs" -version.workspace = true -edition.workspace = true -license.workspace = true -description = "Native bindings for npm `dayjs` / `date-fns` — uses only `perry-ffi`. Sync, handle-based date arithmetic via `chrono`." - -[lints] -workspace = true - -[lib] -crate-type = ["staticlib", "rlib"] - -[dependencies] -perry-ffi.workspace = true -chrono.workspace = true - -[dev-dependencies] -perry-ffi = { workspace = true, features = ["runtime-link"] } diff --git a/crates/perry-ext-dayjs/src/lib.rs b/crates/perry-ext-dayjs/src/lib.rs deleted file mode 100644 index 4c34ef2b3c..0000000000 --- a/crates/perry-ext-dayjs/src/lib.rs +++ /dev/null @@ -1,642 +0,0 @@ -//! Native bindings for npm `dayjs` / `date-fns` — date parsing, -//! formatting, manipulation, and comparison via `chrono`. Sync, -//! handle-based, uses only perry-ffi v0.5 strings + handles. - -use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveDateTime, TimeZone, Timelike, Utc}; -use perry_ffi::{ - alloc_string, get_handle, read_string, register_handle, Handle, JsString, StringHeader, -}; - -pub struct DayjsHandle { - pub datetime: DateTime, -} - -impl DayjsHandle { - pub fn new(dt: DateTime) -> Self { - Self { datetime: dt } - } -} - -unsafe fn read_str(ptr: *const StringHeader) -> Option { - let handle = JsString::from_raw(ptr as *mut StringHeader); - read_string(handle).map(String::from) -} - -#[inline] -fn handle_to_f64(handle: Handle) -> f64 { - f64::from_bits(handle as u64) -} - -#[inline] -fn f64_to_handle(val: f64) -> Handle { - val.to_bits() as i64 -} - -#[no_mangle] -pub extern "C" fn js_dayjs_now() -> f64 { - handle_to_f64(register_handle(DayjsHandle::new(Utc::now()))) -} - -#[no_mangle] -pub extern "C" fn js_dayjs_from_timestamp(timestamp: f64) -> f64 { - // NaN / ±∞ → Invalid Date (dayjs's invalid sentinel is `0.0`). - if !timestamp.is_finite() { - return 0.0; - } - // Millisecond-safe conversion: `from_timestamp_millis` handles - // negative epochs correctly (the old secs/nanos split truncated - // negative fractions toward zero and saturated `nanos` to 0). - if let Some(dt) = DateTime::from_timestamp_millis(timestamp as i64) { - handle_to_f64(register_handle(DayjsHandle::new(dt))) - } else { - 0.0 - } -} - -/// # Safety -/// `date_str_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_dayjs_parse(date_str_ptr: *const StringHeader) -> f64 { - let date_str = match read_str(date_str_ptr) { - Some(s) => s, - None => return 0.0, - }; - - if let Ok(dt) = DateTime::parse_from_rfc3339(&date_str) { - return handle_to_f64(register_handle(DayjsHandle::new(dt.with_timezone(&Utc)))); - } - if let Ok(naive) = NaiveDate::parse_from_str(&date_str, "%Y-%m-%d") { - let datetime = naive.and_hms_opt(0, 0, 0).unwrap(); - let dt = Utc.from_utc_datetime(&datetime); - return handle_to_f64(register_handle(DayjsHandle::new(dt))); - } - if let Ok(naive) = NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%d %H:%M:%S") { - let dt = Utc.from_utc_datetime(&naive); - return handle_to_f64(register_handle(DayjsHandle::new(dt))); - } - // Offset-less ISO forms ("2024-01-15T10:30:00", optional fraction) — - // dayjs treats these as local time; this runtime is UTC-based so they - // match Node under TZ=UTC. - for fmt in ["%Y-%m-%dT%H:%M:%S%.f", "%Y-%m-%d %H:%M:%S%.f"] { - if let Ok(naive) = NaiveDateTime::parse_from_str(&date_str, fmt) { - let dt = Utc.from_utc_datetime(&naive); - return handle_to_f64(register_handle(DayjsHandle::new(dt))); - } - } - 0.0 -} - -extern "C" { - /// perry-runtime: extract the StringHeader pointer from any - /// string-tagged NaN-boxed value (materializes short strings). - fn js_get_string_pointer_unified(value: f64) -> i64; -} - -/// dayjs(input?) — the factory with its real argument surface. -/// `value_bits` is the raw NaN-boxed first argument (TAG_UNDEFINED when -/// absent). undefined → now; string → ISO parse; number → epoch ms. The -/// dispatch row previously called `js_dayjs_now` with no args, silently -/// ignoring `dayjs('2024-01-15')`'s argument. -/// -/// # Safety -/// `value_bits` must be valid NaN-box bits. -#[no_mangle] -pub unsafe extern "C" fn js_dayjs_factory(value_bits: i64) -> f64 { - use perry_ffi::JsValue; - let bits = value_bits as u64; - let jv = JsValue::from_bits(bits); - if jv.is_any_string() { - let ptr = js_get_string_pointer_unified(f64::from_bits(bits)) as *const StringHeader; - return js_dayjs_parse(ptr); - } - if jv.is_int32() { - return js_dayjs_from_timestamp(jv.to_int32() as f64); - } - if jv.is_number() { - let n = f64::from_bits(bits); - if n.is_finite() { - return js_dayjs_from_timestamp(n); - } - } - js_dayjs_now() -} - -/// # Safety -/// `pattern_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_dayjs_format( - handle: Handle, - pattern_ptr: *const StringHeader, -) -> *mut StringHeader { - let pattern = read_str(pattern_ptr).unwrap_or_else(|| "YYYY-MM-DD".to_string()); - - if let Some(wrapper) = get_handle::(handle) { - let dt = &wrapper.datetime; - let chrono_fmt = pattern - .replace("YYYY", "%Y") - .replace("YY", "%y") - .replace("MM", "%m") - .replace("DD", "%d") - .replace("HH", "%H") - .replace("hh", "%I") - .replace("mm", "%M") - .replace("ss", "%S") - .replace("SSS", "%3f") - .replace("A", "%p") - .replace("a", "%P") - .replace("dddd", "%A") - .replace("ddd", "%a") - .replace("MMMM", "%B") - .replace("MMM", "%b") - .replace("ZZ", "%z") - .replace("Z", "%:z"); - let formatted = dt.format(&chrono_fmt).to_string(); - alloc_string(&formatted).as_raw() - } else { - std::ptr::null_mut() - } -} - -#[no_mangle] -pub extern "C" fn js_dayjs_to_iso_string(handle: Handle) -> *mut StringHeader { - if let Some(wrapper) = get_handle::(handle) { - alloc_string(&wrapper.datetime.to_rfc3339()).as_raw() - } else { - std::ptr::null_mut() - } -} - -#[no_mangle] -pub extern "C" fn js_dayjs_value_of(handle: Handle) -> f64 { - get_handle::(handle) - .map(|w| w.datetime.timestamp_millis() as f64) - .unwrap_or(0.0) -} - -#[no_mangle] -pub extern "C" fn js_dayjs_unix(handle: Handle) -> f64 { - get_handle::(handle) - .map(|w| w.datetime.timestamp() as f64) - .unwrap_or(0.0) -} - -#[no_mangle] -pub extern "C" fn js_dayjs_year(handle: Handle) -> f64 { - get_handle::(handle) - .map(|w| w.datetime.year() as f64) - .unwrap_or(0.0) -} - -#[no_mangle] -pub extern "C" fn js_dayjs_month(handle: Handle) -> f64 { - get_handle::(handle) - .map(|w| (w.datetime.month() - 1) as f64) - .unwrap_or(0.0) -} - -#[no_mangle] -pub extern "C" fn js_dayjs_date(handle: Handle) -> f64 { - get_handle::(handle) - .map(|w| w.datetime.day() as f64) - .unwrap_or(0.0) -} - -#[no_mangle] -pub extern "C" fn js_dayjs_day(handle: Handle) -> f64 { - get_handle::(handle) - .map(|w| w.datetime.weekday().num_days_from_sunday() as f64) - .unwrap_or(0.0) -} - -#[no_mangle] -pub extern "C" fn js_dayjs_hour(handle: Handle) -> f64 { - get_handle::(handle) - .map(|w| w.datetime.hour() as f64) - .unwrap_or(0.0) -} - -#[no_mangle] -pub extern "C" fn js_dayjs_minute(handle: Handle) -> f64 { - get_handle::(handle) - .map(|w| w.datetime.minute() as f64) - .unwrap_or(0.0) -} - -#[no_mangle] -pub extern "C" fn js_dayjs_second(handle: Handle) -> f64 { - get_handle::(handle) - .map(|w| w.datetime.second() as f64) - .unwrap_or(0.0) -} - -#[no_mangle] -pub extern "C" fn js_dayjs_millisecond(handle: Handle) -> f64 { - get_handle::(handle) - .map(|w| (w.datetime.nanosecond() / 1_000_000) as f64) - .unwrap_or(0.0) -} - -/// # Safety -/// `unit_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_dayjs_add( - handle: Handle, - value: f64, - unit_ptr: *const StringHeader, -) -> f64 { - let unit = read_str(unit_ptr).unwrap_or_else(|| "day".to_string()); - let value = value as i64; - - if let Some(wrapper) = get_handle::(handle) { - let dt = &wrapper.datetime; - let new_dt = match unit.as_str() { - "day" | "days" | "d" => *dt + Duration::days(value), - "week" | "weeks" | "w" => *dt + Duration::weeks(value), - "month" | "months" | "M" => { - let year = dt.year(); - let month = dt.month() as i32 + value as i32; - let (new_year, new_month) = if month > 12 { - (year + (month - 1) / 12, ((month - 1) % 12) + 1) - } else if month < 1 { - (year + (month - 12) / 12, 12 + (month % 12)) - } else { - (year, month) - }; - dt.with_year(new_year) - .and_then(|d| d.with_month(new_month as u32)) - .unwrap_or(*dt) - } - "year" | "years" | "y" => dt.with_year(dt.year() + value as i32).unwrap_or(*dt), - "hour" | "hours" | "h" => *dt + Duration::hours(value), - "minute" | "minutes" | "m" => *dt + Duration::minutes(value), - "second" | "seconds" | "s" => *dt + Duration::seconds(value), - "millisecond" | "milliseconds" | "ms" => *dt + Duration::milliseconds(value), - _ => *dt, - }; - handle_to_f64(register_handle(DayjsHandle::new(new_dt))) - } else { - 0.0 - } -} - -/// # Safety -/// `unit_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_dayjs_subtract( - handle: Handle, - value: f64, - unit_ptr: *const StringHeader, -) -> f64 { - js_dayjs_add(handle, -value, unit_ptr) -} - -/// # Safety -/// `unit_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_dayjs_start_of(handle: Handle, unit_ptr: *const StringHeader) -> f64 { - let unit = read_str(unit_ptr).unwrap_or_else(|| "day".to_string()); - - if let Some(wrapper) = get_handle::(handle) { - let dt = &wrapper.datetime; - let new_dt = match unit.as_str() { - "year" | "years" | "y" => Utc.with_ymd_and_hms(dt.year(), 1, 1, 0, 0, 0).unwrap(), - "month" | "months" | "M" => Utc - .with_ymd_and_hms(dt.year(), dt.month(), 1, 0, 0, 0) - .unwrap(), - "day" | "days" | "d" => Utc - .with_ymd_and_hms(dt.year(), dt.month(), dt.day(), 0, 0, 0) - .unwrap(), - "hour" | "hours" | "h" => Utc - .with_ymd_and_hms(dt.year(), dt.month(), dt.day(), dt.hour(), 0, 0) - .unwrap(), - "minute" | "minutes" | "m" => Utc - .with_ymd_and_hms(dt.year(), dt.month(), dt.day(), dt.hour(), dt.minute(), 0) - .unwrap(), - _ => *dt, - }; - handle_to_f64(register_handle(DayjsHandle::new(new_dt))) - } else { - 0.0 - } -} - -/// # Safety -/// `unit_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_dayjs_end_of(handle: Handle, unit_ptr: *const StringHeader) -> f64 { - let unit = read_str(unit_ptr).unwrap_or_else(|| "day".to_string()); - - if let Some(wrapper) = get_handle::(handle) { - let dt = &wrapper.datetime; - let new_dt = match unit.as_str() { - "year" | "years" | "y" => Utc.with_ymd_and_hms(dt.year(), 12, 31, 23, 59, 59).unwrap(), - "month" | "months" | "M" => { - let last_day = NaiveDate::from_ymd_opt(dt.year(), dt.month() + 1, 1) - .unwrap_or_else(|| NaiveDate::from_ymd_opt(dt.year() + 1, 1, 1).unwrap()) - .pred_opt() - .unwrap() - .day(); - Utc.with_ymd_and_hms(dt.year(), dt.month(), last_day, 23, 59, 59) - .unwrap() - } - "day" | "days" | "d" => Utc - .with_ymd_and_hms(dt.year(), dt.month(), dt.day(), 23, 59, 59) - .unwrap(), - "hour" | "hours" | "h" => Utc - .with_ymd_and_hms(dt.year(), dt.month(), dt.day(), dt.hour(), 59, 59) - .unwrap(), - "minute" | "minutes" | "m" => Utc - .with_ymd_and_hms(dt.year(), dt.month(), dt.day(), dt.hour(), dt.minute(), 59) - .unwrap(), - _ => *dt, - }; - handle_to_f64(register_handle(DayjsHandle::new(new_dt))) - } else { - 0.0 - } -} - -/// # Safety -/// `unit_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_dayjs_diff( - handle: Handle, - other_handle: Handle, - unit_ptr: *const StringHeader, -) -> f64 { - let unit = read_str(unit_ptr).unwrap_or_else(|| "millisecond".to_string()); - let w1 = get_handle::(handle); - let w2 = get_handle::(other_handle); - - if let (Some(w1), Some(w2)) = (w1, w2) { - let diff = w1.datetime.signed_duration_since(w2.datetime); - match unit.as_str() { - "year" | "years" | "y" => (diff.num_days() / 365) as f64, - "month" | "months" | "M" => (diff.num_days() / 30) as f64, - "week" | "weeks" | "w" => diff.num_weeks() as f64, - "day" | "days" | "d" => diff.num_days() as f64, - "hour" | "hours" | "h" => diff.num_hours() as f64, - "minute" | "minutes" | "m" => diff.num_minutes() as f64, - "second" | "seconds" | "s" => diff.num_seconds() as f64, - _ => diff.num_milliseconds() as f64, - } - } else { - 0.0 - } -} - -#[no_mangle] -pub extern "C" fn js_dayjs_is_before(handle: Handle, other_handle: Handle) -> f64 { - let w1 = get_handle::(handle); - let w2 = get_handle::(other_handle); - if let (Some(w1), Some(w2)) = (w1, w2) { - if w1.datetime < w2.datetime { - 1.0 - } else { - 0.0 - } - } else { - 0.0 - } -} - -#[no_mangle] -pub extern "C" fn js_dayjs_is_after(handle: Handle, other_handle: Handle) -> f64 { - let w1 = get_handle::(handle); - let w2 = get_handle::(other_handle); - if let (Some(w1), Some(w2)) = (w1, w2) { - if w1.datetime > w2.datetime { - 1.0 - } else { - 0.0 - } - } else { - 0.0 - } -} - -#[no_mangle] -pub extern "C" fn js_dayjs_is_same(handle: Handle, other_handle: Handle) -> f64 { - let w1 = get_handle::(handle); - let w2 = get_handle::(other_handle); - if let (Some(w1), Some(w2)) = (w1, w2) { - if w1.datetime == w2.datetime { - 1.0 - } else { - 0.0 - } - } else { - 0.0 - } -} - -#[no_mangle] -pub extern "C" fn js_dayjs_is_valid(handle: Handle) -> f64 { - if get_handle::(handle).is_some() { - 1.0 - } else { - 0.0 - } -} - -// ============ date-fns compatible functions ============ - -/// # Safety -/// `pattern_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_datefns_format( - timestamp: f64, - pattern_ptr: *const StringHeader, -) -> *mut StringHeader { - let handle_f64 = js_dayjs_from_timestamp(timestamp); - if handle_f64 == 0.0 { - return std::ptr::null_mut(); - } - js_dayjs_format(f64_to_handle(handle_f64), pattern_ptr) -} - -/// # Safety -/// `date_str_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_datefns_parse_iso(date_str_ptr: *const StringHeader) -> f64 { - let handle_f64 = js_dayjs_parse(date_str_ptr); - if handle_f64 == 0.0 { - return f64::NAN; - } - js_dayjs_value_of(f64_to_handle(handle_f64)) -} - -#[no_mangle] -pub extern "C" fn js_datefns_add_days(timestamp: f64, amount: f64) -> f64 { - let handle_f64 = js_dayjs_from_timestamp(timestamp); - if handle_f64 == 0.0 { - return f64::NAN; - } - let unit = alloc_string("day"); - let new_handle_f64 = unsafe { js_dayjs_add(f64_to_handle(handle_f64), amount, unit.as_raw()) }; - if new_handle_f64 == 0.0 { - return f64::NAN; - } - js_dayjs_value_of(f64_to_handle(new_handle_f64)) -} - -#[no_mangle] -pub extern "C" fn js_datefns_add_months(timestamp: f64, amount: f64) -> f64 { - let handle_f64 = js_dayjs_from_timestamp(timestamp); - if handle_f64 == 0.0 { - return f64::NAN; - } - let unit = alloc_string("month"); - let new_handle_f64 = unsafe { js_dayjs_add(f64_to_handle(handle_f64), amount, unit.as_raw()) }; - if new_handle_f64 == 0.0 { - return f64::NAN; - } - js_dayjs_value_of(f64_to_handle(new_handle_f64)) -} - -#[no_mangle] -pub extern "C" fn js_datefns_add_years(timestamp: f64, amount: f64) -> f64 { - let handle_f64 = js_dayjs_from_timestamp(timestamp); - if handle_f64 == 0.0 { - return f64::NAN; - } - let unit = alloc_string("year"); - let new_handle_f64 = unsafe { js_dayjs_add(f64_to_handle(handle_f64), amount, unit.as_raw()) }; - if new_handle_f64 == 0.0 { - return f64::NAN; - } - js_dayjs_value_of(f64_to_handle(new_handle_f64)) -} - -#[no_mangle] -pub extern "C" fn js_datefns_difference_in_days(timestamp_left: f64, timestamp_right: f64) -> f64 { - let diff_ms = timestamp_left - timestamp_right; - (diff_ms / (1000.0 * 60.0 * 60.0 * 24.0)).floor() -} - -#[no_mangle] -pub extern "C" fn js_datefns_difference_in_hours(timestamp_left: f64, timestamp_right: f64) -> f64 { - let diff_ms = timestamp_left - timestamp_right; - (diff_ms / (1000.0 * 60.0 * 60.0)).floor() -} - -#[no_mangle] -pub extern "C" fn js_datefns_difference_in_minutes( - timestamp_left: f64, - timestamp_right: f64, -) -> f64 { - let diff_ms = timestamp_left - timestamp_right; - (diff_ms / (1000.0 * 60.0)).floor() -} - -#[no_mangle] -pub extern "C" fn js_datefns_is_after(timestamp: f64, compare_timestamp: f64) -> f64 { - if timestamp > compare_timestamp { - 1.0 - } else { - 0.0 - } -} - -#[no_mangle] -pub extern "C" fn js_datefns_is_before(timestamp: f64, compare_timestamp: f64) -> f64 { - if timestamp < compare_timestamp { - 1.0 - } else { - 0.0 - } -} - -#[no_mangle] -pub extern "C" fn js_datefns_start_of_day(timestamp: f64) -> f64 { - let handle_f64 = js_dayjs_from_timestamp(timestamp); - if handle_f64 == 0.0 { - return f64::NAN; - } - let unit = alloc_string("day"); - let new_handle_f64 = unsafe { js_dayjs_start_of(f64_to_handle(handle_f64), unit.as_raw()) }; - if new_handle_f64 == 0.0 { - return f64::NAN; - } - js_dayjs_value_of(f64_to_handle(new_handle_f64)) -} - -#[no_mangle] -pub extern "C" fn js_datefns_end_of_day(timestamp: f64) -> f64 { - let handle_f64 = js_dayjs_from_timestamp(timestamp); - if handle_f64 == 0.0 { - return f64::NAN; - } - let unit = alloc_string("day"); - let new_handle_f64 = unsafe { js_dayjs_end_of(f64_to_handle(handle_f64), unit.as_raw()) }; - if new_handle_f64 == 0.0 { - return f64::NAN; - } - js_dayjs_value_of(f64_to_handle(new_handle_f64)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn now_returns_handle() { - let f = js_dayjs_now(); - assert_ne!(f, 0.0); - assert!(js_dayjs_is_valid(f64_to_handle(f)) > 0.0); - } - - #[test] - fn from_timestamp_round_trip() { - let ts = 1_700_000_000_000.0_f64; - let h = js_dayjs_from_timestamp(ts); - assert_ne!(h, 0.0); - let back = js_dayjs_value_of(f64_to_handle(h)); - assert_eq!(back, ts); - } - - #[test] - fn parse_iso_format() { - let s = alloc_string("2024-01-15T10:30:00Z"); - let h = unsafe { js_dayjs_parse(s.as_raw()) }; - assert_ne!(h, 0.0); - assert_eq!(js_dayjs_year(f64_to_handle(h)), 2024.0); - assert_eq!(js_dayjs_month(f64_to_handle(h)), 0.0); // Jan = 0 - assert_eq!(js_dayjs_date(f64_to_handle(h)), 15.0); - } - - #[test] - fn add_subtract_days() { - let ts = 1_700_000_000_000.0_f64; - let h = js_dayjs_from_timestamp(ts); - let unit = alloc_string("day"); - let h2 = unsafe { js_dayjs_add(f64_to_handle(h), 1.0, unit.as_raw()) }; - let h3 = unsafe { js_dayjs_subtract(f64_to_handle(h2), 1.0, unit.as_raw()) }; - assert_eq!(js_dayjs_value_of(f64_to_handle(h3)), ts); - } - - #[test] - fn comparison_predicates() { - let earlier = js_dayjs_from_timestamp(1_000_000_000_000.0); - let later = js_dayjs_from_timestamp(2_000_000_000_000.0); - assert_eq!( - js_dayjs_is_before(f64_to_handle(earlier), f64_to_handle(later)), - 1.0 - ); - assert_eq!( - js_dayjs_is_after(f64_to_handle(later), f64_to_handle(earlier)), - 1.0 - ); - assert_eq!( - js_dayjs_is_same(f64_to_handle(earlier), f64_to_handle(earlier)), - 1.0 - ); - } - - #[test] - fn datefns_difference_in_days() { - let one_day_ms = 24.0 * 60.0 * 60.0 * 1000.0; - let diff = js_datefns_difference_in_days(2.0 * one_day_ms, 0.0); - assert_eq!(diff, 2.0); - } -} diff --git a/crates/perry-ext-fastify/Cargo.toml b/crates/perry-ext-fastify/Cargo.toml deleted file mode 100644 index 8bfd40c715..0000000000 --- a/crates/perry-ext-fastify/Cargo.toml +++ /dev/null @@ -1,60 +0,0 @@ -[package] -name = "perry-ext-fastify" -version.workspace = true -edition.workspace = true -license.workspace = true -description = "Native bindings for the npm `fastify` HTTP server framework — hyper-based router with Hono-style request/reply context. Uses only `perry-ffi`." - -[lints] -workspace = true - -[lib] -crate-type = ["staticlib", "rlib"] - -[dependencies] -perry-ffi.workspace = true -# #1113: cross-crate WS upgrade handoff. perry-ext-ws exposes -# `register_external_ws_stream(WebSocketStream) -> i64`; we call -# it from upgrade::handle_fastify_websocket_upgrade so the fastify -# `app.server.on("upgrade", …)` path produces a ws_id usable through -# the rest of the perry-ext-ws FFI surface. Mirrors the proven -# perry-ext-http (#577 Phase 4) dependency. -perry-ext-ws = { path = "../perry-ext-ws" } -hyper = { workspace = true, features = ["server", "http1", "http2"] } -hyper-util = { workspace = true, features = ["server", "server-auto", "tokio"] } -http-body-util.workspace = true -bytes.workspace = true -tokio = { workspace = true } -tokio-tungstenite = { workspace = true } -serde_json.workspace = true - -# #cluster — SO_REUSEPORT bind for `cluster.fork()` workers (unix only), -# mirroring perry-ext-http's cluster_bind. The -# `perry_cluster_worker_listening` symbol it reports to resolves at final link -# (defined in perry-runtime), like perry-ffi's runtime helpers — no Cargo dep on -# perry-runtime is needed. -[target.'cfg(unix)'.dependencies] -socket2.workspace = true - -[dev-dependencies] -perry-ffi = { workspace = true, features = ["runtime-link"] } -# #6303: perry-runtime MUST be built here with the same feature set the shipped -# `libperry_runtime.a` / `libperry_stdlib.a` carry (i.e. its `default`). This crate -# is a `staticlib`, so it BUNDLES the perry-runtime rlib objects into -# `libperry_ext_*.a` — and perry links the ext archives BEFORE stdlib/runtime -# (`prefer_well_known_before_stdlib`), so those bundled objects WIN the link for -# every symbol they define. The workspace dep is `default-features = false`, so -# without `"default"` here a per-crate `cargo build -p perry-ext-` (exactly what -# release-packages.yml does in its per-crate loop) bundles a runtime with -# `regex-engine`/`temporal`/... compiled OUT. The dispatchers those features gate -# are exported UNCONDITIONALLY (`js_string_replace_search_dyn`, -# `js_native_call_method`, ...) with the feature-gated logic `#[cfg]`-ed out of the -# BODY — so the degraded copy silently ToString-coerces a RegExp argument and -# searches for it literally instead of matching it (str.replace(re, fn) never fires -# its callback). Keep `"default"` in lock-step with perry-runtime's default feature -# list; the `ext_crates_bundle_a_full_featured_perry_runtime` test (well_known.rs) guards it. -# `stdlib` drops the bundled no-op stdlib_stubs (js_stdlib_init_dispatch, ...) so -# they don't shadow perry-stdlib's real symbols when this staticlib is linked -# before stdlib (#6314). `external-ws-symbols` additionally suppresses the ws -# stubs (redundant under `stdlib`, kept to document intent). -perry-runtime = { workspace = true, features = ["default", "external-ws-symbols", "stdlib"] } diff --git a/crates/perry-ext-fastify/src/app.rs b/crates/perry-ext-fastify/src/app.rs deleted file mode 100644 index eaf538d26b..0000000000 --- a/crates/perry-ext-fastify/src/app.rs +++ /dev/null @@ -1,599 +0,0 @@ -//! FastifyApp model + FFI for application creation, route registration, -//! lifecycle hooks, error handlers, and plugin registration. - -use std::collections::HashMap; - -use perry_ffi::{ - get_handle_mut, register_handle, Handle, JsClosure, JsValue, ObjectHeader, RawClosureHeader, - StringHeader, -}; - -use crate::context::string_from_nanboxed; -use crate::ensure_gc_scanner_registered; -use crate::router::RoutePattern; - -const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; -const PTR_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; - -/// Closure pointer (matches the runtime's `*const ClosureHeader`). -pub type ClosurePtr = i64; - -/// Route registration entry — method, parsed pattern, user closure. -#[derive(Clone)] -pub struct Route { - pub method: String, - pub pattern: RoutePattern, - pub handler: ClosurePtr, -} - -/// Lifecycle hook callbacks. Each hook list is iterated in FIFO order; -/// any hook that calls `reply.send(...)` aborts the chain (sets -/// `ctx.sent = true`) so subsequent hooks + the route handler are -/// skipped. -#[derive(Default, Clone)] -pub struct Hooks { - pub on_request: Vec, - pub pre_parsing: Vec, - pub pre_validation: Vec, - pub pre_handler: Vec, - pub pre_serialization: Vec, - pub on_send: Vec, - pub on_response: Vec, - pub on_error: Vec, -} - -/// Plugin registration record. Today only `prefix` is honored — the -/// plugin's body is invoked synchronously at registration time and -/// any routes it adds end up on the parent app under the prefix. -#[derive(Clone)] -pub struct Plugin { - pub handler: ClosurePtr, - pub prefix: String, -} - -/// FastifyApp — registered routes + lifecycle hooks + plugins + -/// configuration. -pub struct FastifyApp { - pub routes: Vec, - /// O(1) index over the subset of `routes` whose pattern is fully static - /// (no `Param`/`Wildcard` segments). Key: `"{METHOD} {full_path}"` (matching - /// what `match_route` builds at lookup time); value: the index into `routes`. - /// Built incrementally in `add_route`; parametric/wildcard routes fall - /// through to the linear scan. Purely additive — `routes` (and the GC - /// scanner's walk over it) is unchanged — turning the dominant static-route - /// lookup from O(N) per request into one hash + comparison. - pub static_index: HashMap, - pub hooks: Hooks, - pub error_handler: Option, - pub plugins: Vec, - pub prefix: String, - pub config: FastifyConfig, - /// #1113: callbacks registered via `app.server.on("upgrade", cb)`. - /// See the doc-comment on `js_fastify_app_server` for the full - /// rationale — the short version is that `app.server` returns the - /// FastifyApp handle pointer-tagged so `.on(…)` dispatches back - /// into this same struct's `upgrade_handlers` Vec. Today only - /// stores the callbacks — the hyper accept-loop in `server.rs` - /// doesn't yet route `Upgrade:` requests through - /// `hyper::upgrade::on(req)` and hand the raw socket + head - /// bytes back to TypeScript. Full bidirectional WebSocket - /// upgrade dispatch through perry-ext-ws's `noServer` mode is - /// the tracked #1113 follow-up. - pub upgrade_handlers: Vec, -} - -/// Server configuration parsed from the `Fastify({ ... })` call. -#[derive(Clone, Default)] -pub struct FastifyConfig { - pub logger: bool, - pub trust_proxy: bool, - pub body_limit: Option, -} - -/// Canonicalize a path into the normalized, leading-slash form the route -/// matcher compares against: drop any query string, then keep only the -/// non-empty `/`-separated segments (so `/api//users`, `api/users`, and -/// `/api/users/` all collapse to `/api/users`, and `/` / `` collapse to `/`). -/// `static_index` keys and lookups both go through this, so the index can never -/// diverge from `RoutePattern`'s own segment view — which likewise ignores -/// empty segments — and a route registered with redundant slashes still -/// resolves through the O(1) fast path instead of falling back to the scan. -fn canonical_path(path: &str) -> String { - let path = path.split('?').next().unwrap_or(path); - // Fast path: an already-canonical `/a/b/c` (leading slash, no empty - // segments, no trailing slash) is by far the common case — copy it as-is - // with a single allocation and skip the segment rebuild. Only paths with - // missing/redundant/trailing slashes fall through to normalization. - if path.len() > 1 && path.starts_with('/') && !path.ends_with('/') && !path.contains("//") { - return path.to_string(); - } - // The normalized form only ever drops bytes (query string, redundant - // slashes), so the input length is a safe upper bound — pre-size to keep - // this to a single allocation too. - let mut out = String::with_capacity(path.len() + 1); - for part in path.split('/').filter(|p| !p.is_empty()) { - out.push('/'); - out.push_str(part); - } - if out.is_empty() { - out.push('/'); - } - out -} - -impl FastifyApp { - pub fn new() -> Self { - Self { - routes: Vec::new(), - static_index: HashMap::new(), - hooks: Hooks::default(), - error_handler: None, - plugins: Vec::new(), - prefix: String::new(), - config: FastifyConfig::default(), - upgrade_handlers: Vec::new(), - } - } - - pub fn with_prefix(prefix: String) -> Self { - let mut app = Self::new(); - app.prefix = prefix; - app - } - - /// Register a route. The method is upper-cased and the path is parsed into a - /// [`RoutePattern`]. Fully-static routes (no `Param`/`Wildcard` segments) are - /// additionally recorded in `static_index` for O(1) lookup in `match_route`, - /// but *only* when doing so cannot change which handler `match_route` returns - /// (see below); everything else resolves through the linear scan. - pub fn add_route(&mut self, method: &str, path: &str, handler: ClosurePtr) { - let full_path = if self.prefix.is_empty() { - path.to_string() - } else { - format!("{}{}", self.prefix, path) - }; - let method_upper = method.to_uppercase(); - let pattern = RoutePattern::parse(&full_path); - let is_static = pattern - .segments - .iter() - .all(|s| matches!(s, crate::router::Segment::Static(_))); - let new_idx = self.routes.len(); - self.routes.push(Route { - method: method_upper, - pattern, - handler, - }); - if is_static { - // Normalize the path through the same `canonical_path` the lookup - // side uses, so the key matches regardless of leading/redundant - // slashes (`/foo`, `foo`, `/foo//bar`). - let method_for_key = self.routes[new_idx].method.clone(); - let canon_path = canonical_path(&full_path); - let key = format!("{} {}", method_for_key, canon_path); - // Precedence guard: `match_route` consults the index *before* the - // linear scan, so only index this static route if no earlier- - // registered route (another static, or a parametric/wildcard pattern - // like `/:slug` or `/static/*`) already matches the same path. When - // one does, skip the index so the lookup falls through to the scan and - // the first-registered route still wins — preserving the adapter's - // existing first-match semantics. `or_insert` keeps the first static - // registration on exact-duplicate keys for the same reason. - let shadowed_by_prior = self.routes[..new_idx].iter().any(|route| { - route.method == method_for_key && route.pattern.match_path(&canon_path).is_some() - }); - if !shadowed_by_prior { - self.static_index.entry(key).or_insert(new_idx); - } - } - } - - pub fn add_hook(&mut self, hook_name: &str, handler: ClosurePtr) { - match hook_name { - "onRequest" => self.hooks.on_request.push(handler), - "preParsing" => self.hooks.pre_parsing.push(handler), - "preValidation" => self.hooks.pre_validation.push(handler), - "preHandler" => self.hooks.pre_handler.push(handler), - "preSerialization" => self.hooks.pre_serialization.push(handler), - "onSend" => self.hooks.on_send.push(handler), - "onResponse" => self.hooks.on_response.push(handler), - "onError" => self.hooks.on_error.push(handler), - _ => eprintln!("Unknown hook: {}", hook_name), - } - } - - pub fn set_error_handler(&mut self, handler: ClosurePtr) { - self.error_handler = Some(handler); - } - - /// Resolve a request `(method, path)` to its handler and extracted path - /// params. Fully-static routes resolve in O(1) through `static_index`; - /// everything else (and any static route the index deliberately omits to - /// preserve precedence — see `add_route`) falls through to a linear scan that - /// returns the first registered match. - pub fn match_route( - &self, - method: &str, - path: &str, - ) -> Option<(&Route, HashMap)> { - // Normalize the method the same way `add_route` does (it stores - // `to_uppercase()`), so a lowercase/mixed-case caller hits both the index - // and the scan rather than silently missing. Requests off the hyper accept - // loop are already upper-case (the hot path), so only allocate when a - // direct caller actually passes a lower-case letter. - let method_upper: std::borrow::Cow<'_, str> = - if method.bytes().any(|b| b.is_ascii_lowercase()) { - std::borrow::Cow::Owned(method.to_uppercase()) - } else { - std::borrow::Cow::Borrowed(method) - }; - // Canonicalize the path (drops the query string + redundant slashes) the - // same way `add_route` builds the key and `RoutePattern::match_path` - // normalizes on the scan side, so e.g. `/ping?x=1` still hits the static - // `/ping` index entry. - let canon_path = canonical_path(path); - let key = format!("{} {}", method_upper, canon_path); - // Fast path: fully-static routes resolve in O(1) via `static_index`. - if let Some(&idx) = self.static_index.get(&key) { - // Static patterns never extract path params — return an empty map, - // matching `RoutePattern::match_path`'s behaviour for static patterns. - return Some((&self.routes[idx], HashMap::new())); - } - // Slow path: parametric/wildcard routes still go through the linear scan, - // preserving first-registered-wins semantics. - for route in &self.routes { - if route.method == *method_upper { - if let Some(params) = route.pattern.match_path(path) { - return Some((route, params)); - } - } - } - None - } -} - -impl Default for FastifyApp { - fn default() -> Self { - Self::new() - } -} - -// ============================================================================ -// FFI: app creation -// ============================================================================ - -/// Strip a NaN-box `POINTER_TAG` envelope off a closure pointer if -/// present. Codegen sometimes hands us the raw pointer, sometimes the -/// NaN-boxed form — we accept both. -fn strip_pointer_tag(value: i64) -> i64 { - if (value as u64 & 0xFFFF_0000_0000_0000) == POINTER_TAG { - (value as u64 & PTR_MASK) as i64 - } else { - value - } -} - -/// Pull a string field out of an options object via perry-ffi's -/// `json_stringify` round-trip. Same trick mongodb / http use. -unsafe fn opts_string(opts_f64: f64, key: &str) -> Option { - let v = JsValue::from_bits(opts_f64.to_bits()); - if v.is_undefined() || v.is_null() { - return None; - } - let json = perry_ffi::json_stringify(v)?; - let parsed: serde_json::Value = serde_json::from_str(&json).ok()?; - parsed.get(key).and_then(|v| v.as_str()).map(String::from) -} - -unsafe fn opts_bool(opts_f64: f64, key: &str) -> Option { - let v = JsValue::from_bits(opts_f64.to_bits()); - if v.is_undefined() || v.is_null() { - return None; - } - let json = perry_ffi::json_stringify(v)?; - let parsed: serde_json::Value = serde_json::from_str(&json).ok()?; - parsed.get(key).and_then(|v| v.as_bool()) -} - -unsafe fn opts_u64(opts_f64: f64, key: &str) -> Option { - let v = JsValue::from_bits(opts_f64.to_bits()); - if v.is_undefined() || v.is_null() { - return None; - } - let json = perry_ffi::json_stringify(v)?; - let parsed: serde_json::Value = serde_json::from_str(&json).ok()?; - parsed.get(key).and_then(|v| { - v.as_u64() - .or_else(|| v.as_i64().map(|n| n.max(0) as u64)) - .or_else(|| v.as_f64().map(|n| n.max(0.0) as u64)) - }) -} - -/// `Fastify()` — create a new application with default config. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_create() -> Handle { - ensure_gc_scanner_registered(); - register_handle(FastifyApp::new()) -} - -/// `Fastify({ logger, trustProxy, bodyLimit })` — create with options. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_create_with_opts(opts_f64: f64) -> Handle { - ensure_gc_scanner_registered(); - let mut config = FastifyConfig::default(); - if let Some(b) = opts_bool(opts_f64, "logger") { - config.logger = b; - } - if let Some(b) = opts_bool(opts_f64, "trustProxy") { - config.trust_proxy = b; - } - if let Some(n) = opts_u64(opts_f64, "bodyLimit") { - config.body_limit = Some(n as usize); - } - let mut app = FastifyApp::new(); - app.config = config; - register_handle(app) -} - -// ============================================================================ -// FFI: route registration -// ============================================================================ - -/// Internal helper — append a route to the app. -unsafe fn register_route(app_handle: Handle, method: &str, path: i64, handler: i64) -> bool { - let path_str = match string_from_nanboxed(path) { - Some(p) => p, - None => return false, - }; - let raw_handler = strip_pointer_tag(handler); - if let Some(app) = get_handle_mut::(app_handle) { - app.add_route(method, &path_str, raw_handler); - return true; - } - false -} - -/// `app.get(path, handler)`. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_get(app: Handle, path: i64, handler: i64) -> bool { - register_route(app, "GET", path, handler) -} - -/// `app.post(path, handler)`. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_post(app: Handle, path: i64, handler: i64) -> bool { - register_route(app, "POST", path, handler) -} - -/// `app.put(path, handler)`. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_put(app: Handle, path: i64, handler: i64) -> bool { - register_route(app, "PUT", path, handler) -} - -/// `app.delete(path, handler)`. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_delete(app: Handle, path: i64, handler: i64) -> bool { - register_route(app, "DELETE", path, handler) -} - -/// `app.patch(path, handler)`. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_patch(app: Handle, path: i64, handler: i64) -> bool { - register_route(app, "PATCH", path, handler) -} - -/// `app.head(path, handler)`. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_head(app: Handle, path: i64, handler: i64) -> bool { - register_route(app, "HEAD", path, handler) -} - -/// `app.options(path, handler)`. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_options(app: Handle, path: i64, handler: i64) -> bool { - register_route(app, "OPTIONS", path, handler) -} - -/// `app.all(path, handler)` — registers the same handler under every -/// HTTP method. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_all(app: Handle, path: i64, handler: i64) -> bool { - let methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]; - let mut ok = true; - for m in methods { - if !register_route(app, m, path, handler) { - ok = false; - } - } - ok -} - -/// `app.route({ method, url, handler })` — generic dispatch with -/// caller-supplied method. The first arg here is the method as a -/// NaN-boxed string; we support uppercased / lowercased variants. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_route( - app: Handle, - method: i64, - path: i64, - handler: i64, -) -> bool { - let method = match string_from_nanboxed(method) { - Some(m) => m.to_uppercase(), - None => return false, - }; - register_route(app, &method, path, handler) -} - -// ============================================================================ -// FFI: hooks -// ============================================================================ - -/// `app.addHook(event, handler)` — registers a lifecycle hook. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_add_hook(app: Handle, hook_name: i64, handler: i64) -> bool { - let name = match string_from_nanboxed(hook_name) { - Some(n) => n, - None => return false, - }; - let raw_handler = strip_pointer_tag(handler); - if let Some(app) = get_handle_mut::(app) { - app.add_hook(&name, raw_handler); - return true; - } - false -} - -// ============================================================================ -// FFI: error handler -// ============================================================================ - -/// `app.setErrorHandler(fn)`. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_set_error_handler(app: Handle, handler: i64) -> bool { - let raw = strip_pointer_tag(handler); - if let Some(app) = get_handle_mut::(app) { - app.set_error_handler(raw); - return true; - } - false -} - -// ============================================================================ -// FFI: plugins -// ============================================================================ - -/// `app.register(plugin, opts?)` — invokes the plugin synchronously -/// with the parent app handle and the options object. Plugin routes -/// are registered onto the parent app under any `opts.prefix` value -/// (the prefix is temporarily appended to the parent app's prefix -/// while the plugin runs, then restored). -#[no_mangle] -pub unsafe extern "C" fn js_fastify_register(app_handle: Handle, plugin: i64, opts: f64) -> bool { - let plugin_prefix = opts_string(opts, "prefix").unwrap_or_default(); - - // Save old prefix and set the combined prefix on the parent app. - let old_prefix = match get_handle_mut::(app_handle) { - Some(app) => { - let old = app.prefix.clone(); - app.prefix = if old.is_empty() { - plugin_prefix.clone() - } else if plugin_prefix.is_empty() { - old.clone() - } else { - format!("{}{}", old, plugin_prefix) - }; - old - } - None => return false, - }; - - // NaN-box the parent app handle so the plugin's method dispatch - // (e.g. `app.get(...)`) sees a POINTER_TAG'd JS handle the - // codegen-side dispatcher knows how to unbox. - let nanboxed_app = f64::from_bits(POINTER_TAG | (app_handle as u64 & PTR_MASK)); - - // Strip a NaN-box wrapper if codegen handed us one. - let raw_closure = if (plugin as u64 & 0xFFFF_0000_0000_0000) == POINTER_TAG { - (plugin as u64 & PTR_MASK) as *const RawClosureHeader - } else { - plugin as *const RawClosureHeader - }; - - let closure = JsClosure::from_raw(raw_closure); - if !closure.is_null() { - let _ = closure.call2(nanboxed_app, opts); - } - - // Restore the old prefix. - if let Some(app) = get_handle_mut::(app_handle) { - app.prefix = old_prefix; - } - - true -} - -// ============================================================================ -// FFI: #1113 — app.server EventEmitter (minimum-viable shape) -// ============================================================================ - -/// `app.server` — Node-compatible getter that returns an -/// object-shaped value supporting `.on(event, cb)`. -/// -/// In Node, `app.server` is the underlying `http.Server` (or -/// `http2.Http2Server`) which `extends EventEmitter`. The canonical -/// pattern for attaching a WebSocket server without opening a second -/// port is: -/// -/// ```ts -/// import { WebSocketServer } from "ws"; -/// const wss = new WebSocketServer({ noServer: true }); -/// app.server.on("upgrade", (req, socket, head) => { -/// wss.handleUpgrade(req, socket, head, (sock) => { … }); -/// }); -/// ``` -/// -/// Pre-fix `app.server` fell through to the generic property getter -/// on the FastifyApp handle and returned the raw handle id as a JS -/// number — so `typeof app.server` was `"number"` and `app.server.on` -/// was `undefined`. The user-side `.on("upgrade", …)` call then threw -/// `(number).on is not a function` and the boot `catch` arm logged -/// `fatal:` and exited. -/// -/// We return the same FastifyApp handle id — the codegen-side -/// `NATIVE_MODULE_TABLE` arm at `module: "fastify", method: "server"` -/// NaN-boxes it via `NR_PTR` before it reaches the JS world. Perry's -/// `typeof` against `POINTER_TAG` returns `"object"` and `.on(…)` -/// routes through the same FastifyApp method dispatch (the -/// `"on"` arm in `js_fastify_app_on` below). -/// -/// **Today only stores the handler list** — the hyper accept loop in -/// `server.rs` doesn't yet route `Upgrade:` requests through -/// `hyper::upgrade::on(req)` and hand the raw socket + head bytes -/// back to TypeScript, so registered upgrade handlers never fire. -/// Full bidirectional WebSocket upgrade dispatch through -/// `perry-ext-ws`'s `noServer` mode is tracked as the #1113 -/// follow-up. The diagnostic line emitted from `server.rs` at -/// request-dispatch time tells the user when an Upgrade arrived -/// despite the gap. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_app_server(app_handle: Handle) -> Handle { - app_handle -} - -/// `app.server.on(event, cb)` — register an event handler. Only -/// `"upgrade"` is meaningful today; other event names -/// (`"connection"`, `"error"`, `"listening"`, …) are silently -/// accepted so boot-time registrations don't crash, but the -/// hyper accept loop doesn't currently fire them. See -/// `js_fastify_app_server` for the broader rationale. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_app_on(app_handle: Handle, event: i64, callback: i64) { - if callback == 0 { - return; - } - let event_name = match string_from_nanboxed(event) { - Some(s) => s, - None => return, - }; - let raw_cb = strip_pointer_tag(callback); - if event_name == "upgrade" { - if let Some(app) = get_handle_mut::(app_handle) { - app.upgrade_handlers.push(raw_cb); - } - } - // Other event names (e.g. "connection", "error", "listening") are - // silently accepted — see the function-level comment. -} - -// Suppress unused-import warnings for FFI surface re-exports the -// caller's code may need at link time. -#[allow(dead_code)] -fn _link_keepalive() -> Option<*mut ObjectHeader> { - None -} -#[allow(dead_code)] -fn _link_string_keepalive() -> Option<*mut StringHeader> { - None -} diff --git a/crates/perry-ext-fastify/src/cluster_bind.rs b/crates/perry-ext-fastify/src/cluster_bind.rs deleted file mode 100644 index 3294eebf1b..0000000000 --- a/crates/perry-ext-fastify/src/cluster_bind.rs +++ /dev/null @@ -1,138 +0,0 @@ -//! `node:cluster` worker port sharing for the Fastify listen site. -//! -//! When this process is a `cluster.fork()`ed worker, the TCP bind goes through -//! SO_REUSEPORT so N workers can share one port — the kernel load-balances -//! accepts across them without a primary-accept hop. The bound address is -//! reported to the primary over the -//! cluster IPC so `cluster.on('listening')` fires Node-style. -//! -//! This wires Fastify into the cluster machinery that already exists in -//! perry-runtime (`worker_reuseport_bind`, `perry_cluster_worker_listening`) -//! and is used by `net` and perry-ext-http. It mirrors the SO_REUSEPORT -//! (`SCHED_NONE`) path of perry-ext-http's HTTP/2 & HTTPS listen sites. -//! Round-robin fd-passing (`SCHED_RR`) and the shared ephemeral port for -//! `listen(0)` (#4962) are a follow-up here, exactly as for those sites today. - -use std::net::{SocketAddr, TcpListener}; - -/// True when this process is a `cluster.fork()`ed worker. The runtime caches -/// this before consuming Node's bootstrap-only `NODE_UNIQUE_ID` variable. -pub(crate) fn is_cluster_worker() -> bool { - unsafe { perry_cluster_is_worker() != 0 } -} - -/// Bind `addr` with SO_REUSEPORT (+SO_REUSEADDR) so multiple cluster workers can -/// share the port (kernel-balanced accepts). Unix-only; the caller falls back -/// to the plain path elsewhere. -#[cfg(unix)] -fn reuseport_bind(addr: SocketAddr) -> std::io::Result { - use socket2::{Domain, Protocol, Socket, Type}; - let socket = Socket::new(Domain::for_address(addr), Type::STREAM, Some(Protocol::TCP))?; - socket.set_reuse_address(true)?; - socket.set_reuse_port(true)?; - socket.bind(&addr.into())?; - // Node's default listen backlog. - socket.listen(511)?; - Ok(socket.into()) -} - -/// Bind `addr`, enabling SO_REUSEPORT when this process is a cluster worker -/// **or** when the caller explicitly requested it via the `reusePort: true` -/// listen option, so multiple processes can share the port (kernel-balanced -/// accepts). `reusePort` is a real Node (`net`/`http` `listen`) and Bun -/// (`Bun.serve`) option; honoring it lets a non-cluster program opt into port -/// sharing directly. Non-worker, non-`reusePort` (or non-unix) binds keep the -/// plain `TcpListener::bind` path. -pub(crate) fn bind_listener(addr: SocketAddr, reuse_port: bool) -> std::io::Result { - #[cfg(unix)] - if reuse_port || is_cluster_worker() { - return reuseport_bind(addr); - } - // On non-unix targets SO_REUSEPORT isn't wired (matching the HTTP listen - // sites); the explicit request is a no-op there rather than an error. - #[cfg(not(unix))] - let _ = reuse_port; - TcpListener::bind(addr) -} - -extern "C" { - // Defined in perry-runtime's cluster module. This crate has no Cargo dep on - // perry-runtime (dev-dep only); the symbol resolves at final link, the same - // way perry-ffi's runtime helpers do — matching perry-ext-http's - // `cluster_bind`. - fn perry_cluster_is_worker() -> i32; - fn perry_cluster_worker_listening( - addr_ptr: *const u8, - addr_len: u32, - port: i32, - address_type: i32, - ); -} - -/// Report this worker's bound `host:port` to the primary so -/// `cluster.on('listening')` fires Node-style. No-op unless this is a cluster -/// worker (re-checked on the runtime side as well). -pub(crate) fn notify_listening(host: &str, port: u16) { - if !is_cluster_worker() { - return; - } - let address_type = if host.contains(':') { 6 } else { 4 }; - unsafe { - perry_cluster_worker_listening(host.as_ptr(), host.len() as u32, port as i32, address_type); - } -} - -#[cfg(all(test, unix))] -mod tests { - use super::*; - - /// SO_REUSEPORT lets two listeners share one live port — the mechanism that - /// makes `cluster.fork()` workers able to each `listen()` on the same port. - #[test] - fn reuseport_bind_lets_workers_share_a_port() { - let any: SocketAddr = "127.0.0.1:0".parse().unwrap(); - let first = reuseport_bind(any).expect("first reuseport bind"); - let port = first.local_addr().unwrap().port(); - let shared: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); - - // A second SO_REUSEPORT bind on the SAME live port succeeds. - let second = reuseport_bind(shared).expect("second reuseport bind on the same port"); - assert_eq!(second.local_addr().unwrap().port(), port); - - // ...whereas a PLAIN bind on that live port is refused — proving the - // shared bind above is SO_REUSEPORT doing the work, not an accident. - assert!( - TcpListener::bind(shared).is_err(), - "a plain bind must be refused on a port already in use" - ); - } - - /// `reusePort: true` enables SO_REUSEPORT even when this process is NOT a - /// cluster worker — the explicit-option path (a real Node/Bun listen - /// option). Two `bind_listener(_, true)` calls share one live port, while - /// the non-worker, no-option path keeps the plain bind and is refused. - #[test] - fn explicit_reuse_port_option_shares_a_port_without_cluster() { - // Sharing here comes purely from the explicit `reusePort = true` - // argument, not from worker auto-detection — guard that invariant so a - // stray NODE_UNIQUE_ID can't make this pass for the wrong reason. - assert!( - !is_cluster_worker(), - "this test must not run as a cluster worker" - ); - let any: SocketAddr = "127.0.0.1:0".parse().unwrap(); - let first = bind_listener(any, true).expect("first reusePort bind"); - let port = first.local_addr().unwrap().port(); - let shared: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); - - let _second = - bind_listener(shared, true).expect("second reusePort bind shares the same port"); - - // Without the option (and not a worker), `bind_listener` takes the - // plain path, which is refused on the SO_REUSEPORT-held port. - assert!( - bind_listener(shared, false).is_err(), - "bind_listener(_, false) off-cluster must not share a reusePort-held port" - ); - } -} diff --git a/crates/perry-ext-fastify/src/context.rs b/crates/perry-ext-fastify/src/context.rs deleted file mode 100644 index 535e072ab1..0000000000 --- a/crates/perry-ext-fastify/src/context.rs +++ /dev/null @@ -1,772 +0,0 @@ -//! Request/Reply context — unified surface for both Fastify-style -//! `(request, reply)` handlers and Hono-style `(c)` handlers. The -//! same handle backs both: each call's signature picks which set of -//! methods is exposed to TS code. - -use std::collections::HashMap; -use std::sync::atomic::{AtomicU64, Ordering}; - -use perry_ffi::{ - alloc_string, build_object_shape, get_handle, get_handle_mut, js_object_alloc_with_shape, - js_object_set_field, read_bytes, ArrayHeader, Handle, JsString, JsValue, ObjectHeader, - StringHeader, -}; - -const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; - -/// Mirror of `perry_runtime::string::STRING_FLAG_HAS_LONE_SURROGATES` — the -/// `StringHeader::flags` bit marking a WTF-8 string that holds lone surrogates -/// (so it isn't well-formed UTF-8). Defined locally because this crate's -/// library code links only `perry-ffi`, not `perry-runtime` (same local-mirror -/// pattern as the `extern "C"` runtime symbols below); the value is part of the -/// stable runtime ABI. -const STRING_FLAG_HAS_LONE_SURROGATES: u32 = 1; - -// Runtime symbols that perry-ffi hasn't yet wrapped — these are -// stable `extern "C"` exports from perry-runtime. Declaring them -// locally is the same pattern perry-ext-{net,http,ws} use for the -// few runtime symbols outside perry-ffi v0.5's surface. -extern "C" { - /// Parse a JSON string into a NaN-boxed JsValue. Returns the - /// resulting NaN-boxed bits as a `u64`. - fn js_json_parse(text_ptr: *const StringHeader) -> u64; - - /// Pull the `*mut StringHeader` out of a NaN-boxed JsValue, - /// regardless of whether the box is STRING_TAG or POINTER_TAG. - /// Returns 0 if the value isn't a string. - fn js_get_string_pointer_unified(value: f64) -> i64; - - /// Stringify any NaN-boxed JsValue (number, bool, etc.) — the - /// runtime's general-purpose toString helper. - fn js_jsvalue_to_string(value: f64) -> *mut StringHeader; - - /// JSON.stringify with type hint. Same as - /// `perry_ffi::json_stringify` modulo the type-hint argument. - fn js_json_stringify(value: f64, type_hint: u32) -> *mut StringHeader; - - /// True if `ptr` (raw POINTER_TAG-masked address) points at a - /// `BufferHeader` registered with the runtime's BUFFER_REGISTRY. - /// Used to distinguish `Buffer` / `Uint8Array` payloads from - /// `StringHeader`-shaped objects when building response bodies. - /// Same C-exposed extern perry-ext-http uses (see - /// `crates/perry-runtime/src/buffer.rs:601`). - fn js_buffer_is_buffer(ptr: i64) -> i32; -} - -/// `BufferHeader` mirror — must match `crates/perry-runtime/src/buffer.rs`'s -/// layout (`{ length: u32, capacity: u32 }`, data immediately after the -/// 8-byte header). Distinct from `StringHeader` (20-byte header), so a -/// Buffer must NEVER be read through the string-shaped path. -#[repr(C)] -struct BufferHeader { - length: u32, - capacity: u32, -} - -/// Body type returned by `build_response_body` / `jsvalue_to_response_body` -/// when the caller needs to pick a sensible default `content-type`. -/// `Binary` is set when the handler returned a `Buffer` / `Uint8Array`; -/// the caller defaults its content-type to `application/octet-stream` -/// instead of `application/json` so binary assets (PNG, WASM, …) ship -/// untouched (#1120). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum BodyKind { - Binary, - TextOrJson, -} - -static CONTEXT_ID_COUNTER: AtomicU64 = AtomicU64::new(1); - -/// Unified request / reply context — backs both Fastify `(req, reply)` -/// pairs and Hono `(c)` handlers. -pub struct FastifyContext { - pub id: u64, - pub request_id: u64, - pub method: String, - pub url: String, - pub query_string: String, - pub params: HashMap, - pub headers: HashMap, - pub body: Option>, - - // Reply state - pub status_code: u16, - pub response_headers: Vec<(String, String)>, - pub sent: bool, - pub response_body: Option>, - /// User data stashed by auth middleware — NaN-boxed bits. - pub user_data: u64, - /// Cached `request.params` JS object, built on first access by - /// `js_fastify_req_params_object`. `0` means uncached; otherwise the - /// NaN-boxed object-pointer bits (`POINTER_TAG` top 16 = `0x7FFD`), so `0` - /// never collides with a real entry. `AtomicU64` (not `Cell`) because the - /// handle registry hands out shared `&FastifyContext` and the type must stay - /// `Send + Sync`. Each request gets a fresh context (reset to 0 in `new`), so - /// the cache is naturally request-scoped — no inter-request leak. The GC root - /// scanner visits this slot (`scan_fastify_roots`) so a copying GC between the - /// first build and a later `req.params` read keeps the object alive *and* - /// relocates the cached pointer. - pub params_object_cache: AtomicU64, - /// Cached `request.query` JS object. Same encoding and invariants as - /// `params_object_cache`. - pub query_object_cache: AtomicU64, - /// Cached `request.headers` JS object. Same encoding and invariants as - /// `params_object_cache`. Handlers commonly read several headers per request, - /// so caching the constructed object after the first read makes later reads an - /// atomic load. - pub headers_object_cache: AtomicU64, -} - -impl FastifyContext { - pub fn new( - request_id: u64, - method: String, - url: String, - headers: HashMap, - body: Option>, - params: HashMap, - ) -> Self { - let (path, query_string) = match url.split_once('?') { - Some((p, q)) => (p.to_string(), q.to_string()), - None => (url.clone(), String::new()), - }; - Self { - id: CONTEXT_ID_COUNTER.fetch_add(1, Ordering::SeqCst), - request_id, - method, - url: path, - query_string, - params, - headers, - body, - status_code: 200, - response_headers: Vec::new(), - sent: false, - response_body: None, - user_data: TAG_UNDEFINED, - params_object_cache: AtomicU64::new(0), - query_object_cache: AtomicU64::new(0), - headers_object_cache: AtomicU64::new(0), - } - } - - pub fn get_query_param(&self, name: &str) -> Option { - for pair in self.query_string.split('&') { - if let Some((key, value)) = pair.split_once('=') { - if key == name { - return Some(urlencoding_decode(value)); - } - } - } - None - } - - pub fn get_query_params(&self) -> HashMap { - let mut params = HashMap::new(); - for pair in self.query_string.split('&') { - if let Some((key, value)) = pair.split_once('=') { - params.insert(key.to_string(), urlencoding_decode(value)); - } - } - params - } - - pub fn body_string(&self) -> Option { - self.body - .as_ref() - .map(|b| String::from_utf8_lossy(b).to_string()) - } - - pub fn set_status(&mut self, code: u16) { - self.status_code = code; - } - - pub fn add_header(&mut self, name: &str, value: &str) { - self.response_headers - .push((name.to_string(), value.to_string())); - } -} - -/// Minimal RFC-3986 percent-decoder + `+ → space` normalization for -/// query strings. -fn urlencoding_decode(s: &str) -> String { - let mut result = String::with_capacity(s.len()); - let mut chars = s.chars().peekable(); - while let Some(c) = chars.next() { - if c == '%' { - let hex: String = chars.by_ref().take(2).collect(); - if let Ok(byte) = u8::from_str_radix(&hex, 16) { - result.push(byte as char); - } else { - result.push('%'); - result.push_str(&hex); - } - } else if c == '+' { - result.push(' '); - } else { - result.push(c); - } - } - result -} - -/// Read a `*const StringHeader` into an owned `String`. -pub(crate) unsafe fn string_from_header(ptr: *const StringHeader) -> Option { - let handle = JsString::from_raw(ptr as *mut StringHeader); - read_bytes(handle).map(|bytes| String::from_utf8_lossy(bytes).into_owned()) -} - -/// Pull a string out of a raw i64 NaN-boxed value. -pub(crate) unsafe fn string_from_nanboxed(value: i64) -> Option { - let ptr = js_get_string_pointer_unified(f64::from_bits(value as u64)); - if ptr == 0 { - return None; - } - string_from_header(ptr as *const StringHeader) -} - -/// Pull a string out of a NaN-boxed f64 JsValue. -unsafe fn extract_jsvalue_string(value: f64) -> Option { - let ptr = js_get_string_pointer_unified(value); - if ptr == 0 { - return None; - } - string_from_header(ptr as *const StringHeader) -} - -// ============================================================================ -// FFI: Request methods (Fastify + Hono) -// ============================================================================ - -/// `request.method` / `c.req.method`. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_req_method(ctx_handle: Handle) -> *mut StringHeader { - if let Some(ctx) = get_handle::(ctx_handle) { - return alloc_string(&ctx.method).as_raw(); - } - std::ptr::null_mut() -} - -/// `request.url` / `c.req.url` — the path **with** its query string, matching -/// Node/Fastify where `req.url` is the raw request target (e.g. `/p?k=v`). -/// `FastifyContext` splits the incoming target into `url` (path) and -/// `query_string` for routing/`req.query`, so reconstruct the original here. -/// #1705: returning the bare path dropped `?k=v`, so `@hono/perry-server`'s -/// `new Request('http://' + host + req.url)` lost the query and -/// `c.req.query()` / `new URL(c.req.url).searchParams` came back empty. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_req_url(ctx_handle: Handle) -> *mut StringHeader { - if let Some(ctx) = get_handle::(ctx_handle) { - if ctx.query_string.is_empty() { - return alloc_string(&ctx.url).as_raw(); - } - let full = format!("{}?{}", ctx.url, ctx.query_string); - return alloc_string(&full).as_raw(); - } - std::ptr::null_mut() -} - -/// `request.params` — JSON-encoded params object as a string. Used by -/// codegen paths that don't yet support object returns. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_req_params(ctx_handle: Handle) -> *mut StringHeader { - if let Some(ctx) = get_handle::(ctx_handle) { - if let Ok(json) = serde_json::to_string(&ctx.params) { - return alloc_string(&json).as_raw(); - } - } - std::ptr::null_mut() -} - -/// `request.params` returning a NaN-boxed object — built via -/// perry-ffi's shape-aware allocator. -/// -/// Caches the constructed object on the `FastifyContext` on first access, so a -/// handler reading `req.params.a` then `req.params.b` builds the object once. -/// The cache holds the NaN-boxed object bits; the GC root scanner visits the -/// slot, so a copying GC between reads keeps it alive and relocates the pointer. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_req_params_object(ctx_handle: Handle) -> f64 { - let undefined = f64::from_bits(TAG_UNDEFINED); - let ctx = match get_handle::(ctx_handle) { - Some(c) => c, - None => return undefined, - }; - // Fast path: object already built earlier in this same request. - let cached = ctx.params_object_cache.load(Ordering::Acquire); - if cached != 0 { - return f64::from_bits(cached); - } - match build_string_map_object(&ctx.params) { - // Release so a later Acquire load on this context sees the bits. - Some(obj) => { - ctx.params_object_cache - .store(obj.to_bits(), Ordering::Release); - obj - } - None => undefined, - } -} - -/// `c.req.param('id')` — single param accessor. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_req_param(ctx_handle: Handle, name: i64) -> *mut StringHeader { - let name = match string_from_nanboxed(name) { - Some(n) => n, - None => return std::ptr::null_mut(), - }; - if let Some(ctx) = get_handle::(ctx_handle) { - if let Some(value) = ctx.params.get(&name) { - return alloc_string(value).as_raw(); - } - } - std::ptr::null_mut() -} - -/// `request.query` — JSON-encoded query params. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_req_query(ctx_handle: Handle) -> *mut StringHeader { - if let Some(ctx) = get_handle::(ctx_handle) { - let params = ctx.get_query_params(); - if let Ok(json) = serde_json::to_string(¶ms) { - return alloc_string(&json).as_raw(); - } - } - std::ptr::null_mut() -} - -/// `request.query` returning a NaN-boxed object. Cached on the -/// `FastifyContext` on first access — same mechanism as -/// `js_fastify_req_params_object`. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_req_query_object(ctx_handle: Handle) -> f64 { - let undefined = f64::from_bits(TAG_UNDEFINED); - let ctx = match get_handle::(ctx_handle) { - Some(c) => c, - None => return undefined, - }; - let cached = ctx.query_object_cache.load(Ordering::Acquire); - if cached != 0 { - return f64::from_bits(cached); - } - let params = ctx.get_query_params(); - match build_string_map_object(¶ms) { - Some(obj) => { - ctx.query_object_cache - .store(obj.to_bits(), Ordering::Release); - obj - } - None => undefined, - } -} - -/// `request.body` — raw body as a string. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_req_body(ctx_handle: Handle) -> *mut StringHeader { - if let Some(ctx) = get_handle::(ctx_handle) { - if let Some(body) = ctx.body_string() { - return alloc_string(&body).as_raw(); - } - } - std::ptr::null_mut() -} - -/// `await request.json()` — parse the body as JSON, returning a -/// NaN-boxed object. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_req_json(ctx_handle: Handle) -> f64 { - if let Some(ctx) = get_handle::(ctx_handle) { - if let Some(body) = ctx.body_string() { - // Round-trip through perry-runtime's JSON parser. - let body_str = alloc_string(&body); - if !body_str.is_null() { - let bits = js_json_parse(body_str.as_raw() as *const StringHeader); - return f64::from_bits(bits); - } - } - } - f64::from_bits(TAG_UNDEFINED) -} - -/// `request.headers` — full headers as a NaN-boxed object. Cached on the -/// `FastifyContext` on first access — same mechanism as -/// `js_fastify_req_params_object`. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_req_headers(ctx_handle: Handle) -> i64 { - if let Some(ctx) = get_handle::(ctx_handle) { - let cached = ctx.headers_object_cache.load(Ordering::Acquire); - if cached != 0 { - return cached as i64; - } - if let Some(obj_f64) = build_string_map_object(&ctx.headers) { - ctx.headers_object_cache - .store(obj_f64.to_bits(), Ordering::Release); - return obj_f64.to_bits() as i64; - } - } - TAG_UNDEFINED as i64 -} - -/// `request.headers['x-foo']` — single header lookup. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_req_header(ctx_handle: Handle, name: i64) -> *mut StringHeader { - let name = match string_from_nanboxed(name) { - Some(n) => n.to_lowercase(), - None => return std::ptr::null_mut(), - }; - if let Some(ctx) = get_handle::(ctx_handle) { - if let Some(value) = ctx.headers.get(&name) { - return alloc_string(value).as_raw(); - } - } - std::ptr::null_mut() -} - -/// `request.user` — accessor for user data attached by middleware. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_req_get_user_data(ctx_handle: Handle) -> f64 { - if let Some(ctx) = get_handle::(ctx_handle) { - return f64::from_bits(ctx.user_data); - } - f64::from_bits(TAG_UNDEFINED) -} - -/// `request.user = data` — setter for middleware-attached state. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_req_set_user_data(ctx_handle: Handle, data: f64) { - if let Some(ctx) = get_handle_mut::(ctx_handle) { - ctx.user_data = data.to_bits(); - } -} - -/// #1293 — membership probe for the well-known-flipped generic -/// dispatch path. Returns 1 iff `handle` is a live `FastifyContext` -/// in *this crate's* perry-ffi registry, 0 otherwise. -/// -/// When the well-known flip routes `fastify` to perry-ext-fastify, -/// perry-stdlib's `js_handle_method_dispatch` / `js_handle_property_dispatch` -/// can no longer see the request/reply handle — it lives in perry-ffi's -/// registry, not perry-stdlib's. So `(request as any).json()` / -/// `(request as any).body` (where the `as any` cast erased the static -/// type and codegen emitted a generic dynamic dispatch rather than a -/// `NativeMethodCall`) silently returned NaN / undefined. perry-stdlib's -/// external-fastify dispatch arms call this to confirm the handle is ours -/// before forwarding to the `js_fastify_req_*` / `js_fastify_ctx_*` -/// exports. Mirrors `js_ext_net_is_socket_handle` (perry-ext-net). -#[no_mangle] -pub unsafe extern "C" fn js_ext_fastify_is_context_handle(ctx_handle: Handle) -> i32 { - if get_handle::(ctx_handle).is_some() { - 1 - } else { - 0 - } -} - -// ============================================================================ -// FFI: Reply methods (Fastify style — `reply.status(...).send(...)`) -// ============================================================================ - -/// `reply.status(code)` — chainable, returns the same handle. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_reply_status(ctx_handle: Handle, code: f64) -> Handle { - if let Some(ctx) = get_handle_mut::(ctx_handle) { - ctx.status_code = code as u16; - } - ctx_handle -} - -/// `reply.header(name, value)` — chainable. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_reply_header( - ctx_handle: Handle, - name: i64, - value: i64, -) -> Handle { - let name = match string_from_nanboxed(name) { - Some(n) => n, - None => return ctx_handle, - }; - let value = match string_from_nanboxed(value) { - Some(v) => v, - None => return ctx_handle, - }; - if let Some(ctx) = get_handle_mut::(ctx_handle) { - ctx.response_headers.push((name, value)); - } - ctx_handle -} - -/// `reply.type(value)` — Fastify alias for `reply.header("content-type", value)`. -/// Returns the reply handle for chaining. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_reply_type(ctx_handle: Handle, value: i64) -> Handle { - let value = match string_from_nanboxed(value) { - Some(v) => v, - None => return ctx_handle, - }; - if let Some(ctx) = get_handle_mut::(ctx_handle) { - ctx.response_headers - .push(("content-type".to_string(), value)); - } - ctx_handle -} - -/// `reply.send(payload)` — finalize the response. Returns true if the -/// response was newly sent, false if `ctx.sent` was already true. -/// -/// When the payload is a `Buffer` / `Uint8Array` and the handler -/// hasn't pinned a `content-type` yet (`reply.type(...)`), the -/// response defaults to `application/octet-stream` so binary assets -/// don't ship as JSON.toString'd bytes (#1120). -#[no_mangle] -pub unsafe extern "C" fn js_fastify_reply_send(ctx_handle: Handle, data: f64) -> bool { - if let Some(ctx) = get_handle_mut::(ctx_handle) { - if ctx.sent { - return false; - } - let (body, kind) = jsvalue_to_response_body(data); - if kind == BodyKind::Binary - && !ctx - .response_headers - .iter() - .any(|(k, _)| k.eq_ignore_ascii_case("content-type")) - { - ctx.response_headers.push(( - "content-type".to_string(), - "application/octet-stream".to_string(), - )); - } - ctx.response_body = Some(body); - ctx.sent = true; - return true; - } - false -} - -// ============================================================================ -// FFI: Context methods (Hono style — `c.json(...)`, `c.text(...)`, …) -// ============================================================================ - -/// `c.json(data, status?)`. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_ctx_json(ctx_handle: Handle, data: f64, status: f64) -> f64 { - // #1240 — `request.json()` (Fetch API) shares the dispatch slot with - // `reply.json(data)` / `c.json(data, status)` because both receivers are - // backed by the same FastifyContext handle. A zero-arg call from user code - // arrives here with `data` padded to NaN-boxed undefined; in that case route - // to `js_fastify_req_json` so existing Fetch-style codebases keep working - // without touching `request.body`. - if data.to_bits() == TAG_UNDEFINED { - return js_fastify_req_json(ctx_handle); - } - - if let Some(ctx) = get_handle_mut::(ctx_handle) { - if status > 0.0 { - ctx.status_code = status as u16; - } - ctx.response_headers.push(( - "content-type".to_string(), - "application/json; charset=utf-8".to_string(), - )); - let body = jsvalue_to_json_string(data); - ctx.response_body = Some(body.into_bytes()); - ctx.sent = true; - } - f64::from_bits(TAG_UNDEFINED) -} - -/// `c.text(text, status?)`. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_ctx_text(ctx_handle: Handle, text: i64, status: f64) -> f64 { - let text = string_from_nanboxed(text).unwrap_or_default(); - if let Some(ctx) = get_handle_mut::(ctx_handle) { - if status > 0.0 { - ctx.status_code = status as u16; - } - ctx.response_headers.push(( - "content-type".to_string(), - "text/plain; charset=utf-8".to_string(), - )); - ctx.response_body = Some(text.into_bytes()); - ctx.sent = true; - } - f64::from_bits(TAG_UNDEFINED) -} - -/// `c.html(html, status?)`. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_ctx_html(ctx_handle: Handle, html: i64, status: f64) -> f64 { - let html = string_from_nanboxed(html).unwrap_or_default(); - if let Some(ctx) = get_handle_mut::(ctx_handle) { - if status > 0.0 { - ctx.status_code = status as u16; - } - ctx.response_headers.push(( - "content-type".to_string(), - "text/html; charset=utf-8".to_string(), - )); - ctx.response_body = Some(html.into_bytes()); - ctx.sent = true; - } - f64::from_bits(TAG_UNDEFINED) -} - -/// `c.redirect(url, status?)`. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_ctx_redirect(ctx_handle: Handle, url: i64, status: f64) -> f64 { - let url = string_from_nanboxed(url).unwrap_or_default(); - if let Some(ctx) = get_handle_mut::(ctx_handle) { - ctx.status_code = if status > 0.0 { status as u16 } else { 302 }; - ctx.response_headers.push(("location".to_string(), url)); - ctx.response_body = Some(Vec::new()); - ctx.sent = true; - } - f64::from_bits(TAG_UNDEFINED) -} - -// ============================================================================ -// Helpers — shared with server.rs -// ============================================================================ - -/// Convert a NaN-boxed JsValue to response bytes. Strings pass through -/// as-is; `Buffer` / `Uint8Array` ships its raw bytes (no UTF-8 -/// round-trip); everything else gets JSON-stringified. The caller -/// inspects the returned `BodyKind` to pick a default content-type -/// when the handler didn't set one. -/// -/// Issue #1120: pre-fix, every non-string value funnelled through -/// `js_json_stringify`. A returned Buffer surfaced as Buffer.toJSON's -/// `{"type":"Buffer","data":[...]}` payload (~6× bloat) and the -/// caller still pushed `application/json` for the content-type. -pub(crate) unsafe fn jsvalue_to_response_body(value: f64) -> (Vec, BodyKind) { - let jsv = JsValue::from_bits(value.to_bits()); - if jsv.is_string() { - // Fast path: copy the StringHeader's bytes straight into the response - // Vec, skipping the `extract_jsvalue_string` round-trip - // (`String::from_utf8_lossy(..).to_string().into_bytes()` — one extra - // heap allocation plus a redundant UTF-8 validity scan). The body - // writer streams the bytes out unchanged, so the intermediate `String` - // buys nothing on the common text-response path. The pointer math - // mirrors `string_from_header`. - // - // Only well-formed UTF-8 is safe to copy verbatim. Perry strings are - // WTF-8 and may carry lone surrogates (flagged - // `STRING_FLAG_HAS_LONE_SURROGATES`); copying those raw would emit - // invalid UTF-8 in the response. For flagged strings fall through to - // the `extract_jsvalue_string` path, whose `from_utf8_lossy` substitutes - // U+FFFD — preserving the pre-fast-path output. - let ptr = js_get_string_pointer_unified(value); - if ptr != 0 { - let header = ptr as *const StringHeader; - let well_formed = (*header).flags & STRING_FLAG_HAS_LONE_SURROGATES == 0; - if well_formed { - let len = (*header).byte_len as usize; - let data_ptr = (header as *const u8).add(std::mem::size_of::()); - let mut bytes = Vec::with_capacity(len); - bytes.extend_from_slice(std::slice::from_raw_parts(data_ptr, len)); - return (bytes, BodyKind::TextOrJson); - } - } - // Fallback to the lossy `String` round-trip — taken for WTF-8 strings - // with lone surrogates, and defensively if the unified pointer accessor - // returns 0 (should not happen for an `is_string()` value). - if let Some(s) = extract_jsvalue_string(value) { - return (s.into_bytes(), BodyKind::TextOrJson); - } - } - if let Some(bytes) = extract_buffer_bytes(value) { - return (bytes, BodyKind::Binary); - } - ( - jsvalue_to_json_string(value).into_bytes(), - BodyKind::TextOrJson, - ) -} - -/// Probe BUFFER_REGISTRY for a POINTER_TAG'd value. Returns the raw -/// bytes of the underlying `BufferHeader` payload if `value` is a -/// `Buffer` / `Uint8Array`; `None` otherwise. Shared with `server.rs`'s -/// `build_response_body` (#1120). -pub(crate) unsafe fn extract_buffer_bytes(value: f64) -> Option> { - let v = JsValue::from_bits(value.to_bits()); - if !v.is_pointer() { - return None; - } - let raw = (value.to_bits() & 0x0000_FFFF_FFFF_FFFF) as i64; - if js_buffer_is_buffer(raw) == 0 { - return None; - } - let buf = raw as *const BufferHeader; - if buf.is_null() { - return None; - } - perry_ffi::read_buffer_bytes(buf.cast()).map(<[u8]>::to_vec) -} - -/// Serialize a NaN-boxed JsValue as a JSON string. -pub(crate) unsafe fn jsvalue_to_json_string(value: f64) -> String { - let jsv = JsValue::from_bits(value.to_bits()); - if jsv.is_undefined() || jsv.is_null() { - return "null".to_string(); - } - if jsv.is_bool() { - return if jsv.to_bool() { - "true".to_string() - } else { - "false".to_string() - }; - } - if jsv.is_number() { - return format!("{}", value); - } - if jsv.is_string() { - if let Some(s) = extract_jsvalue_string(value) { - return serde_json::to_string(&s).unwrap_or_else(|_| format!("\"{}\"", s)); - } - } - if jsv.is_pointer() { - let str_ptr = js_json_stringify(value, 0); - if !str_ptr.is_null() { - if let Some(s) = string_from_header(str_ptr) { - return s; - } - } - } - let str_ptr = js_jsvalue_to_string(value); - if !str_ptr.is_null() { - if let Some(s) = string_from_header(str_ptr) { - return s; - } - } - "null".to_string() -} - -/// Build a NaN-boxed object from a `HashMap`. Returns -/// the boxed pointer as f64. None on allocation failure / empty. -unsafe fn build_string_map_object(map: &HashMap) -> Option { - let keys: Vec<&str> = map.keys().map(|s| s.as_str()).collect(); - let (packed, shape_id) = build_object_shape(&keys); - let count = keys.len() as u32; - let obj: *mut ObjectHeader = - js_object_alloc_with_shape(shape_id, count, packed.as_ptr(), packed.len() as u32); - if obj.is_null() { - return None; - } - for (i, key) in keys.iter().enumerate() { - if let Some(val) = map.get(*key) { - let s = alloc_string(val); - let v = JsValue::from_string_ptr(s.as_raw()); - js_object_set_field(obj, i as u32, v); - } - } - let v = JsValue::from_object_ptr(obj); - Some(f64::from_bits(v.bits())) -} - -// Suppress unused-import warnings for FFI types that may not appear -// in this module's signatures but need to remain in scope for the -// crate's exports. -#[allow(dead_code)] -fn _link_keepalive() -> Option<*mut ArrayHeader> { - None -} diff --git a/crates/perry-ext-fastify/src/lib.rs b/crates/perry-ext-fastify/src/lib.rs deleted file mode 100644 index c883ba516c..0000000000 --- a/crates/perry-ext-fastify/src/lib.rs +++ /dev/null @@ -1,899 +0,0 @@ -//! Native bindings for the npm `fastify` HTTP server framework. -//! -//! Replaces `perry-stdlib`'s in-tree `fastify/` module — same FFI -//! surface (`js_fastify_*` symbols), implemented on top of `perry-ffi` -//! v0.5 only (handle registry + JsValue + JsClosure + GC scanner + -//! spawn_blocking + notify_main_thread). hyper provides the HTTP -//! transport. -//! -//! # Architecture -//! -//! - `Fastify(opts?)` returns a `FastifyApp` handle. Routes / hooks / -//! plugins / error handler are registered via the per-method FFI -//! calls, all mutating that single handle. -//! - `app.listen({ port })` spawns a perry-ffi blocking task that -//! runs the hyper accept loop on the shared tokio runtime, then -//! enters a main-thread event loop that drains pending requests -//! from an mpsc channel. -//! - Each request is matched against the snapshot of routes captured -//! at `listen()` time, then dispatched: lifecycle hooks fire first -//! (any hook that calls `reply.send` aborts the chain), then the -//! route handler runs, then the response (which may carry a value -//! from the handler's return or an explicit `reply.send`) is sent -//! back via a oneshot channel. -//! - User closures (route handlers, hooks, error handler, plugin -//! bodies) are stored as raw `i64` pointers inside the -//! `FastifyApp`. A mutable GC root scanner keeps each closure live -//! and rewrites moved pointers after copied-minor GC so a `gc()` -//! call between registration and incoming-request dispatch can't -//! free them (issue #35 pattern in perry-stdlib's existing copy). -//! -//! # Punted gaps -//! -//! Documented here so future ports know what to extend: -//! -//! - **HTTP/2** — hyper's `http2` builder isn't wired up; we use -//! `http1::Builder::new()`. Adding a configurable -//! `http2: true` option-flag would require switching to -//! `hyper_util::server::conn::auto::Builder` for upgrade -//! negotiation. perry-stdlib's existing copy has the same gap. -//! - **WebSocket upgrade** — fastify exposes `app.register(websocket)` -//! for protocol upgrades; we don't support that path. Programs -//! that need a server-side WebSocket should reach for `ws` directly -//! (perry-ext-ws). -//! - **Multipart / file upload parsing** — `req.body` exposes raw -//! bytes; multipart structuring is left to user code (or -//! `perry-stdlib::framework::multipart`, which is not part of -//! the well-known flip). -//! - **Schema validation** — fastify's `schema` option (JSON Schema -//! on routes for input validation + serialization) is parsed but -//! not enforced. Programs needing validation should call -//! `validator` (perry-ext-validator) inside their handler. -//! - **Plugins** — `app.register(plugin, opts)` runs the plugin -//! synchronously and inherits a temporary prefix; nested plugin -//! isolation (separate `app` handles per plugin) is deferred. -//! - **Streaming bodies** — `req.body` collects the entire body -//! before dispatching. Cooperative streaming would require a -//! `spawn_async` perry-ffi surface (v0.6.0 followup, same gap as -//! perry-ext-http / perry-ext-ws). - -use std::sync::Once; - -use perry_ffi::{ - gc_register_mutable_root_scanner_named, iter_handles_of_mut, register_aux_event_pump, - GcRootVisitor, -}; - -mod app; -mod cluster_bind; -mod context; -mod router; -mod server; -mod upgrade; - -pub use app::*; -pub use context::*; -pub use router::*; -pub use server::*; - -// ============================================================================ -// GC root scanner -// ============================================================================ - -static GC_REGISTERED: Once = Once::new(); - -/// Register the fastify GC root scanner exactly once. User closures -/// (route handlers, lifecycle hooks, error handler, plugin bodies) -/// are stored as raw `i64` pointers inside `FastifyApp` handles. -/// Without this scanner, a malloc-triggered GC between registration -/// and incoming-request dispatch would sweep the handler closures — -/// same root cause as issue #35 for net.Socket listeners. -/// The same one-time hook registers Fastify's event pump and keepalive -/// contributor directly with the runtime. -pub(crate) fn ensure_gc_scanner_registered() { - GC_REGISTERED.call_once(|| { - gc_register_mutable_root_scanner_named("perry-ext-fastify", scan_fastify_roots); - register_aux_event_pump( - server::js_fastify_process_pending, - server::js_fastify_has_active, - ); - }); -} - -/// GC root scanner — walk every registered FastifyApp and visit every -/// closure pointer the wrapper has stashed. Closures are stored as raw -/// `i64`s so copied-minor GC can rewrite the slots directly. -fn scan_fastify_roots(visitor: &mut GcRootVisitor<'_>) { - iter_handles_of_mut::(|app| { - for route in app.routes.iter_mut() { - visitor.visit_i64_slot(&mut route.handler); - } - for cb in app - .hooks - .on_request - .iter_mut() - .chain(app.hooks.pre_parsing.iter_mut()) - .chain(app.hooks.pre_validation.iter_mut()) - .chain(app.hooks.pre_handler.iter_mut()) - .chain(app.hooks.pre_serialization.iter_mut()) - .chain(app.hooks.on_send.iter_mut()) - .chain(app.hooks.on_response.iter_mut()) - .chain(app.hooks.on_error.iter_mut()) - { - visitor.visit_i64_slot(cb); - } - if let Some(eh) = app.error_handler.as_mut() { - visitor.visit_i64_slot(eh); - } - for plugin in app.plugins.iter_mut() { - visitor.visit_i64_slot(&mut plugin.handler); - } - // #1113: upgrade handlers registered via - // `app.server.on("upgrade", cb)`. Visit slots mutably so - // copied-minor GC can rewrite closures if they move. - for cb in app.upgrade_handlers.iter_mut() { - visitor.visit_i64_slot(cb); - } - }); - - // Per-request `req.params` / `req.query` / `req.headers` JS objects are - // cached on each live FastifyContext (`params_object_cache` / - // `query_object_cache` / `headers_object_cache`). Visit them as NaN-boxed - // value slots so a copying GC between the first accessor build and a later - // read keeps the object alive AND relocates the cached pointer. - // `0` means uncached; only a real object pointer (POINTER_TAG `0x7FFD`) is - // visited. `get_mut()` is sound here — the GC scan has exclusive access to - // each handle. - iter_handles_of_mut::(|ctx| { - let params_slot = ctx.params_object_cache.get_mut(); - if *params_slot >> 48 == 0x7FFD { - visitor.visit_nanbox_u64_slot(params_slot); - } - let query_slot = ctx.query_object_cache.get_mut(); - if *query_slot >> 48 == 0x7FFD { - visitor.visit_nanbox_u64_slot(query_slot); - } - let headers_slot = ctx.headers_object_cache.get_mut(); - if *headers_slot >> 48 == 0x7FFD { - visitor.visit_nanbox_u64_slot(headers_slot); - } - }); -} - -// ============================================================================ -// Tests -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - use perry_ffi::{drop_handle, get_handle, register_handle}; - use std::collections::HashMap; - use std::sync::{Mutex, MutexGuard}; - - static GC_TEST_LOCK: Mutex<()> = Mutex::new(()); - - struct GcTestGuard { - frame: u64, - previous_force_evacuation: i32, - _lock: MutexGuard<'static, ()>, - } - - impl GcTestGuard { - fn new() -> Self { - let lock = GC_TEST_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let previous_force_evacuation = - perry_runtime::gc::js_gc_force_evacuation_test_override(1); - perry_runtime::gc::js_gc_write_barriers_emitted(1); - let frame = perry_runtime::gc::js_shadow_frame_push(0); - Self { - frame, - previous_force_evacuation, - _lock: lock, - } - } - } - - impl Drop for GcTestGuard { - fn drop(&mut self) { - perry_runtime::gc::js_shadow_frame_pop(self.frame); - perry_runtime::gc::js_gc_write_barriers_emitted(0); - perry_runtime::gc::js_gc_force_evacuation_test_override(self.previous_force_evacuation); - } - } - - fn young_gc_root() -> i64 { - perry_runtime::arena::arena_alloc_gc(32, 8, perry_runtime::gc::GC_TYPE_STRING) as i64 - } - - fn assert_rewritten(before: i64, after: i64) { - assert_ne!(after, before); - assert!(perry_runtime::arena::pointer_in_nursery(after as usize)); - } - - #[test] - fn gc_scanner_registers_idempotently() { - // Calling ensure_gc_scanner_registered multiple times must - // not panic and must not register the scanner twice (Once - // guarantees). - ensure_gc_scanner_registered(); - ensure_gc_scanner_registered(); - ensure_gc_scanner_registered(); - } - - #[test] - fn gc_mutable_scanner_rewrites_registered_roots() { - let _guard = GcTestGuard::new(); - perry_ffi::gc_register_mutable_root_scanner_named("perry-ext-fastify", scan_fastify_roots); - - let route = young_gc_root(); - let hook = young_gc_root(); - let error = young_gc_root(); - let plugin = young_gc_root(); - let mut app = FastifyApp::new(); - app.add_route("GET", "/gc", route); - app.add_hook("onRequest", hook); - app.set_error_handler(error); - app.plugins.push(Plugin { - handler: plugin, - prefix: "/api".to_string(), - }); - let handle = register_handle(app); - - let _ = perry_runtime::gc::gc_collect_minor(); - - { - let app = get_handle::(handle).expect("fastify handle should remain live"); - assert_rewritten(route, app.routes[0].handler); - assert_rewritten(hook, app.hooks.on_request[0]); - assert_rewritten(error, app.error_handler.expect("error handler")); - assert_rewritten(plugin, app.plugins[0].handler); - } - drop_handle(handle); - } - - /// A fresh `FastifyContext` must have both per-request object caches empty - /// (`0`), and the slots round-trip the NaN-boxed bits they're built to hold. - #[test] - fn context_object_caches_start_empty() { - use std::sync::atomic::Ordering; - let ctx = FastifyContext::new( - 7, - "GET".to_string(), - "/users/:id".to_string(), - HashMap::new(), - None, - HashMap::new(), - ); - assert_eq!(ctx.params_object_cache.load(Ordering::Acquire), 0); - assert_eq!(ctx.query_object_cache.load(Ordering::Acquire), 0); - assert_eq!(ctx.headers_object_cache.load(Ordering::Acquire), 0); - - // Round-trip a synthetic NaN-boxed object pointer through each slot, so a - // wiring regression in either cache is caught. - let nan_boxed = 0x7FFD_0000_DEAD_BEEFu64; - ctx.params_object_cache.store(nan_boxed, Ordering::Release); - assert_eq!(ctx.params_object_cache.load(Ordering::Acquire), nan_boxed); - ctx.query_object_cache.store(nan_boxed, Ordering::Release); - assert_eq!(ctx.query_object_cache.load(Ordering::Acquire), nan_boxed); - ctx.headers_object_cache.store(nan_boxed, Ordering::Release); - assert_eq!(ctx.headers_object_cache.load(Ordering::Acquire), nan_boxed); - } - - /// The `req.params` / `req.query` / `req.headers` object accessors build the - /// object once and return the cached NaN-boxed pointer on subsequent calls - /// within a request. - #[test] - fn req_object_accessors_cache_within_request() { - use std::sync::atomic::Ordering; - let _guard = GcTestGuard::new(); - let mut params = HashMap::new(); - params.insert("id".to_string(), "42".to_string()); - let mut headers = HashMap::new(); - headers.insert("host".to_string(), "example.com".to_string()); - let ctx = FastifyContext::new( - 0, - "GET".to_string(), - "/users/42?trace=1".to_string(), - headers, - None, - params, - ); - let h = register_handle(ctx); - unsafe { - // params: two reads return identical (cached) bits. - let a = js_fastify_req_params_object(h); - let b = js_fastify_req_params_object(h); - assert_eq!(a.to_bits(), b.to_bits(), "second params read is cached"); - assert_eq!(a.to_bits() >> 48, 0x7FFD, "params object is POINTER_TAG'd"); - let cached_p = get_handle::(h) - .unwrap() - .params_object_cache - .load(Ordering::Acquire); - assert_eq!( - cached_p, - a.to_bits(), - "params cache holds the returned object" - ); - - // query: same, and a distinct slot from params. - let c = js_fastify_req_query_object(h); - let d = js_fastify_req_query_object(h); - assert_eq!(c.to_bits(), d.to_bits(), "second query read is cached"); - assert_eq!(c.to_bits() >> 48, 0x7FFD, "query object is POINTER_TAG'd"); - assert_ne!( - a.to_bits(), - c.to_bits(), - "query cache must not reuse the params object" - ); - let cached_q = get_handle::(h) - .unwrap() - .query_object_cache - .load(Ordering::Acquire); - assert_eq!( - cached_q, - c.to_bits(), - "query cache holds the returned object" - ); - - // headers: same caching behaviour, a third distinct slot. - let e = js_fastify_req_headers(h); - let f = js_fastify_req_headers(h); - assert_eq!(e, f, "second headers read is cached"); - assert_eq!((e as u64) >> 48, 0x7FFD, "headers object is POINTER_TAG'd"); - assert_ne!( - e as u64, - a.to_bits(), - "headers cache must not reuse the params object" - ); - assert_ne!( - e as u64, - c.to_bits(), - "headers cache must not reuse the query object" - ); - let cached_h = get_handle::(h) - .unwrap() - .headers_object_cache - .load(Ordering::Acquire); - assert_eq!( - cached_h, e as u64, - "headers cache holds the returned object" - ); - } - drop_handle(h); - } - - /// GC soundness: the root scanner must visit each live FastifyContext's - /// cached object slots so a copying GC keeps them alive AND relocates the - /// cached pointer in place — and must preserve the NaN-box `POINTER_TAG` - /// (a raw `visit_i64_slot` would strip it). Without this rooting a GC between - /// the first `req.params` build and a later read would dangle the cache. - #[test] - fn gc_scanner_rewrites_cached_object_slots() { - use std::sync::atomic::Ordering; - let _guard = GcTestGuard::new(); - perry_ffi::gc_register_mutable_root_scanner_named("perry-ext-fastify", scan_fastify_roots); - - let params_obj = young_gc_root(); - let query_obj = young_gc_root(); - let headers_obj = young_gc_root(); - let nanbox = |addr: i64| 0x7FFD_0000_0000_0000u64 | (addr as u64 & 0x0000_FFFF_FFFF_FFFF); - let mask = 0x0000_FFFF_FFFF_FFFFu64; - - let ctx = FastifyContext::new( - 0, - "GET".to_string(), - "/x".to_string(), - HashMap::new(), - None, - HashMap::new(), - ); - ctx.params_object_cache - .store(nanbox(params_obj), Ordering::Release); - ctx.query_object_cache - .store(nanbox(query_obj), Ordering::Release); - ctx.headers_object_cache - .store(nanbox(headers_obj), Ordering::Release); - let handle = register_handle(ctx); - - let _ = perry_runtime::gc::gc_collect_minor(); - - { - let ctx = - get_handle::(handle).expect("context handle should remain live"); - let p = ctx.params_object_cache.load(Ordering::Acquire); - let q = ctx.query_object_cache.load(Ordering::Acquire); - let hdr = ctx.headers_object_cache.load(Ordering::Acquire); - // Tag preserved (visit_nanbox_u64_slot, not visit_i64_slot). - assert_eq!(p >> 48, 0x7FFD, "params cache keeps POINTER_TAG after GC"); - assert_eq!(q >> 48, 0x7FFD, "query cache keeps POINTER_TAG after GC"); - assert_eq!( - hdr >> 48, - 0x7FFD, - "headers cache keeps POINTER_TAG after GC" - ); - // Address relocated to the moved object, still in the nursery. - assert_rewritten(params_obj, (p & mask) as i64); - assert_rewritten(query_obj, (q & mask) as i64); - assert_rewritten(headers_obj, (hdr & mask) as i64); - } - drop_handle(handle); - } - - #[test] - fn route_registration_round_trip() { - let mut app = FastifyApp::new(); - app.add_route("GET", "/", 0); - app.add_route("GET", "/users", 1); - app.add_route("GET", "/users/:id", 2); - app.add_route("POST", "/users", 3); - assert_eq!(app.routes.len(), 4); - - // Match - let (route, params) = app.match_route("GET", "/users/42").unwrap(); - assert_eq!(route.handler, 2); - assert_eq!(params.get("id"), Some(&"42".to_string())); - - // Wrong method → no match - assert!(app.match_route("DELETE", "/users").is_none()); - } - - #[test] - fn hooks_register_in_order() { - let mut app = FastifyApp::new(); - app.add_hook("onRequest", 1); - app.add_hook("preHandler", 2); - app.add_hook("preHandler", 3); - app.add_hook("onResponse", 4); - assert_eq!(app.hooks.on_request, vec![1]); - assert_eq!(app.hooks.pre_handler, vec![2, 3]); - assert_eq!(app.hooks.on_response, vec![4]); - - // Unknown hook is silently ignored (matches perry-stdlib). - app.add_hook("notARealHook", 999); - assert!(!app.hooks.on_request.contains(&999)); - } - - #[test] - fn request_response_shape() { - let mut headers = HashMap::new(); - headers.insert("content-type".to_string(), "application/json".to_string()); - headers.insert("authorization".to_string(), "Bearer x".to_string()); - let mut params = HashMap::new(); - params.insert("id".to_string(), "42".to_string()); - - let ctx = FastifyContext::new( - 7, - "GET".to_string(), - "/users/42?foo=bar".to_string(), - headers, - Some(b"{}".to_vec()), - params, - ); - - // URL gets split - assert_eq!(ctx.method, "GET"); - assert_eq!(ctx.url, "/users/42"); - assert_eq!(ctx.query_string, "foo=bar"); - // Defaults - assert_eq!(ctx.status_code, 200); - assert!(!ctx.sent); - assert!(ctx.response_body.is_none()); - - // Query param parsing - assert_eq!(ctx.get_query_param("foo"), Some("bar".to_string())); - assert_eq!(ctx.get_query_param("missing"), None); - - // Param accessor - assert_eq!(ctx.params.get("id"), Some(&"42".to_string())); - - // Body - assert_eq!(ctx.body_string(), Some("{}".to_string())); - } - - #[test] - fn req_url_includes_query_string() { - // #1705 — `c.req.url` / `req.url` must carry the query string like - // Node/Fastify (`@hono/perry-server` builds `new Request(host + req.url)` - // from it). The context splits the target internally, so the FFI getter - // reconstructs `path?query`. - let with_query = FastifyContext::new( - 0, - "GET".to_string(), - "/u3?a=1&b=2".to_string(), - HashMap::new(), - None, - HashMap::new(), - ); - let h = register_handle(with_query); - let got = unsafe { crate::context::string_from_header(super::js_fastify_req_url(h)) }; - assert_eq!(got.as_deref(), Some("/u3?a=1&b=2")); - drop_handle(h); - - // No query → bare path, no trailing '?'. - let no_query = FastifyContext::new( - 0, - "GET".to_string(), - "/u3".to_string(), - HashMap::new(), - None, - HashMap::new(), - ); - let h2 = register_handle(no_query); - let got2 = unsafe { crate::context::string_from_header(super::js_fastify_req_url(h2)) }; - assert_eq!(got2.as_deref(), Some("/u3")); - drop_handle(h2); - } - - #[test] - fn ext_fastify_is_context_handle_membership() { - // #1293 — the membership probe perry-stdlib's external-fastify - // dispatch arms consult before forwarding `(request as any).json()` - // / `(request as any).body` to our `js_fastify_*` exports. - let ctx = FastifyContext::new( - 0, - "POST".to_string(), - "/x".to_string(), - HashMap::new(), - Some(b"{}".to_vec()), - HashMap::new(), - ); - let ctx_handle = register_handle(ctx); - // A non-existent handle id is never ours. - assert_eq!(unsafe { js_ext_fastify_is_context_handle(0) }, 0); - assert_eq!( - unsafe { js_ext_fastify_is_context_handle(ctx_handle + 9_999) }, - 0 - ); - // A live FastifyContext handle is ours. - assert_eq!(unsafe { js_ext_fastify_is_context_handle(ctx_handle) }, 1); - // A FastifyApp handle is NOT a context (type-discriminated downcast). - let app_handle = register_handle(FastifyApp::new()); - assert_eq!(unsafe { js_ext_fastify_is_context_handle(app_handle) }, 0); - - drop_handle(ctx_handle); - drop_handle(app_handle); - // After the context is dropped the probe reports it gone. - assert_eq!(unsafe { js_ext_fastify_is_context_handle(ctx_handle) }, 0); - } - - /// Regression: the per-request dispatcher (`process_request` in `server.rs`) - /// registers a fresh `FastifyContext` in the handle registry for every - /// request and must drop that handle once the response is sent. Without the - /// trailing `drop_handle`, every served request would leak one context — its - /// headers map, body, params, and response state — into the global registry, - /// growing unbounded under sustained load until allocation fails. - /// - /// This drives an actual request *through* `process_request` rather than - /// calling `drop_handle` directly, so it pins the real dispatch invariant: a - /// change that removed the cleanup at the tail of `process_request` leaves - /// the handle live and fails here. The request matches no route, so the - /// dispatcher takes the no-handler path (`undefined` result → - /// `build_response_body` short-circuits to `{}` with no JS allocation), - /// while still exercising the full register → build response → drop - /// lifecycle. `process_request` returns the context handle it registered - /// (and must have freed) so the assertion targets that exact handle — - /// race-free under parallel test execution. - #[test] - fn context_handle_dropped_after_dispatch() { - let app_handle = register_handle(FastifyApp::new()); - let (response_tx, mut response_rx) = tokio::sync::oneshot::channel(); - let pending = crate::server::FastifyPendingRequest { - method: "GET".to_string(), - path: "/health".to_string(), - headers: HashMap::new(), - body: None, - params: HashMap::new(), - response_tx, - }; - - // Drive the real dispatcher; it returns the context handle it - // registered and freed. - let ctx_handle = crate::server::process_request(app_handle, pending); - - // The request actually flowed through: the no-route path still sends a - // `{}` response, so the oneshot has a value. - assert!( - response_rx.try_recv().is_ok(), - "process_request should send a response even for an unmatched route" - ); - - // The invariant: the context handle `process_request` created must be - // gone. A missing `drop_handle` at the tail would leave it live here. - assert!( - get_handle::(ctx_handle).is_none(), - "FastifyContext handle leaked: still present after process_request" - ); - - drop_handle(app_handle); - } - - #[test] - fn port_extraction_safe_defaults() { - // Object literal pattern verified through the wider unit - // tests; here we exercise the bare-number pattern + the - // missing-port pattern indirectly via FastifyApp::new() + - // manual exec. We can't call extract_port directly (unsafe - // + needs a NaN-boxed JsValue), so we just verify the - // FastifyApp default-ports correctly. - let app = FastifyApp::new(); - assert!(app.routes.is_empty()); - assert!(app.hooks.on_request.is_empty()); - assert!(app.hooks.pre_handler.is_empty()); - assert!(app.error_handler.is_none()); - assert!(app.prefix.is_empty()); - - // Plugin prefix path - let app = FastifyApp::with_prefix("/api/v1".to_string()); - assert_eq!(app.prefix, "/api/v1"); - - // Routes inherit prefix - let mut app = FastifyApp::with_prefix("/api".to_string()); - app.add_route("GET", "/users", 1); - // Match through the full path. - let (route, _) = app.match_route("GET", "/api/users").unwrap(); - assert_eq!(route.handler, 1); - // The bare path must NOT match (prefix is required). - assert!(app.match_route("GET", "/users").is_none()); - } - - #[test] - fn error_handler_setter() { - let mut app = FastifyApp::new(); - assert!(app.error_handler.is_none()); - app.set_error_handler(42); - assert_eq!(app.error_handler, Some(42)); - } - - #[test] - fn static_index_population_and_lookup() { - let mut app = FastifyApp::new(); - app.add_route("GET", "/ping", 11); - app.add_route("POST", "/users", 22); - app.add_route("GET", "/users/:id", 33); // parametric — not indexed - app.add_route("GET", "/static/*", 44); // wildcard — not indexed - - // Only the two fully-static routes are indexed. - assert!(app.static_index.contains_key("GET /ping")); - assert!(app.static_index.contains_key("POST /users")); - assert_eq!(app.static_index.len(), 2); - - // Indexed lookup returns the correct handler and empty params. - let (route, params) = app.match_route("GET", "/ping").expect("static hit"); - assert_eq!(route.handler, 11); - assert!(params.is_empty()); - - // A query string on the request must not break the static-index hit. - let (route, params) = app - .match_route("GET", "/ping?trace=1") - .expect("static hit with query string"); - assert_eq!(route.handler, 11); - assert!(params.is_empty()); - - // Parametric routes still match via the linear-scan fallback, with params. - let (route, params) = app.match_route("GET", "/users/42").expect("parametric hit"); - assert_eq!(route.handler, 33); - assert_eq!(params.get("id").map(String::as_str), Some("42")); - - // Method mismatch must not short-circuit: only POST /users is registered, - // so GET /users falls through to None rather than hitting the POST entry. - assert!(app.match_route("GET", "/users").is_none()); - } - - #[test] - fn static_index_respects_prefix() { - let mut app = FastifyApp::with_prefix("/api".to_string()); - app.add_route("GET", "/users", 7); - // The index key is the full prefixed path, not the bare registration path. - assert!(app.static_index.contains_key("GET /api/users")); - assert!(!app.static_index.contains_key("GET /users")); - let (route, _) = app - .match_route("GET", "/api/users") - .expect("prefixed static hit"); - assert_eq!(route.handler, 7); - } - - /// The index is consulted before the linear scan, so it must not let a later - /// static route jump ahead of an earlier parametric/wildcard route that also - /// matches — first-registered-wins has to survive. - #[test] - fn static_index_preserves_registration_precedence() { - // Parametric `/:slug` registered FIRST, colliding static `/static` second. - let mut app = FastifyApp::new(); - app.add_route("GET", "/:slug", 1); // parametric, registered first - app.add_route("GET", "/static", 2); // static, collides with /:slug - - // The shadowed static route is deliberately NOT indexed... - assert!(!app.static_index.contains_key("GET /static")); - // ...so the earlier parametric route still wins through the linear scan. - let (route, params) = app.match_route("GET", "/static").expect("hit"); - assert_eq!(route.handler, 1); - assert_eq!(params.get("slug").map(String::as_str), Some("static")); - - // Reverse order: static registered first IS indexed and wins. - let mut app2 = FastifyApp::new(); - app2.add_route("GET", "/static", 2); // static first - app2.add_route("GET", "/:slug", 1); // parametric second - assert!(app2.static_index.contains_key("GET /static")); - assert_eq!(app2.match_route("GET", "/static").unwrap().0.handler, 2); - - // Wildcard `/static/*` registered FIRST: the later static `/static/foo` - // must not bypass it via the index — the wildcard still wins and captures. - let mut app3 = FastifyApp::new(); - app3.add_route("GET", "/static/*", 3); // wildcard, registered first - app3.add_route("GET", "/static/foo", 4); // static, shadowed by the wildcard - assert!(!app3.static_index.contains_key("GET /static/foo")); - let (route, params) = app3.match_route("GET", "/static/foo").expect("hit"); - assert_eq!(route.handler, 3); - assert_eq!(params.get("*").map(String::as_str), Some("foo")); - } - - /// Redundant slashes in a registered path must not push a static route off - /// the O(1) index: `RoutePattern` ignores empty segments, so the index key - /// has to normalize the same way (`/api//users` → `GET /api/users`). - #[test] - fn static_index_normalizes_redundant_slashes() { - let mut app = FastifyApp::new(); - app.add_route("GET", "/api//users", 9); - // Indexed under the normalized key, not the raw double-slash path. - assert!(app.static_index.contains_key("GET /api/users")); - assert!(!app.static_index.contains_key("GET /api//users")); - // And a normal request resolves through the fast path. - assert_eq!(app.match_route("GET", "/api/users").unwrap().0.handler, 9); - } - - /// Exact-duplicate static routes keep the first registration (matching the - /// linear scan's first-match behaviour) rather than flipping to last-wins. - #[test] - fn static_index_duplicate_routes_keep_first() { - let mut app = FastifyApp::new(); - app.add_route("GET", "/dup", 100); - app.add_route("GET", "/dup", 200); - assert_eq!(app.static_index.get("GET /dup"), Some(&0)); - assert_eq!(app.match_route("GET", "/dup").unwrap().0.handler, 100); - } - - /// `add_route` stores the method upper-cased, so `match_route` must normalize - /// the lookup method too — a lowercase/mixed-case caller resolves through both - /// the static index and the linear-scan fallback. - #[test] - fn match_route_normalizes_method_case() { - let mut app = FastifyApp::new(); - app.add_route("GET", "/ping", 11); // static -> index - app.add_route("GET", "/users/:id", 22); // parametric -> scan - - // Static, indexed: lowercase + mixed-case both hit. - assert_eq!(app.match_route("get", "/ping").unwrap().0.handler, 11); - assert_eq!(app.match_route("GeT", "/ping").unwrap().0.handler, 11); - // Parametric, scanned: same normalization on the slow path. - let (route, params) = app.match_route("get", "/users/7").expect("hit"); - assert_eq!(route.handler, 22); - assert_eq!(params.get("id").map(String::as_str), Some("7")); - } - - /// Regression: the per-request dispatch in `process_request` must MOVE the - /// pending request's headers/body/params into `FastifyContext` (via - /// `mem::take` / `Option::take`) rather than clone them — each is consumed - /// exactly once, so cloning fires three redundant per-request allocations - /// (two `HashMap`s + one `Vec`, dominated by the O(headers) header-map clone). - /// - /// This drives `build_context_from_pending` — the *same* helper - /// `process_request` uses to construct the context — so the test fails if - /// that construction reverts to cloning: after the call the source `pending` - /// collections must be empty (a clone would leave them populated, the canary), - /// while `method`/`path` must survive (they're reused for the route match). - #[test] - fn process_request_moves_pending_fields_not_clone() { - use crate::server::{build_context_from_pending, FastifyPendingRequest}; - - let mut headers = HashMap::new(); - headers.insert("content-type".to_string(), "application/json".to_string()); - headers.insert("x-trace-id".to_string(), "abc-123".to_string()); - let mut params = HashMap::new(); - params.insert("id".to_string(), "42".to_string()); - - // The response channel is irrelevant to context construction; a dropped - // receiver is fine — the helper never touches `response_tx`. - let (response_tx, _response_rx) = tokio::sync::oneshot::channel(); - let mut pending = FastifyPendingRequest { - method: "POST".to_string(), - path: "/users/42".to_string(), - headers, - body: Some(b"{\"hello\":\"world\"}".to_vec()), - params, - response_tx, - }; - - // Exercise the production construction path. - let ctx = build_context_from_pending(7, &mut pending); - - // (a) The context received the original values (incl. the request_id). - assert_eq!(ctx.request_id, 7); - assert_eq!(ctx.method, "POST"); - assert_eq!(ctx.headers.len(), 2); - assert_eq!( - ctx.headers.get("content-type").map(String::as_str), - Some("application/json") - ); - assert_eq!(ctx.params.get("id").map(String::as_str), Some("42")); - assert_eq!(ctx.body.as_deref(), Some(&b"{\"hello\":\"world\"}"[..])); - - // (b) Move semantics — `pending`'s collections are empty post-construction. - // A clone-based helper would leave them populated, failing the test. - assert!(pending.headers.is_empty()); - assert!(pending.params.is_empty()); - assert!(pending.body.is_none()); - - // (c) method/path are intentionally NOT consumed — reused for the route match. - assert_eq!(pending.method, "POST"); - assert_eq!(pending.path, "/users/42"); - } - - /// The string fast path in `jsvalue_to_response_body` copies the - /// `StringHeader` bytes directly, bypassing the `extract_jsvalue_string` - /// round-trip. It must reproduce the source bytes exactly — including - /// multi-byte UTF-8 — and tag the body `TextOrJson`. - #[test] - fn jsvalue_to_response_body_string_fast_path() { - let _guard = GcTestGuard::new(); - // SAFETY: `alloc_string` returns a live `StringHeader`; we NaN-box it and - // read it back synchronously with no intervening GC. - unsafe { - for sample in ["Status: UP [200]", "héllo → 世界 🚀", ""] { - let s = perry_ffi::alloc_string(sample); - assert!(!s.is_null(), "alloc_string returned null for {sample:?}"); - let value = f64::from_bits(perry_ffi::nanbox_string_bits(s.as_raw())); - - let (bytes, kind) = crate::context::jsvalue_to_response_body(value); - assert_eq!(bytes, sample.as_bytes(), "byte-exact round-trip"); - assert_eq!(kind, crate::context::BodyKind::TextOrJson); - } - } - } - - /// A WTF-8 string carrying a lone surrogate must NOT take the verbatim-copy - /// fast path (that would emit invalid UTF-8). It has to fall back to the - /// lossy `String` round-trip, substituting U+FFFD — exactly the - /// pre-fast-path behaviour. - #[test] - fn jsvalue_to_response_body_lone_surrogate_falls_back_to_lossy() { - let _guard = GcTestGuard::new(); - unsafe { - // "hi" + lone surrogate U+D800 (WTF-8: ED A0 80) + "!". - let wtf8: &[u8] = b"hi\xED\xA0\x80!"; - let ptr = - perry_runtime::string::js_string_from_wtf8_bytes(wtf8.as_ptr(), wtf8.len() as u32); - assert!(!ptr.is_null()); - // The constructor flags the string as carrying lone surrogates, so the - // fast path must skip it (otherwise this test wouldn't exercise the guard). - assert_ne!( - (*ptr).flags & perry_runtime::string::STRING_FLAG_HAS_LONE_SURROGATES, - 0, - "expected js_string_from_wtf8_bytes to flag the lone surrogate" - ); - // The two `StringHeader` types are layout-identical across crates. - let value = f64::from_bits(perry_ffi::nanbox_string_bits( - ptr as *mut perry_ffi::StringHeader, - )); - - let (bytes, kind) = crate::context::jsvalue_to_response_body(value); - // Output is the lossy form (valid UTF-8), not the raw WTF-8 bytes. - assert_eq!( - bytes, - String::from_utf8_lossy(wtf8).into_owned().into_bytes() - ); - assert!( - std::str::from_utf8(&bytes).is_ok(), - "response body must be valid UTF-8" - ); - assert_ne!( - bytes.as_slice(), - wtf8, - "raw WTF-8 bytes must not be emitted verbatim" - ); - assert_eq!(kind, crate::context::BodyKind::TextOrJson); - } - } -} diff --git a/crates/perry-ext-fastify/src/router.rs b/crates/perry-ext-fastify/src/router.rs deleted file mode 100644 index 2cdda70758..0000000000 --- a/crates/perry-ext-fastify/src/router.rs +++ /dev/null @@ -1,150 +0,0 @@ -//! Route pattern parsing and matching. -//! -//! Supports: -//! - Static segments: `/users`, `/api/v1` -//! - Parameters: `/users/:id`, `/posts/:postId/comments/:commentId` -//! - Wildcards: `/static/*` (captures rest of path) - -use std::collections::HashMap; - -/// A segment in a route pattern. -#[derive(Debug, Clone, PartialEq)] -pub enum Segment { - /// Static path segment (e.g., "users"). - Static(String), - /// Named parameter (e.g., ":id" captures "id"). - Param(String), - /// Wildcard captures rest of path. - Wildcard, -} - -/// Parsed route pattern for efficient matching. -#[derive(Debug, Clone)] -pub struct RoutePattern { - /// Pattern segments. - pub segments: Vec, - /// Original pattern string. - pub raw: String, -} - -impl RoutePattern { - /// Parse a route pattern string into segments. - pub fn parse(path: &str) -> Self { - let mut segments = Vec::new(); - let path = path.trim_start_matches('/'); - - if path.is_empty() { - return Self { - segments, - raw: "/".to_string(), - }; - } - - for part in path.split('/') { - if part.is_empty() { - continue; - } - let segment = if let Some(rest) = part.strip_prefix(':') { - Segment::Param(rest.to_string()) - } else if part == "*" { - Segment::Wildcard - } else { - Segment::Static(part.to_string()) - }; - segments.push(segment); - } - - Self { - segments, - raw: path.to_string(), - } - } - - /// Match a request path against this pattern. - /// - /// Returns `Some(params)` if the path matches; `None` otherwise. - pub fn match_path(&self, path: &str) -> Option> { - let path = path.trim_start_matches('/'); - let path = path.split('?').next().unwrap_or(path); - let path_parts: Vec<&str> = if path.is_empty() { - Vec::new() - } else { - path.split('/').filter(|s| !s.is_empty()).collect() - }; - - if self.segments.is_empty() { - return if path_parts.is_empty() { - Some(HashMap::new()) - } else { - None - }; - } - - let mut params = HashMap::new(); - let mut path_idx = 0; - - for segment in &self.segments { - match segment { - Segment::Static(expected) => { - if path_idx >= path_parts.len() || path_parts[path_idx] != expected { - return None; - } - path_idx += 1; - } - Segment::Param(name) => { - if path_idx >= path_parts.len() { - return None; - } - params.insert(name.clone(), path_parts[path_idx].to_string()); - path_idx += 1; - } - Segment::Wildcard => { - let rest: String = path_parts[path_idx..].join("/"); - params.insert("*".to_string(), rest); - return Some(params); - } - } - } - - if path_idx == path_parts.len() { - Some(params) - } else { - None - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn static_route_matches() { - let pattern = RoutePattern::parse("/users"); - assert!(pattern.match_path("/users").is_some()); - assert!(pattern.match_path("/posts").is_none()); - assert!(pattern.match_path("/users/123").is_none()); - } - - #[test] - fn param_route_extracts() { - let pattern = RoutePattern::parse("/users/:id"); - let params = pattern.match_path("/users/42").unwrap(); - assert_eq!(params.get("id"), Some(&"42".to_string())); - assert!(pattern.match_path("/users").is_none()); - } - - #[test] - fn wildcard_captures_rest() { - let pattern = RoutePattern::parse("/static/*"); - let params = pattern.match_path("/static/css/style.css").unwrap(); - assert_eq!(params.get("*"), Some(&"css/style.css".to_string())); - } - - #[test] - fn query_string_is_ignored() { - let pattern = RoutePattern::parse("/users/:id"); - let params = pattern.match_path("/users/7?foo=bar").unwrap(); - assert_eq!(params.get("id"), Some(&"7".to_string())); - } -} diff --git a/crates/perry-ext-fastify/src/server.rs b/crates/perry-ext-fastify/src/server.rs deleted file mode 100644 index 0d05dfcd53..0000000000 --- a/crates/perry-ext-fastify/src/server.rs +++ /dev/null @@ -1,1835 +0,0 @@ -//! HTTP server loop and request dispatch (hyper-based). -//! -//! `js_fastify_listen` is a blocking call: TS code calls -//! `app.listen({ port: 3000 })` and the wrapper enters an event loop -//! that doesn't return until the program exits. The actual TCP -//! accept loop lives on a perry-ffi-spawned blocking task; the main -//! thread receives `FastifyPendingRequest`s through an `mpsc` -//! channel and invokes the user's TS handler synchronously, then -//! sends the response back via a oneshot channel. - -use std::collections::HashMap; -use std::net::SocketAddr; -use std::os::raw::c_int; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; - -use bytes::Bytes; -use http_body_util::{BodyExt, Full}; -use hyper::server::conn::http1; -use hyper::service::service_fn; -use hyper::{body::Incoming, Request, Response, StatusCode}; -use hyper_util::rt::TokioIo; -use tokio::net::TcpListener; -use tokio::sync::{mpsc, oneshot}; - -use perry_ffi::{ - alloc_string, get_handle, get_handle_mut, iter_handle_ids_of, read_bytes, register_handle, - Handle, JsClosure, JsString, JsValue, RawClosureHeader, StringHeader, -}; - -use crate::app::{ClosurePtr, FastifyApp}; -use crate::context::{extract_buffer_bytes, jsvalue_to_response_body, BodyKind, FastifyContext}; -use crate::router::RoutePattern; - -const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; -const PTR_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; -const TAG_NULL: u64 = 0x7FFC_0000_0000_0002; -const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; -const GC_HEADER_SIZE: usize = 8; -const GC_TYPE_ERROR: u8 = 7; - -struct ClosureCallResult { - value: f64, - thrown: Option, -} - -enum HookOutcome { - Continue, - Sent, - Error(f64), -} - -#[derive(Clone)] -struct RouteMatcher { - method: String, - pattern: RoutePattern, -} - -impl RouteMatcher { - fn from_route(route: &crate::app::Route) -> Self { - Self { - method: route.method.clone(), - pattern: route.pattern.clone(), - } - } -} - -// Runtime symbols not yet wrapped by perry-ffi — we declare them -// locally as `extern "C"`. Same pattern perry-ext-{net,http,ws} -// follow for the small set of stable runtime exports outside -// perry-ffi v0.5's surface. -extern "C" { - /// Drain all queued microtasks. The fastify event loop calls - /// this between recv'ing a request and waiting for the next one - /// so promise chains the user's handler kicked off get a - /// chance to advance. - fn js_promise_run_microtasks() -> i32; - - /// Dispatch the registered stdlib pump (perry-stdlib registers - /// `js_stdlib_process_pending` here at startup, which fans out to - /// `js_ws_process_pending`, `js_net_process_pending`, - /// `js_http_process_pending`, etc.). Called from the fastify - /// event loop so perry-ext-{ws,net,http,fetch} events accumulated - /// on tokio workers get dispatched on the JS main thread. See #747. - fn js_run_stdlib_pump(); - - /// True if `ptr` is a Promise (NaN-boxed pointer to a runtime - /// `Promise` struct). - fn js_is_promise(ptr: *mut Promise) -> i32; - - /// Promise state — 0 = pending, 1 = fulfilled, 2 = rejected. - fn js_promise_state(ptr: *mut Promise) -> i32; - - /// Read the resolved value of a settled promise. - fn js_promise_value(ptr: *mut Promise) -> f64; - - /// Read the rejection reason of a rejected promise. - fn js_promise_reason(ptr: *mut Promise) -> f64; - - /// JSON.stringify with type hint — used for non-string handler - /// returns when no explicit response body was set. - fn js_json_stringify(value: f64, type_hint: u32) -> *mut StringHeader; - - /// Condvar-based wait for the next event (timer fire, notify from a - /// tokio worker, or 1 s idle cap). Used by `wait_for_promise` so the - /// handler dispatcher blocks on real events instead of burning the - /// CPU in a 100 us-poll loop. Wakes the moment any stdlib worker - /// calls `js_notify_main_thread`, including the per-promise wake - /// fired by `js_promise_resolve` / `js_promise_reject`. - fn js_wait_for_event(); - - /// Drive timer callback dispatch (matches the codegen-emitted await - /// wait body). Without these the `await new Promise(r => setTimeout(r, n))` - /// shape would never advance inside `wait_for_promise`, only inside - /// directly-compiled `await` sites. - fn js_timer_tick() -> i32; - fn js_callback_timer_tick() -> i32; - fn js_interval_timer_tick() -> i32; - - fn js_try_push() -> *mut c_int; - fn js_try_end(); - fn js_get_exception() -> f64; - fn js_clear_exception(); - fn js_error_get_message(error: *mut ErrorHeader) -> *mut StringHeader; -} - -extern "C" { - /// The runtime's C setjmp trampoline (#9305, `perry_sjlj.c`, bundled in - /// `libperry_runtime.a`). rustc cannot express `returns_twice`, so a raw - /// `setjmp` call in a Rust frame is miscompiled under LLVM's one-return - /// assumption (stack-slot coloring across the call); every jmp_buf arm - /// goes through this C frame instead. Mirrors - /// `perry_runtime::exception::arm_trap_and_run`. - fn perry_sjlj_try( - env: *mut core::ffi::c_void, - body: unsafe extern "C" fn(*mut core::ffi::c_void), - ctx: *mut core::ffi::c_void, - ) -> c_int; -} - -/// Arm `env` (from `js_try_push`) inside the C trampoline and run `f` under -/// it. `None` = a JS throw longjmp-landed (exception state set, trap still -/// pushed). Local mirror of `perry_runtime::exception::arm_trap_and_run` — -/// this crate deliberately has no Cargo dep on perry-runtime. -fn arm_trap_and_run R>(env: *mut c_int, f: F) -> Option { - struct Ctx { - f: Option, - ret: Option, - } - unsafe extern "C" fn invoke R, R>(raw: *mut core::ffi::c_void) { - let ctx = unsafe { &mut *(raw as *mut Ctx) }; - let f = ctx.f.take().expect("sjlj trampoline invoked body twice"); - ctx.ret = Some(f()); - } - let mut ctx: Ctx<_, R> = Ctx { - f: Some(f), - ret: None, - }; - let rc = unsafe { - perry_sjlj_try( - env as *mut core::ffi::c_void, - invoke::, - &mut ctx as *mut Ctx<_, R> as *mut core::ffi::c_void, - ) - }; - if rc == 0 { - Some( - ctx.ret - .take() - .expect("sjlj trampoline returned 0 without a body result"), - ) - } else { - None - } -} - -/// Opaque marker for the runtime's Promise struct. We never read its -/// fields directly — only pass pointers to runtime helpers above. -#[repr(C)] -pub struct Promise { - _opaque: [u8; 0], -} - -#[repr(C)] -pub struct ErrorHeader { - _opaque: [u8; 0], -} - -/// Server handle returned by `js_fastify_listen`. -/// -/// Pre-fix, `listen()` blocked the main TS thread inside an inner -/// `event_loop` that never returned, so `await app.listen(...)` in -/// user code never resumed and any subsequent code (an in-process -/// `fetch` against the same process, `app.close()`, etc.) never ran — -/// the compat-sweep fixture timed out at gtimeout(30s). The fix -/// mirrors what perry-ext-http did in #604: `listen()` returns -/// immediately after spawning the accept loop, and a new -/// `js_fastify_process_pending` extern wired into perry-stdlib's main -/// pump drains the per-server mpsc each tick. The receiver lives -/// inside the handle so the pump can find it after `listen()` returns. -pub struct FastifyServerHandle { - pub port: u16, - pub app_handle: Handle, - pub shutdown_tx: Option>, - /// Drained by `js_fastify_process_pending` from the main TS thread - /// each tick. `Mutex` because the handle registry hands out `&'static` - /// references but the pump needs `&mut` access to `try_recv` and - /// we can't statically prove the pump is the only mutator. - pub request_rx: Mutex>>, - /// #1113 — WebSocket upgrade events queued from the hyper accept - /// task once `hyper::upgrade::on` resolves and the upgraded stream - /// has been registered with `perry_ext_ws::register_external_ws_stream`. - /// Drained alongside `request_rx` in `js_fastify_process_pending`. - pub upgrade_rx: Mutex>>, - /// True between `listen()` and `close()`. The - /// `js_fastify_has_active` extern returns 1 while any server has - /// this set, keeping the runtime's main event loop alive until the - /// user explicitly closes the server. - pub listening: AtomicBool, -} - -/// #1113 — pending WebSocket upgrade ready to fire the fastify -/// `app.server.on("upgrade", …)` handlers. Sent by the hyper accept -/// task after `hyper::upgrade::on` resolves and the upgraded stream -/// has been registered with `perry_ext_ws::register_external_ws_stream`. -/// Mirror of perry-ext-http's `HttpPendingUpgrade`. -pub struct FastifyPendingUpgrade { - pub app_handle: Handle, - pub method: String, - pub path: String, - pub headers: HashMap, - pub ws_id: i64, -} - -/// Pending request waiting for the TS handler to produce a response. -pub struct FastifyPendingRequest { - pub method: String, - pub path: String, - pub headers: HashMap, - pub body: Option>, - pub params: HashMap, - pub response_tx: oneshot::Sender, -} - -/// Response built by the TS handler, sent back to hyper's worker. -pub struct FastifyResponse { - pub status: u16, - pub headers: Vec<(String, String)>, - pub body: Vec, -} - -// ============================================================================ -// FFI: listen + close -// ============================================================================ - -/// `app.listen({ port }, callback?)` — start the server. Blocks the -/// caller indefinitely (the TS-visible API is "kick off the server, -/// then live in the event loop"; main thread returns to the event -/// loop after this call returns). -/// -/// # Safety -/// -/// `app_handle` must be a registered `FastifyApp` handle. `callback` -/// is an optional `*const ClosureHeader` (NaN-boxed or raw); pass `0` -/// for "no callback". -#[no_mangle] -pub unsafe extern "C" fn js_fastify_listen(app_handle: Handle, opts: f64, callback: i64) { - // Extract port — accepts `{ port: 3000 }`, a bare number, or - // falls back to 3000. - let port = extract_port(opts); - // Honor an explicit `{ reusePort: true }` (the Node/Bun listen option) in - // addition to auto-enabling SO_REUSEPORT for cluster workers. - let reuse_port = extract_reuse_port(opts); - - // Bind synchronously, BEFORE registering the server or firing the success - // callback, so a bind failure (e.g. EADDRINUSE) reaches the `(err, address)` - // callback as an error instead of being silently dropped inside the accept - // task while the caller has already been told listening succeeded. Only - // `from_std` needs a runtime context, so it stays in the spawned task below; - // the bind + `set_nonblocking` that actually fail on a port clash run here. - let addr = SocketAddr::from(([0, 0, 0, 0], port)); - let std_listener = match crate::cluster_bind::bind_listener(addr, reuse_port) { - Ok(l) => l, - Err(e) => { - fire_listen_error(callback, &e, port); - return; - } - }; - if let Err(e) = std_listener.set_nonblocking(true) { - fire_listen_error(callback, &e, port); - return; - } - // `listen(0)` asks the OS for an ephemeral port; read the real one back so - // the registered handle + callback report the actual bound port. - let actual_port = std_listener.local_addr().map(|a| a.port()).unwrap_or(port); - - let (request_tx, request_rx) = mpsc::channel::(1024); - // #1113 — separate channel for WebSocket upgrade events so a busy - // request stream can't starve them (mirror of perry-ext-http). - let (upgrade_tx, upgrade_rx) = mpsc::channel::(256); - let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>(); - let request_tx = Arc::new(request_tx); - let upgrade_tx = Arc::new(upgrade_tx); - - // Snapshot only route-matching metadata for the server task. Handler - // closure pointers stay in the FastifyApp handle and are read by the - // main-thread pump during dispatch. - let routes_arc = Arc::new( - get_handle::(app_handle) - .map(|app| { - app.routes - .iter() - .map(RouteMatcher::from_route) - .collect::>() - }) - .unwrap_or_default(), - ); - - // Tokio workers only match routes and queue raw request/upgrade data. - // User JS dispatch runs from `js_fastify_process_pending` on the main - // thread, so a server lifetime must not suppress GC. - - let request_tx_for_spawn = request_tx.clone(); - let upgrade_tx_for_spawn = upgrade_tx.clone(); - let routes_for_spawn = routes_arc.clone(); - - // The accept loop must run as a cooperative task on the shared - // multi-thread runtime. A plain `spawn_blocking` thread does not - // reliably carry the runtime's reactor/worker context: with - // `Handle::current().block_on(accept_loop)` the listener bound and - // accepted connections, but the per-connection - // `tokio::spawn(serve_connection)` tasks below were never driven — the - // request bytes sat unread and every response hung (the "in release the - // IO loop silently failed to start" brittleness the net/ws adapters - // hit). `spawn_blocking_with_reactor` runs the closure inside a worker - // task (`runtime().spawn(async { … })`), so `tokio::spawn`-ing the - // accept loop drives it and its fan-out serve tasks on the worker pool — - // mirroring perry-ext-http / -net / -ws. (A bare - // `Handle::current().block_on` here would panic "Cannot start a runtime - // from within a runtime" inside the worker task; spawn instead.) - perry_ffi::spawn_blocking_with_reactor(move || { - tokio::spawn(async move { - // The bind already succeeded on the caller thread (so a port clash - // was reported to the listen callback). Here we only report the - // bound address for `cluster.on('listening')` and adopt the std - // listener into the tokio reactor — `from_std` is the one step that - // needs the runtime context this task provides. - crate::cluster_bind::notify_listening("0.0.0.0", actual_port); - let listener = match TcpListener::from_std(std_listener) { - Ok(l) => l, - Err(e) => { - eprintln!("[fastify] adopting listener failed: {}", e); - return; - } - }; - loop { - tokio::select! { - accepted = listener.accept() => { - match accepted { - Ok((stream, _)) => { - let io = TokioIo::new(stream); - let request_tx = request_tx_for_spawn.clone(); - let upgrade_tx = upgrade_tx_for_spawn.clone(); - let routes = routes_for_spawn.clone(); - tokio::spawn(async move { - let service = service_fn(move |req: Request| { - let request_tx = request_tx.clone(); - let upgrade_tx = upgrade_tx.clone(); - let routes = routes.clone(); - async move { - handle_request(app_handle, req, request_tx, upgrade_tx, routes).await - } - }); - // #1113: `.with_upgrades()` is REQUIRED for - // `hyper::upgrade::on(&mut req)` to resolve. - // Without it the upgrade future never - // completes and the WS handshake stalls. - if let Err(e) = http1::Builder::new() - .serve_connection(io, service) - .with_upgrades() - .await - { - // perry#924: hyper surfaces every malformed - // client read as a per-connection error - // (HTTP/2 prefaces, scanner garbage). The - // application never sees these requests, so - // logging them by default just floods PM2 - // error logs. Gate behind `PERRY_DEBUG=1`. - if std::env::var_os("PERRY_DEBUG").is_some() { - eprintln!("Connection error: {}", e); - } - } - }); - } - Err(e) => eprintln!("Accept error: {}", e), - } - } - _ = &mut shutdown_rx => { - break; - } - } - } - }); - }); - - // Register the server handle so `js_fastify_close` and the - // process_pending pump can find it. The receiver lives inside the - // handle so the pump (driven from perry-stdlib's main loop) can - // drain it after `listen()` returns. - let _server_handle = register_handle(FastifyServerHandle { - port: actual_port, - app_handle, - shutdown_tx: Some(shutdown_tx), - request_rx: Mutex::new(Some(request_rx)), - upgrade_rx: Mutex::new(Some(upgrade_rx)), - listening: AtomicBool::new(true), - }); - - // Fire the user's `(err, address) => { ... }` callback — null - // err, address as a string. - if callback != 0 { - let raw = if (callback as u64 & 0xFFFF_0000_0000_0000) == POINTER_TAG { - (callback as u64 & PTR_MASK) as *const RawClosureHeader - } else { - callback as *const RawClosureHeader - }; - let address = format!("http://0.0.0.0:{}", actual_port); - let addr_str = alloc_string(&address); - let addr_val = JsValue::from_string_ptr(addr_str.as_raw()); - let null_val = f64::from_bits(TAG_NULL); - let closure = JsClosure::from_raw(raw); - if !closure.is_null() { - let _ = closure.call2(null_val, f64::from_bits(addr_val.bits())); - } - } - - println!("Server listening on http://0.0.0.0:{}", actual_port); - - // `listen()` is now non-blocking — the accept loop is already - // spawned above, and `js_fastify_process_pending` drains pending - // requests from the registered handle on every tick of - // perry-stdlib's main pump. Pre-fix this function entered the - // blocking `event_loop(...)` and never returned, so - // `await app.listen(...)` in user code never resumed — every - // subsequent line (the in-process `fetch` against itself, - // `app.close()`, etc.) was unreachable. -} - -/// Close one specific server by its `FastifyServerHandle` id. Marks -/// the server as no-longer-listening (so `js_fastify_has_active` -/// stops reporting it as active), drops the request receiver, and -/// fires the shutdown oneshot so the accept loop exits. Idempotent — -/// safe to call multiple times. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_close(server_handle: Handle) -> bool { - if let Some(server) = get_handle_mut::(server_handle) { - server.listening.store(false, Ordering::Release); - *server.request_rx.lock().unwrap() = None; - *server.upgrade_rx.lock().unwrap() = None; - if let Some(tx) = server.shutdown_tx.take() { - let _ = tx.send(()); - } - return true; - } - false -} - -/// `app.close()` — close every server bound to `app_handle`. Walks the -/// handle registry for matching `FastifyServerHandle` rows and marks -/// each as no-longer listening so `js_fastify_has_active` lets the -/// runtime's event loop exit. Returns void — TS-side dispatch arm -/// just discards the result. -#[no_mangle] -pub unsafe extern "C" fn js_fastify_app_close(app_handle: Handle) { - let mut server_ids: Vec = Vec::new(); - iter_handle_ids_of::(|id| { - if let Some(s) = get_handle::(id) { - if s.app_handle == app_handle { - server_ids.push(id); - } - } - }); - for id in server_ids { - let _ = js_fastify_close(id); - } -} - -/// Cap on the per-thread deferred-request queue (see -/// `js_fastify_process_pending`). A pathological awaited-handler storm that -/// exceeds this drops the newest pending — its `response_tx` Drop makes the -/// awaiting hyper service-fn resolve `Err` (→ an error response), so memory is -/// bounded (backpressure) rather than growing without limit. -const DEFERRED_QUEUE_CAP: usize = 4096; - -/// Non-blocking `try_recv` of one pending request from a server's channel. -/// Holds the `request_rx` mutex only for the `try_recv` itself, never across -/// dispatch. Returns `None` when the channel is empty / the handle is gone. -fn try_recv_pending_request(server_handle: Handle) -> Option { - let s = get_handle::(server_handle)?; - let mut guard = s.request_rx.lock().unwrap(); - guard.as_mut()?.try_recv().ok() -} - -/// Move every currently-queued request out of one server's channel into -/// `deferred` — the nested-pump path (see `js_fastify_process_pending`). Does -/// NOT dispatch (dispatching in a nested frame would deepen the try-frame -/// nesting toward `MAX_TRY_DEPTH`). Respects `cap`: once `deferred` holds `cap` -/// entries, further pending are dropped, and dropping a `FastifyPendingRequest` -/// drops its `response_tx`, signalling the awaiting service-fn with `Err`. -/// Returns the number actually moved into `deferred`. -fn drain_server_requests_into_deferred( - server_handle: Handle, - app_handle: Handle, - deferred: &mut std::collections::VecDeque<(Handle, FastifyPendingRequest)>, - cap: usize, -) -> usize { - let mut moved = 0; - while let Some(pending) = try_recv_pending_request(server_handle) { - if deferred.len() < cap { - deferred.push_back((app_handle, pending)); - moved += 1; - } - // else: `pending` is dropped here → response_tx Drop → service-fn Err. - } - moved -} - -/// Drain & fire every queued WebSocket upgrade for one server (#1113). Upgrades -/// are handled before request traffic — including *between* deferred-request -/// dispatches in the pump's tail loop — so a busy / replenishing request stream -/// can't starve them. Returns the number fired. -fn drain_server_upgrades(server_handle: Handle) -> i32 { - let mut count = 0i32; - while let Some(up) = try_recv_fastify_upgrade(server_handle) { - let req_bits = - unsafe { crate::upgrade::build_request_object(&up.method, &up.path, &up.headers) } - .to_bits() as i64; - crate::upgrade::fire_fastify_upgrade_listeners( - up.app_handle, - req_bits, - up.ws_id, - Vec::new(), - ); - count += 1; - } - count -} - -/// Pump entrypoint — drain pending requests from every registered -/// `FastifyServerHandle` and dispatch each on the main TS thread. -/// Registered with perry-runtime when the first Fastify app initializes, so -/// the runtime's outer event loop drives it without a named reference from -/// stdlib. -/// -/// Re-entrancy: dispatching a request runs user TS, which may `await`; -/// `wait_for_promise` then drives `js_run_stdlib_pump`, which calls back into -/// this pump. Each nested dispatch would push another `call_closure2_catching` -/// try-frame (setjmp), so under sustained awaited-handler load the recursion -/// could blow `MAX_TRY_DEPTH` (128 — "Try block nesting too deep"). An -/// `IN_PROGRESS` thread-local guard makes a nested entry capture pending -/// requests into a thread-local `DEFERRED` queue WITHOUT dispatching, and the -/// outer frame drains `DEFERRED` at its tail — so every dispatch happens at the -/// outer (bounded) try-depth. -/// -/// Returns the number of requests processed (matches the convention -/// other pump arms follow — `js_ws_process_pending`, -/// `js_node_http_server_process_pending`, etc.). -#[no_mangle] -pub extern "C" fn js_fastify_process_pending() -> i32 { - // #1114: called every iteration of the generated event loop AND - // every inline `await` poll loop. A fresh `Vec` per call is - // a high-frequency alloc/free that shows up as GC `madvise` - // page-churn under sustained async load (the wedge signature). - // Reuse a per-thread scratch buffer; move it out (not borrow it) - // across `process_request` since that dispatches user TS which can - // re-enter this pump. Mirror of the bundled `perry-stdlib::fastify` - // fix — either crate can be live depending on the well-known flip. - thread_local! { - static SCRATCH: std::cell::RefCell> = - const { std::cell::RefCell::new(Vec::new()) }; - // Re-entrancy guard + deferred queue (see the fn doc-comment). Both are - // main-thread-only (the pump runs only on the main TS thread). - static IN_PROGRESS: std::cell::Cell = const { std::cell::Cell::new(false) }; - static DEFERRED: std::cell::RefCell> = - const { std::cell::RefCell::new(std::collections::VecDeque::new()) }; - } - - if IN_PROGRESS.with(|f| f.replace(true)) { - // Nested entry (a dispatched handler's await re-drove the pump): capture - // every server's pending into DEFERRED for the outer frame to dispatch; - // do NOT dispatch here — that would deepen the try-frame nesting. Collect - // ids first (don't call `get_handle` inside `iter_handle_ids_of`). - let mut ids: Vec = Vec::new(); - iter_handle_ids_of::(|id| ids.push(id)); - DEFERRED.with(|q| { - let mut q = q.borrow_mut(); - for &h in &ids { - if let Some(app_handle) = get_handle::(h).map(|s| s.app_handle) - { - drain_server_requests_into_deferred(h, app_handle, &mut q, DEFERRED_QUEUE_CAP); - } - } - }); - return 0; - } - // Reset the guard on EVERY exit path (incl. an early return / unwind). - struct ResetReentryGuard; - impl Drop for ResetReentryGuard { - fn drop(&mut self) { - IN_PROGRESS.with(|f| f.set(false)); - } - } - let _reset = ResetReentryGuard; - - let mut server_handles = SCRATCH.with(|s| std::mem::take(&mut *s.borrow_mut())); - server_handles.clear(); - iter_handle_ids_of::(|id| { - server_handles.push(id); - }); - let mut count = 0i32; - for &h in server_handles.iter() { - let app_handle = match get_handle::(h) { - Some(s) => s.app_handle, - None => continue, - }; - // #1113 — drain WebSocket upgrades FIRST so a busy request - // stream can't starve them (mirror of perry-ext-http's - // `js_node_http_server_process_pending`). - count += drain_server_upgrades(h); - while let Some(pending) = try_recv_pending_request(h) { - process_request(app_handle, pending); - count += 1; - } - } - - // Tail: dispatch anything a nested pump call captured into DEFERRED. These - // were already `try_recv`'d out of the per-server channels, so we dispatch - // the captured structs here in the OUTER frame — the try-frame depth stays - // at the outer (bounded) level. A dispatch here may itself await and re-enter - // the pump; that nested call hits the guard above and appends to DEFERRED, - // which this same loop then drains — still all at the outer try-depth. - // - // Re-drain upgrades before each deferred dispatch: a long (nested-await - // replenished) deferred drain must not starve WebSocket upgrades that arrive - // after the initial pass. Nested entries defer only REQUESTS, so the outer - // frame is where late upgrades get picked up — keep them ahead of requests - // here too (#1113). - loop { - for &h in server_handles.iter() { - count += drain_server_upgrades(h); - } - let next = DEFERRED.with(|q| q.borrow_mut().pop_front()); - let (app_handle, pending) = match next { - Some(t) => t, - None => break, - }; - process_request(app_handle, pending); - count += 1; - } - - server_handles.clear(); - SCRATCH.with(|s| { - let mut slot = s.borrow_mut(); - if server_handles.capacity() >= slot.capacity() { - *slot = server_handles; - } - }); - count -} - -/// #1113 — non-blocking try_recv for a pending WebSocket upgrade. -/// Mirror of perry-ext-http's `try_recv_upgrade`. -fn try_recv_fastify_upgrade(server_handle: Handle) -> Option { - if let Some(s) = get_handle::(server_handle) { - let mut guard = s.upgrade_rx.lock().unwrap(); - if let Some(rx) = guard.as_mut() { - return rx.try_recv().ok(); - } - } - None -} - -/// Reports whether any registered fastify server is currently in the -/// "listening" state OR has a non-empty upgrade queue. Registered as a -/// runtime keepalive contributor so the main event loop keeps running until -/// the user explicitly closes every server and every queued upgrade has been -/// drained. -#[no_mangle] -pub extern "C" fn js_fastify_has_active() -> i32 { - let mut active = 0i32; - iter_handle_ids_of::(|id| { - if let Some(s) = get_handle::(id) { - if s.listening.load(Ordering::Acquire) { - active = 1; - } - // Even after close(), the upgrade channel may still hold - // queued items the pump needs to drain on a later tick - // before the program can exit cleanly (mirror of - // perry-ext-http's `server_is_active`). - if let Ok(guard) = s.upgrade_rx.lock() { - if let Some(rx) = guard.as_ref() { - if !rx.is_closed() && !rx.is_empty() { - active = 1; - } - } - } - } - }); - active -} - -// ============================================================================ -// Request dispatch -// ============================================================================ - -/// Hyper service function — match the route, hand the request to the -/// main thread via mpsc, await the response. -async fn handle_request( - app_handle: Handle, - req: Request, - request_tx: Arc>, - upgrade_tx: Arc>, - routes: Arc>, -) -> Result>, hyper::Error> { - let method = req.method().to_string(); - let uri = req.uri(); - let path = match uri.query() { - Some(q) => format!("{}?{}", uri.path(), q), - None => uri.path().to_string(), - }; - - let mut headers = HashMap::new(); - for (name, value) in req.headers() { - if let Ok(v) = value.to_str() { - headers.insert(name.to_string().to_lowercase(), v.to_string()); - } - } - - // #1113: detect WebSocket upgrade requests. The user's pattern - // - // import { WebSocketServer } from "ws"; - // const wss = new WebSocketServer({ noServer: true }); - // app.server.on("upgrade", (req, socket, head) => { - // wss.handleUpgrade(req, socket, head, (sock) => { ... }); - // }); - // - // expects the fastify accept loop to surface upgrade requests via - // `app.server`'s registered `"upgrade"` handler. Branch into the - // handshake path: build the 101 response synchronously and spawn - // a task that awaits hyper's upgraded stream, completes the - // tungstenite server handshake, registers the WebSocketStream - // with perry-ext-ws, and queues a `FastifyPendingUpgrade` for the - // main-thread pump to fire the registered handlers. Mirror of - // perry-ext-http's #577 Phase 4 path. - if crate::upgrade::is_websocket_upgrade(&req) { - return handle_fastify_websocket_upgrade( - app_handle, req, method, path, headers, upgrade_tx, - ) - .await; - } - - let body = match req.collect().await { - Ok(collected) => { - let bytes = collected.to_bytes(); - if bytes.is_empty() { - None - } else { - Some(bytes.to_vec()) - } - } - Err(_) => None, - }; - - // Match: first try the exact method, then — for HEAD — fall back to - // a GET route with the same path. Node fastify auto-handles HEAD - // against any registered GET (via `app.head` shadowing) by running - // the GET handler and dropping the body before sending. We do the - // same: rewrite the method to GET so the handler sees a vanilla - // request, then strip the body on the way out (see `head_for_get` - // below). #1120 part 2. - let mut matched_params = HashMap::new(); - let mut found_route = false; - let mut head_for_get = false; - for route in routes.iter() { - if route.method == method { - if let Some(params) = route.pattern.match_path(&path) { - matched_params = params; - found_route = true; - break; - } - } - } - if !found_route && method == "HEAD" { - for route in routes.iter() { - if route.method == "GET" { - if let Some(params) = route.pattern.match_path(&path) { - matched_params = params; - found_route = true; - head_for_get = true; - break; - } - } - } - } - - if !found_route { - return Ok(Response::builder() - .status(StatusCode::NOT_FOUND) - .header("content-type", "application/json") - .body(Full::new(Bytes::from(r#"{"error":"Not Found"}"#))) - .unwrap()); - } - - let (response_tx, response_rx) = oneshot::channel::(); - // When fronting a GET handler for an inbound HEAD, surface the - // method as `GET` to the handler — Node fastify's shadowing - // semantics. The body-drop happens below in the hyper response - // assembly. - let dispatch_method = if head_for_get { - "GET".to_string() - } else { - method.clone() - }; - let pending = FastifyPendingRequest { - method: dispatch_method, - path, - headers, - body, - params: matched_params, - response_tx, - }; - - if request_tx.send(pending).await.is_err() { - return Ok(Response::builder() - .status(StatusCode::SERVICE_UNAVAILABLE) - .body(Full::new(Bytes::from("Server unavailable"))) - .unwrap()); - } - - // Wake the main thread so it doesn't wait on its 10ms timeout. - perry_ffi::notify_main_thread(); - - match response_rx.await { - Ok(fr) => { - let body_len = fr.body.len(); - let mut builder = Response::builder() - .status(StatusCode::from_u16(fr.status).unwrap_or(StatusCode::OK)); - let mut had_content_length = false; - for (name, value) in fr.headers { - if name.eq_ignore_ascii_case("content-length") { - had_content_length = true; - } - builder = builder.header(name, value); - } - let body_bytes = if head_for_get { - // HEAD response: no body on the wire, but expose the - // would-have-been size via Content-Length so clients - // (curl -I, browsers, monitoring) see what GET would - // produce. Mirror of Node fastify's HEAD-on-GET path. - if !had_content_length { - builder = builder.header("content-length", body_len.to_string()); - } - Bytes::new() - } else { - Bytes::from(fr.body) - }; - Ok(builder.body(Full::new(body_bytes)).unwrap()) - } - Err(_) => Ok(Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(Full::new(Bytes::from("Handler error"))) - .unwrap()), - } -} - -/// #1113 — WebSocket upgrade dispatch (mirror of perry-ext-http's -/// `handle_websocket_upgrade`, issue #577 Phase 4). -/// -/// Synchronously builds the 101 response (so hyper drives the protocol -/// switch) and spawns a tokio task that awaits the upgraded stream, -/// finishes the handshake server-side via -/// `tokio_tungstenite::WebSocketStream::from_raw_socket`, registers -/// the stream with perry-ext-ws, and queues a `FastifyPendingUpgrade` -/// on the per-server channel; the main-thread pump fires the -/// `app.server.on("upgrade", …)` handlers with `(req, ws_id, head)`. -async fn handle_fastify_websocket_upgrade( - app_handle: Handle, - mut req: Request, - method: String, - path: String, - headers: HashMap, - upgrade_tx: Arc>, -) -> Result>, hyper::Error> { - // Compute the Sec-WebSocket-Accept value before consuming req. - let accept_value = req - .headers() - .get("sec-websocket-key") - .and_then(|v| v.to_str().ok()) - .map(|k| tokio_tungstenite::tungstenite::handshake::derive_accept_key(k.as_bytes())) - .unwrap_or_default(); - - // Spawn a task that waits for hyper to perform the protocol - // switch, completes the tungstenite handshake, and hands the - // resulting stream to perry-ext-ws. - tokio::spawn(async move { - let upgraded = match hyper::upgrade::on(&mut req).await { - Ok(u) => u, - Err(_) => return, - }; - let io = TokioIo::new(upgraded); - let ws = tokio_tungstenite::WebSocketStream::from_raw_socket( - io, - tokio_tungstenite::tungstenite::protocol::Role::Server, - None, - ) - .await; - let ws_id = perry_ext_ws::register_external_ws_stream(ws); - let pending = FastifyPendingUpgrade { - app_handle, - method, - path, - headers, - ws_id, - }; - let _ = upgrade_tx.send(pending).await; - perry_ffi::notify_main_thread(); - }); - - Ok(Response::builder() - .status(101) - .header("upgrade", "websocket") - .header("connection", "Upgrade") - .header("sec-websocket-accept", accept_value) - .body(Full::new(Bytes::new())) - .unwrap()) -} - -/// Build the per-request [`FastifyContext`], MOVING the pending request's -/// headers/body/params out of `pending` (via `mem::take` / `Option::take`) -/// rather than cloning them. Each is consumed exactly once, so cloning would -/// fire three redundant per-request allocations (two `HashMap`s + one `Vec`, -/// dominated by the O(headers) header-map clone) on the hot dispatch path. -/// `method`/`path` stay cloned: `process_request` reuses them for the route -/// match (`app.match_route(&pending.method, &pending.path)`) after the context -/// is built. This is the single construction site `process_request` uses, so a -/// regression test can drive it directly. -pub(crate) fn build_context_from_pending( - request_id: u64, - pending: &mut FastifyPendingRequest, -) -> FastifyContext { - FastifyContext::new( - request_id, - pending.method.clone(), - pending.path.clone(), - std::mem::take(&mut pending.headers), - pending.body.take(), - std::mem::take(&mut pending.params), - ) -} - -/// Process one request — fire hooks, call route handler, send the -/// response back through the oneshot channel. -pub(crate) fn process_request(app_handle: Handle, mut pending: FastifyPendingRequest) -> Handle { - let ctx = build_context_from_pending(0, &mut pending); - let ctx_handle = register_handle(ctx); - - // Snapshot hooks + matched route (need to drop the borrow before - // invoking user closures, which may mutate the app). - let (on_request_hooks, pre_handler_hooks, matched_handler, error_handler): ( - Vec, - Vec, - Option, - Option, - ) = match get_handle::(app_handle) { - Some(app) => { - let on_req = app.hooks.on_request.clone(); - let pre = app.hooks.pre_handler.clone(); - let matched = app - .match_route(&pending.method, &pending.path) - .map(|(r, _)| r.handler); - (on_req, pre, matched, app.error_handler) - } - None => (Vec::new(), Vec::new(), None, None), - }; - - // NaN-box the context handle — POINTER_TAG so codegen-side - // method dispatch on `request.*` / `reply.*` Just Works. - let ctx_f64 = f64::from_bits(POINTER_TAG | (ctx_handle as u64 & PTR_MASK)); - - let mut response_sent = false; - for hook in &on_request_hooks { - match call_hook_awaiting(*hook, ctx_f64, ctx_handle) { - HookOutcome::Continue => {} - HookOutcome::Sent => { - response_sent = true; - break; - } - HookOutcome::Error(reason) => { - handle_error_response(error_handler, ctx_f64, ctx_handle, reason); - response_sent = true; - break; - } - } - } - if !response_sent { - for hook in &pre_handler_hooks { - match call_hook_awaiting(*hook, ctx_f64, ctx_handle) { - HookOutcome::Continue => {} - HookOutcome::Sent => { - response_sent = true; - break; - } - HookOutcome::Error(reason) => { - handle_error_response(error_handler, ctx_f64, ctx_handle, reason); - response_sent = true; - break; - } - } - } - } - - let mut final_result = f64::from_bits(TAG_UNDEFINED); - if !response_sent { - if let Some(handler) = matched_handler { - let call = unsafe { - let raw = handler as *const RawClosureHeader; - let closure = JsClosure::from_raw(raw); - if closure.is_null() { - ClosureCallResult { - value: f64::from_bits(TAG_UNDEFINED), - thrown: None, - } - } else { - call_closure2_catching(closure, ctx_f64, ctx_f64) - } - }; - if let Some(reason) = call.thrown { - handle_error_response(error_handler, ctx_f64, ctx_handle, reason); - response_sent = true; - } - unsafe { - js_promise_run_microtasks(); - } - if !response_sent { - final_result = call.value; - } - - // If the handler returned a Promise, wait for it. - let jsv = JsValue::from_bits(call.value.to_bits()); - if !response_sent && jsv.is_pointer() { - let ptr = jsv.as_pointer::(); - if !ptr.is_null() && unsafe { js_is_promise(ptr) } != 0 { - wait_for_promise(ptr); - // Read state AFTER the wait — `js_promise_value` - // returns `(*promise).value` unconditionally, and - // that field stays at its initial `0.0` for rejected - // promises (which set `reason`, not `value`) and - // pending promises (which are never reached after - // the unbounded `wait_for_promise` returns, but - // defending here keeps us robust against future - // changes to `wait_for_promise`'s contract). Without - // this branch, an unhandled rejection inside a - // route handler would serialize the literal byte - // `0` as the response body — exactly the issue - // #748 symptom for the cases where the chain - // rejected instead of stalling. - let st = unsafe { js_promise_state(ptr) }; - if st == 2 { - // Rejected — translate to a 500 response with - // the rejection reason rendered to JSON. The - // dispatcher's fallback `build_response_body` - // already JSON-stringifies pointer values, so - // wrap the reason in a `{ error: }` - // envelope to avoid spilling raw stack traces - // into the wire. Mirrors `fastify`'s default - // error handler shape. - let reason = unsafe { js_promise_reason(ptr) }; - handle_error_response(error_handler, ctx_f64, ctx_handle, reason); - final_result = f64::from_bits(TAG_UNDEFINED); - } else { - final_result = unsafe { js_promise_value(ptr) }; - } - } - } - } - } - - // Build + send the response. - if let Some(ctx) = get_handle::(ctx_handle) { - // Track whether the body came back as binary (Buffer / Uint8Array) - // so the default content-type below picks octet-stream over JSON - // when the handler didn't pin one via `reply.type(...)` (#1120). - let (body, body_kind) = if let Some(b) = ctx.response_body.clone() { - // The body was set explicitly by `reply.send(...)`, which - // already pushed an `application/octet-stream` default if - // the payload was binary and no content-type was pinned. - // Treat it as text/json here so we don't override. - (b, BodyKind::TextOrJson) - } else { - unsafe { build_response_body(final_result) } - }; - let mut response = FastifyResponse { - status: ctx.status_code, - headers: ctx.response_headers.clone(), - body, - }; - if !response - .headers - .iter() - .any(|(k, _)| k.eq_ignore_ascii_case("content-type")) - { - let ct = match body_kind { - BodyKind::Binary => "application/octet-stream", - BodyKind::TextOrJson => "application/json", - }; - response - .headers - .push(("content-type".to_string(), ct.to_string())); - } - let _ = pending.response_tx.send(response); - } - - // Free the context handle so it doesn't leak. - perry_ffi::drop_handle(ctx_handle); - - // Return the (now-freed) context handle so the register → drop lifecycle - // is observable: the `context_handle_dropped_after_dispatch` regression - // test drives a real request through this function and asserts the handle - // is gone, which a missing `drop_handle` above would fail. The live - // dispatch loop calls this as a statement and discards the value. - ctx_handle -} - -/// Call a hook closure, await any returned Promise, and report whether it -/// sent a response or threw/rejected. -fn call_hook_awaiting(hook: ClosurePtr, ctx_f64: f64, ctx_handle: Handle) -> HookOutcome { - if hook == 0 { - return HookOutcome::Continue; - } - let call = unsafe { - let closure = JsClosure::from_raw(hook as *const RawClosureHeader); - if closure.is_null() { - return HookOutcome::Continue; - } - call_closure2_catching(closure, ctx_f64, ctx_f64) - }; - if let Some(reason) = call.thrown { - return HookOutcome::Error(reason); - } - unsafe { - js_promise_run_microtasks(); - } - let jsv = JsValue::from_bits(call.value.to_bits()); - if jsv.is_pointer() { - let ptr = jsv.as_pointer::(); - if !ptr.is_null() && unsafe { js_is_promise(ptr) } != 0 { - wait_for_promise(ptr); - if unsafe { js_promise_state(ptr) } == 2 { - return HookOutcome::Error(unsafe { js_promise_reason(ptr) }); - } - } - } - if get_handle::(ctx_handle) - .map(|c| c.sent) - .unwrap_or(false) - { - HookOutcome::Sent - } else { - HookOutcome::Continue - } -} - -unsafe fn call_closure2_catching(closure: JsClosure, arg0: f64, arg1: f64) -> ClosureCallResult { - let trap_buf = js_try_push(); - let outcome = arm_trap_and_run(trap_buf, || unsafe { closure.call2(arg0, arg1) }); - match outcome { - Some(value) => { - js_try_end(); - ClosureCallResult { - value, - thrown: None, - } - } - None => { - let exc = js_get_exception(); - js_clear_exception(); - js_try_end(); - ClosureCallResult { - value: f64::from_bits(TAG_UNDEFINED), - thrown: Some(exc), - } - } - } -} - -unsafe fn call_closure3_catching( - closure: JsClosure, - arg0: f64, - arg1: f64, - arg2: f64, -) -> ClosureCallResult { - let trap_buf = js_try_push(); - let outcome = arm_trap_and_run(trap_buf, || unsafe { closure.call3(arg0, arg1, arg2) }); - match outcome { - Some(value) => { - js_try_end(); - ClosureCallResult { - value, - thrown: None, - } - } - None => { - let exc = js_get_exception(); - js_clear_exception(); - js_try_end(); - ClosureCallResult { - value: f64::from_bits(TAG_UNDEFINED), - thrown: Some(exc), - } - } - } -} - -fn handle_error_response( - error_handler: Option, - ctx_f64: f64, - ctx_handle: Handle, - reason: f64, -) { - if let Some(ctx) = perry_ffi::get_handle_mut::(ctx_handle) { - ctx.status_code = 500; - } - - if let Some(handler) = error_handler { - let call = unsafe { - let closure = JsClosure::from_raw(handler as *const RawClosureHeader); - if closure.is_null() { - ClosureCallResult { - value: f64::from_bits(TAG_UNDEFINED), - thrown: Some(reason), - } - } else { - call_closure3_catching(closure, reason, ctx_f64, ctx_f64) - } - }; - let mut fallback_reason = call.thrown; - - if fallback_reason.is_none() { - unsafe { - js_run_stdlib_pump(); - js_promise_run_microtasks(); - } - - let mut final_result = call.value; - let jsv = JsValue::from_bits(call.value.to_bits()); - if jsv.is_pointer() { - let ptr = jsv.as_pointer::(); - if !ptr.is_null() && unsafe { js_is_promise(ptr) } != 0 { - wait_for_promise(ptr); - if unsafe { js_promise_state(ptr) } == 2 { - fallback_reason = Some(unsafe { js_promise_reason(ptr) }); - } else { - final_result = unsafe { js_promise_value(ptr) }; - } - } - } - - if fallback_reason.is_none() { - if let Some(ctx) = perry_ffi::get_handle_mut::(ctx_handle) { - if ctx.response_body.is_none() { - let (bytes, kind) = unsafe { build_response_body(final_result) }; - // #1120: when the hook chain returned a Buffer / - // Uint8Array and no content-type was pinned, default - // to octet-stream so the request finalization step - // doesn't paint over it with `application/json`. - if kind == BodyKind::Binary - && !ctx - .response_headers - .iter() - .any(|(k, _)| k.eq_ignore_ascii_case("content-type")) - { - ctx.response_headers.push(( - "content-type".to_string(), - "application/octet-stream".to_string(), - )); - } - ctx.response_body = Some(bytes); - } - } - return; - } - } - - if let Some(reason) = fallback_reason { - apply_default_error_response(ctx_handle, reason); - } - } else { - apply_default_error_response(ctx_handle, reason); - } -} - -fn apply_default_error_response(ctx_handle: Handle, reason: f64) { - if let Some(ctx) = perry_ffi::get_handle_mut::(ctx_handle) { - ctx.status_code = 500; - ctx.response_body = Some(unsafe { render_rejection_body(reason) }); - } -} - -/// Wait until a promise settles, driving microtasks, the stdlib pump, -/// and timer ticks every iteration. Blocks on `js_wait_for_event` (a -/// condvar with a 1 s idle cap) instead of `thread::sleep`, so the -/// dispatcher wakes the moment any stdlib worker calls -/// `js_notify_main_thread` — the same wake that `js_promise_resolve` -/// and `js_promise_reject` fire when the awaited chain advances. -/// -/// Mirrors the codegen-emitted `await` body in -/// `crates/perry-codegen/src/expr.rs` (the "=== wait ===" block at -/// lines ~9645-9665): no fixed iteration limit, condvar-based wait. -/// -/// ### Why the old polling loop is wrong (issue #748) -/// -/// The previous implementation looped 10_000 × 100 us = ~1 s and then -/// returned regardless of whether the promise had settled. Callers -/// then read `js_promise_value(ptr)` which returns `(*promise).value` -/// — `0.0` for a still-Pending Promise (it's initialized to zero in -/// `Promise::new` and only overwritten by `js_promise_resolve`). The -/// dispatcher serialized that `0.0` as the response body, yielding the -/// literal ASCII byte `0x30` ("0") with HTTP 200 (default -/// `status_code` — `reply.code(201)` was never reached because the -/// handler chain hadn't returned). The signup-style route in #748 -/// runs many awaits (rate-limiter, argon2 hash, multiple `pool.exec` -/// round-trips, JWT signing) which routinely exceeds 1 s on a cold -/// connection pool; every operation after the timeout silently -/// no-op'd because the dispatcher returned and stopped pumping -/// microtasks for the orphaned chain. -fn wait_for_promise(promise_ptr: *mut Promise) { - // First pump synchronously — handles the already-settled case - // (e.g. `async () => 42` whose promise is fulfilled before this - // function is even called) without entering the wait path. - unsafe { - js_run_stdlib_pump(); - js_promise_run_microtasks(); - } - let mut state = unsafe { js_promise_state(promise_ptr) }; - if state != 0 { - return; - } - loop { - unsafe { - // Drive timers, the stdlib pump, and microtasks every tick - // — mirrors the codegen-emitted await body. `js_timer_tick` - // & friends are no-ops when there's nothing to dispatch. - let _ = js_timer_tick(); - let _ = js_callback_timer_tick(); - let _ = js_interval_timer_tick(); - js_run_stdlib_pump(); - js_promise_run_microtasks(); - // Condvar wait: blocks until a notify arrives or the 1 s - // idle cap elapses, whichever is first. `js_promise_resolve` - // / `js_promise_reject` fire `js_notify_main_thread`, so - // the wake happens the instant the chain advances. - js_wait_for_event(); - } - state = unsafe { js_promise_state(promise_ptr) }; - if state != 0 { - return; - } - } -} - -/// Render a Promise rejection reason as a `{ "error": ... }` JSON body -/// for the 500 response surfaced by `process_request`. Falls back to a -/// generic envelope if the reason can't be stringified (e.g. opaque -/// pointer that JSON.stringify rejects). -unsafe fn render_rejection_body(reason: f64) -> Vec { - if let Some(message) = error_message(reason) { - let mut out = - b"{\"statusCode\":500,\"error\":\"Internal Server Error\",\"message\":".to_vec(); - push_json_string(&mut out, message.as_bytes()); - out.push(b'}'); - return out; - } - - // Strings: wrap the user's message verbatim. - let jsv = JsValue::from_bits(reason.to_bits()); - if jsv.is_string() { - let (s, _) = jsvalue_to_response_body(reason); - // s is the raw string bytes; embed as a JSON string literal. - let mut out = b"{\"error\":".to_vec(); - out.push(b'"'); - for b in s { - match b { - b'"' => out.extend_from_slice(b"\\\""), - b'\\' => out.extend_from_slice(b"\\\\"), - b'\n' => out.extend_from_slice(b"\\n"), - b'\r' => out.extend_from_slice(b"\\r"), - b'\t' => out.extend_from_slice(b"\\t"), - 0x00..=0x1f => out.extend_from_slice(format!("\\u{:04x}", b).as_bytes()), - _ => out.push(b), - } - } - out.push(b'"'); - out.push(b'}'); - return out; - } - if jsv.is_pointer() { - let str_ptr = js_json_stringify(reason, 0); - if !str_ptr.is_null() { - let len = (*str_ptr).byte_len as usize; - let data_ptr = (str_ptr as *const u8).add(std::mem::size_of::()); - let inner = std::slice::from_raw_parts(data_ptr, len).to_vec(); - let mut out = b"{\"error\":".to_vec(); - out.extend_from_slice(&inner); - out.push(b'}'); - return out; - } - } - // Numbers/bools/null/undefined: best-effort stringification. - let (body, _) = jsvalue_to_response_body(reason); - let mut out = b"{\"error\":".to_vec(); - if body.is_empty() { - out.extend_from_slice(b"null"); - } else { - out.push(b'"'); - for b in body { - match b { - b'"' => out.extend_from_slice(b"\\\""), - b'\\' => out.extend_from_slice(b"\\\\"), - _ => out.push(b), - } - } - out.push(b'"'); - } - out.push(b'}'); - out -} - -unsafe fn error_message(reason: f64) -> Option { - let jsv = JsValue::from_bits(reason.to_bits()); - if jsv.is_pointer() { - let ptr = jsv.as_pointer::(); - if gc_obj_type(ptr) == GC_TYPE_ERROR { - let msg = js_error_get_message(ptr as *mut ErrorHeader); - return string_header_to_string(msg); - } - } - None -} - -unsafe fn gc_obj_type(ptr: *const u8) -> u8 { - if ptr.is_null() || (ptr as usize) < 0x1000 { - return 0; - } - *ptr.sub(GC_HEADER_SIZE) -} - -unsafe fn string_header_to_string(ptr: *const StringHeader) -> Option { - let handle = JsString::from_raw(ptr as *mut StringHeader); - read_bytes(handle).map(|bytes| String::from_utf8_lossy(bytes).into_owned()) -} - -fn push_json_string(out: &mut Vec, bytes: &[u8]) { - out.push(b'"'); - for &b in bytes { - match b { - b'"' => out.extend_from_slice(b"\\\""), - b'\\' => out.extend_from_slice(b"\\\\"), - b'\n' => out.extend_from_slice(b"\\n"), - b'\r' => out.extend_from_slice(b"\\r"), - b'\t' => out.extend_from_slice(b"\\t"), - 0x00..=0x1f => out.extend_from_slice(format!("\\u{:04x}", b).as_bytes()), - _ => out.push(b), - } - } - out.push(b'"'); -} - -/// Render the handler return value as response bytes. Handlers can -/// return strings (used as-is), `Buffer` / `Uint8Array` (raw bytes, -/// see #1120), objects/arrays (JSON-stringified), numbers/bools -/// (toString), or `undefined` (empty `{}`). The returned `BodyKind` -/// signals whether the caller should default `content-type` to -/// `application/octet-stream` (binary) instead of `application/json`. -unsafe fn build_response_body(value: f64) -> (Vec, BodyKind) { - let jsv = JsValue::from_bits(value.to_bits()); - if jsv.is_undefined() || jsv.is_null() { - return (b"{}".to_vec(), BodyKind::TextOrJson); - } - if jsv.is_string() { - return jsvalue_to_response_body(value); - } - // Issue #1120 — Buffer / Uint8Array must ship their raw bytes, - // not the Buffer.toJSON `{"type":"Buffer","data":[...]}` form - // that `js_json_stringify` produces. Probe BUFFER_REGISTRY - // first; only fall through to JSON for non-buffer pointers. - if let Some(bytes) = extract_buffer_bytes(value) { - return (bytes, BodyKind::Binary); - } - if jsv.is_pointer() { - let str_ptr = js_json_stringify(value, 0); - if !str_ptr.is_null() { - let len = (*str_ptr).byte_len as usize; - let data_ptr = (str_ptr as *const u8).add(std::mem::size_of::()); - return ( - std::slice::from_raw_parts(data_ptr, len).to_vec(), - BodyKind::TextOrJson, - ); - } - } - // Fallback through the unified path. - jsvalue_to_response_body(value) -} - -// ============================================================================ -// Helpers -// ============================================================================ - -unsafe fn extract_port(opts: f64) -> u16 { - let v = JsValue::from_bits(opts.to_bits()); - if v.is_pointer() { - if let Some(json) = perry_ffi::json_stringify(v) { - if let Ok(parsed) = serde_json::from_str::(&json) { - if let Some(p) = parsed.get("port").and_then(|p| { - p.as_u64() - .or_else(|| p.as_i64().map(|n| n.max(0) as u64)) - .or_else(|| p.as_f64().map(|n| n.max(0.0) as u64)) - }) { - return p as u16; - } - } - } - return 3000; - } - if v.is_number() { - let n = v.to_number(); - if n > 0.0 { - return n as u16; - } - } - 3000 -} - -/// Read `reusePort: true` from a `{ port, reusePort }` listen-options object. -/// `reusePort` is a real Node (`net` / `http` `listen`) and Bun (`Bun.serve`) -/// option that sets SO_REUSEPORT so multiple processes can share one port; -/// honoring it lets a non-cluster program opt into port sharing directly. -/// Defaults to false for a bare-number `listen(port)` or a missing option. -unsafe fn extract_reuse_port(opts: f64) -> bool { - let v = JsValue::from_bits(opts.to_bits()); - if v.is_pointer() { - if let Some(json) = perry_ffi::json_stringify(v) { - if let Ok(parsed) = serde_json::from_str::(&json) { - return parsed - .get("reusePort") - .and_then(|p| p.as_bool()) - .unwrap_or(false); - } - } - } - false -} - -/// Hand a failed bind to the `(err, address)` listen callback as a Node-style -/// system `Error` (`.code` / `.syscall` / `.errno`), so user code branching on -/// `err.code === 'EADDRINUSE'` sees the failure instead of being told the -/// server is listening. Called before any server registration or success -/// callback, so a port clash can no longer masquerade as a successful listen. -unsafe fn fire_listen_error(callback: i64, e: &std::io::Error, port: u16) { - eprintln!("[fastify] listen on 0.0.0.0:{} failed: {}", port, e); - if callback == 0 { - return; - } - let raw = if (callback as u64 & 0xFFFF_0000_0000_0000) == POINTER_TAG { - (callback as u64 & PTR_MASK) as *const RawClosureHeader - } else { - callback as *const RawClosureHeader - }; - let closure = JsClosure::from_raw(raw); - if closure.is_null() { - return; - } - let code: std::borrow::Cow<'static, str> = match e.kind() { - std::io::ErrorKind::AddrInUse => "EADDRINUSE".into(), - std::io::ErrorKind::PermissionDenied => "EACCES".into(), - std::io::ErrorKind::AddrNotAvailable => "EADDRNOTAVAIL".into(), - // Any other bind/setup errno: carry it through as `E` so the - // error stays truthy and inspectable rather than being mislabeled. - _ => match e.raw_os_error() { - Some(n) => format!("E{}", n).into(), - None => "EUNKNOWN".into(), - }, - }; - // Node/libuv reports the negated OS errno. - let errno = e.raw_os_error().map(|n| -(n as i64)).unwrap_or(0); - let msg = format!("listen {} 0.0.0.0:{}", code, port); - let err_val = perry_ffi::system_error_value(&msg, &code, "listen", errno); - let _ = closure.call2( - f64::from_bits(err_val.bits()), - f64::from_bits(TAG_UNDEFINED), - ); -} - -// `js_promise_reason` is declared so wrappers that want to surface -// rejected-promise errors can use it; not consumed by the v0 port, -// but kept in the extern block so signature drift causes a link -// error rather than UB. -#[allow(dead_code)] -unsafe fn _force_promise_reason_link(p: *mut Promise) -> f64 { - js_promise_reason(p) -} - -#[cfg(test)] -mod tests { - //! Tests for the re-entrancy deferred-queue path of - //! `js_fastify_process_pending`. These exercise the real - //! `drain_server_requests_into_deferred` / `try_recv_pending_request` - //! helpers against a live `FastifyServerHandle` + mpsc channel — no JS - //! runtime needed (the drain path is pure Rust). The full pump re-entry - //! (guard → defer → outer-tail dispatch) is exercised end-to-end by - //! `scripts/run_fastify_tests.sh`, which runs awaited handlers. - use super::*; - use perry_ffi::drop_handle; - use std::collections::{HashMap, VecDeque}; - - /// Build a pending request tagged by `path`; return it plus its response - /// receiver so a test can observe `response_tx`'s fate (kept open vs dropped). - fn make_pending(path: &str) -> (FastifyPendingRequest, oneshot::Receiver) { - let (response_tx, response_rx) = oneshot::channel::(); - let pending = FastifyPendingRequest { - method: "GET".to_string(), - path: path.to_string(), - headers: HashMap::new(), - body: None, - params: HashMap::new(), - response_tx, - }; - (pending, response_rx) - } - - /// Register a `FastifyServerHandle` wrapping `rx`. Returns (server, app) - /// handles; the caller drops both. - fn register_test_server(rx: mpsc::Receiver) -> (Handle, Handle) { - let app_handle = register_handle(FastifyApp::new()); - let server = FastifyServerHandle { - port: 0, - app_handle, - shutdown_tx: None, - request_rx: Mutex::new(Some(rx)), - upgrade_rx: Mutex::new(None), - listening: AtomicBool::new(true), - }; - (register_handle(server), app_handle) - } - - /// Cap + FIFO + full-drain: over-cap entries are dropped, the kept ones stay - /// in arrival order, and — critically — the channel is fully drained even - /// past the cap (no request is left stranded in the mpsc). - #[test] - fn deferred_drain_respects_cap_and_drains_channel_fully() { - let n = 10usize; - let cap = 4usize; - let (tx, rx) = mpsc::channel::(n + 1); - let (server_h, app_h) = register_test_server(rx); - let mut rxs = Vec::new(); - for i in 0..n { - let (p, r) = make_pending(&format!("req-{i}")); - tx.try_send(p).expect("send"); - rxs.push(r); - } - - let mut deferred: VecDeque<(Handle, FastifyPendingRequest)> = VecDeque::new(); - let moved = drain_server_requests_into_deferred(server_h, app_h, &mut deferred, cap); - - assert_eq!(moved, cap, "exactly `cap` entries are moved"); - assert_eq!(deferred.len(), cap); - for (i, (h, p)) in deferred.iter().enumerate() { - assert_eq!(*h, app_h, "captured under the right app handle"); - assert_eq!(p.path, format!("req-{i}"), "FIFO arrival order preserved"); - } - assert!( - try_recv_pending_request(server_h).is_none(), - "channel must be fully drained even past the cap" - ); - - drop_handle(server_h); - drop_handle(app_h); - drop(rxs); - } - - /// Under cap: every request is captured, in arrival order, and none is - /// dropped — every kept request's response channel stays open (the client - /// will get a real response, not an error). - #[test] - fn deferred_drain_under_cap_moves_all_without_dropping() { - let n = 5usize; - let (tx, rx) = mpsc::channel::(n + 1); - let (server_h, app_h) = register_test_server(rx); - let mut rxs = Vec::new(); - for i in 0..n { - let (p, r) = make_pending(&format!("r{i}")); - tx.try_send(p).expect("send"); - rxs.push(r); - } - - let mut deferred: VecDeque<(Handle, FastifyPendingRequest)> = VecDeque::new(); - let moved = - drain_server_requests_into_deferred(server_h, app_h, &mut deferred, DEFERRED_QUEUE_CAP); - - assert_eq!(moved, n, "all moved when under cap"); - for (i, (_, p)) in deferred.iter().enumerate() { - assert_eq!(p.path, format!("r{i}")); - } - // Nothing dropped: every response channel is still open (Empty, not Closed). - for r in &mut rxs { - assert!( - matches!(r.try_recv(), Err(oneshot::error::TryRecvError::Empty)), - "kept request's response_tx must remain alive" - ); - } - - drop_handle(server_h); - drop_handle(app_h); - } - - /// Backpressure contract: when the cap is hit, the OVER-cap requests are - /// dropped, and dropping a pending drops its `response_tx`, which makes the - /// awaiting hyper service-fn resolve `Closed` → an error response. This - /// proves the client is signalled (no hang / no leak) rather than the - /// request silently vanishing. - #[test] - fn deferred_drain_over_cap_signals_dropped_clients() { - let n = 8usize; - let cap = 3usize; - let (tx, rx) = mpsc::channel::(n + 1); - let (server_h, app_h) = register_test_server(rx); - let mut rxs = Vec::new(); - for i in 0..n { - let (p, r) = make_pending(&format!("q{i}")); - tx.try_send(p).expect("send"); - rxs.push(r); - } - - let mut deferred: VecDeque<(Handle, FastifyPendingRequest)> = VecDeque::new(); - drain_server_requests_into_deferred(server_h, app_h, &mut deferred, cap); - - // Kept entries: response channel still open. - for r in rxs.iter_mut().take(cap) { - assert!( - matches!(r.try_recv(), Err(oneshot::error::TryRecvError::Empty)), - "kept entries stay open" - ); - } - // Over-cap entries: dropped → response_tx Drop → client observes Closed. - for r in rxs.iter_mut().skip(cap) { - assert!( - matches!(r.try_recv(), Err(oneshot::error::TryRecvError::Closed)), - "over-cap entries must signal the client (error response, not a hang)" - ); - } - - drop_handle(server_h); - drop_handle(app_h); - } - - /// No-loss / no-duplication property across multiple servers + repeated - /// drain passes (simulating repeated nested pump entries): every queued - /// request lands in DEFERRED exactly once, in per-server FIFO order, and a - /// second pass captures nothing (channels already drained). - #[test] - fn deferred_drain_no_loss_across_servers_and_repeated_passes() { - let mut deferred: VecDeque<(Handle, FastifyPendingRequest)> = VecDeque::new(); - let mut servers: Vec<( - Handle, - Handle, - mpsc::Sender, - usize, - usize, - )> = Vec::new(); - let mut expected_total = 0usize; - for (s, depth) in [(0usize, 1usize), (1, 5), (2, 0), (3, 12)] { - let (tx, rx) = mpsc::channel::(depth + 1); - let (server_h, app_h) = register_test_server(rx); - for i in 0..depth { - let (p, _r) = make_pending(&format!("s{s}-r{i}")); - tx.try_send(p).expect("send"); - // `_r` dropped here is harmless: it never affects the moved sender. - } - expected_total += depth; - servers.push((server_h, app_h, tx, s, depth)); - } - - // First pass: capture everything exactly once. - for (server_h, app_h, _tx, _s, _d) in &servers { - drain_server_requests_into_deferred( - *server_h, - *app_h, - &mut deferred, - DEFERRED_QUEUE_CAP, - ); - } - assert_eq!( - deferred.len(), - expected_total, - "every request captured exactly once" - ); - - // Second pass: nothing new (no loss, no double-capture). - let before = deferred.len(); - for (server_h, app_h, _tx, _s, _d) in &servers { - drain_server_requests_into_deferred( - *server_h, - *app_h, - &mut deferred, - DEFERRED_QUEUE_CAP, - ); - } - assert_eq!(deferred.len(), before, "repeated drain captures nothing"); - - // Per-server FIFO order preserved. - for (_, app_h, _tx, s, depth) in &servers { - let seq: Vec = deferred - .iter() - .filter(|(h, _)| h == app_h) - .map(|(_, p)| p.path.clone()) - .collect(); - let want: Vec = (0..*depth).map(|i| format!("s{s}-r{i}")).collect(); - assert_eq!(seq, want, "per-server FIFO order"); - } - - for (server_h, app_h, _tx, _s, _d) in servers { - drop_handle(server_h); - drop_handle(app_h); - } - } -} diff --git a/crates/perry-ext-fastify/src/upgrade.rs b/crates/perry-ext-fastify/src/upgrade.rs deleted file mode 100644 index 454f469090..0000000000 --- a/crates/perry-ext-fastify/src/upgrade.rs +++ /dev/null @@ -1,187 +0,0 @@ -//! #1113 — `app.server.on("upgrade", (req, socket, head) => …)` for -//! HTTP Upgrade requests (WebSocket handshakes) on a fastify app. -//! -//! Mirrors perry-ext-http's `upgrade.rs` (issue #577 Phase 4), -//! the proven template for bidirectional WebSocket upgrade dispatch. -//! -//! # Design -//! -//! When the hyper service fn in `server.rs` sees a request with -//! `Connection: Upgrade` + `Upgrade: websocket`, fastify diverges -//! from the normal route flow: -//! -//! 1. The accepting tokio task awaits `hyper::upgrade::on(&mut req)`, -//! yielding an `Upgraded` stream after hyper sends the 101. -//! 2. It builds a `tokio_tungstenite::WebSocketStream` from that raw -//! socket with `Role::Server` (the handshake bytes were already -//! exchanged via the 101 response we returned synchronously). -//! 3. The resulting stream is registered in perry-ext-ws's connection -//! registry through `perry_ext_ws::register_external_ws_stream`, -//! yielding the standard `ws_id`. -//! 4. The fastify `upgrade_handlers` (registered via -//! `app.server.on("upgrade", cb)`) are fired with -//! `(req, ws_id, head)`. `ws_id` is the same integer id the -//! standalone `WebSocketServer({port})` path produces, so -//! `wss.handleUpgrade(req, socket, head, cb)` re-dispatches it -//! through perry-ext-ws's `js_ws_handle_upgrade`. - -use perry_ffi::{ - alloc_string, build_object_shape, get_handle, js_object_alloc_with_shape, js_object_set_field, - Handle, JsClosure, JsValue, ObjectHeader, RawClosureHeader, -}; -use std::collections::HashMap; - -use crate::app::FastifyApp; - -const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; -const PTR_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; -const STRING_TAG: u64 = 0x7FFF_0000_0000_0000; -const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; - -extern "C" { - fn js_promise_run_microtasks() -> i32; -} - -/// Test whether a request looks like a WebSocket upgrade — checks -/// `Connection: Upgrade` (case-insensitive contains) and -/// `Upgrade: websocket` (case-insensitive). Hyper's `headers()` -/// already lowercases names, so we only normalize values. Identical -/// to perry-ext-http's `is_websocket_upgrade`. -pub(crate) fn is_websocket_upgrade(req: &hyper::Request) -> bool { - let h = req.headers(); - let connection_ok = h - .get("connection") - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_ascii_lowercase().contains("upgrade")) - .unwrap_or(false); - let upgrade_ok = h - .get("upgrade") - .and_then(|v| v.to_str().ok()) - .map(|s| s.eq_ignore_ascii_case("websocket")) - .unwrap_or(false); - connection_ok && upgrade_ok -} - -/// Build a minimal pointer-tagged request object exposing -/// `{ method, url, headers }`. `headers` is a nested object of -/// lowercased name → value. Returns the NaN-boxed (POINTER_TAG) bits -/// as f64, or `undefined` on allocation failure. -/// -/// This must run from the main thread. The hyper worker queues the raw -/// method/url/headers in `FastifyPendingUpgrade`; allocating the JS object -/// here avoids holding unscannable JS heap pointers across the worker-to-main -/// handoff. -/// -/// Fastify's per-handler request is backed by a `FastifyContext` -/// handle dispatched via the `request.*` codegen arm — that path is -/// tightly coupled to the route dispatcher and oneshot response -/// channel, so reusing it for an upgrade (which has no reply) would -/// require threading a no-op response. We instead allocate a plain -/// object with the request-shaped fields the `'upgrade'` handler -/// reads (`req.headers`, `req.url`, `req.method`). It's a real -/// pointer-tagged object so `typeof req === "object"`. -pub(crate) unsafe fn build_request_object( - method: &str, - url: &str, - headers: &HashMap, -) -> f64 { - let undefined = f64::from_bits(TAG_UNDEFINED); - - let headers_obj = build_string_map_object(headers).unwrap_or(undefined); - - let keys = ["method", "url", "headers"]; - let (packed, shape_id) = build_object_shape(&keys); - let obj: *mut ObjectHeader = js_object_alloc_with_shape( - shape_id, - keys.len() as u32, - packed.as_ptr(), - packed.len() as u32, - ); - if obj.is_null() { - return undefined; - } - let method_s = alloc_string(method); - js_object_set_field(obj, 0, JsValue::from_string_ptr(method_s.as_raw())); - let url_s = alloc_string(url); - js_object_set_field(obj, 1, JsValue::from_string_ptr(url_s.as_raw())); - js_object_set_field(obj, 2, JsValue::from_bits(headers_obj.to_bits())); - - let v = JsValue::from_object_ptr(obj); - f64::from_bits(v.bits()) -} - -unsafe fn build_string_map_object(map: &HashMap) -> Option { - let keys: Vec<&str> = map.keys().map(|s| s.as_str()).collect(); - let (packed, shape_id) = build_object_shape(&keys); - let count = keys.len() as u32; - let obj: *mut ObjectHeader = - js_object_alloc_with_shape(shape_id, count, packed.as_ptr(), packed.len() as u32); - if obj.is_null() { - return None; - } - for (i, key) in keys.iter().enumerate() { - if let Some(val) = map.get(*key) { - let s = alloc_string(val); - js_object_set_field(obj, i as u32, JsValue::from_string_ptr(s.as_raw())); - } - } - let v = JsValue::from_object_ptr(obj); - Some(f64::from_bits(v.bits())) -} - -/// Fire the `app.server.on("upgrade", …)` handlers with -/// `(req, ws_id, head)`. Called from the main-thread pump after the -/// upgrade has been registered with perry-ext-ws. -/// -/// NaN-boxing mirrors the http template exactly: -/// - `req` is a pointer-tagged minimal request object. -/// - `ws_id` is encoded as `POINTER_TAG | (ws_id & PTR_MASK)` so -/// the codegen `unbox_to_i64` at every `wss.handleUpgrade(...)` / -/// `wsId.send(...)` callsite extracts the original integer id. -/// - `head` is a STRING_TAG string when non-empty, else undefined. -pub(crate) fn fire_fastify_upgrade_listeners( - app_handle: Handle, - req_handle_bits: i64, - ws_id: i64, - head_data: Vec, -) { - let listeners = match get_handle::(app_handle) { - Some(app) => app.upgrade_handlers.clone(), - None => return, - }; - if listeners.is_empty() { - return; - } - - let req_f64 = f64::from_bits(req_handle_bits as u64); - let ws_id_f64 = f64::from_bits(POINTER_TAG | (ws_id as u64 & PTR_MASK)); - let head_f64 = if head_data.is_empty() { - f64::from_bits(TAG_UNDEFINED) - } else { - let s = String::from_utf8_lossy(&head_data).into_owned(); - let header = alloc_string(&s); - f64::from_bits(STRING_TAG | (header.as_raw() as u64 & PTR_MASK)) - }; - - for cb in &listeners { - if *cb == 0 { - continue; - } - unsafe { - let raw = *cb as *const RawClosureHeader; - let closure = JsClosure::from_raw(raw); - if !closure.is_null() { - let _ = closure.call3(req_f64, ws_id_f64, head_f64); - } - js_promise_run_microtasks(); - } - } -} - -#[allow(dead_code)] -// The `& 0` is deliberate: this linker anchor only exists to keep the tag -// constants referenced; the composed value itself is meaningless. -#[allow(clippy::erasing_op)] -fn _force_link() -> u64 { - POINTER_TAG | (PTR_MASK & 0) | STRING_TAG | TAG_UNDEFINED -} diff --git a/crates/perry-ext-qs/Cargo.toml b/crates/perry-ext-qs/Cargo.toml deleted file mode 100644 index 96211bb51e..0000000000 --- a/crates/perry-ext-qs/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "perry-ext-qs" -version.workspace = true -edition.workspace = true -license.workspace = true -description = "Native qs compatibility binding for nested query-string parsing and serialization" - -[lints] -workspace = true - -[lib] -crate-type = ["staticlib", "rlib"] - -[dependencies] -perry-ffi.workspace = true -serde_json.workspace = true - -[dev-dependencies] -perry-ffi = { workspace = true, features = ["runtime-link"] } -perry-runtime = { workspace = true, features = ["default", "stdlib"] } diff --git a/crates/perry-ext-qs/src/codec.rs b/crates/perry-ext-qs/src/codec.rs deleted file mode 100644 index d2458cfd98..0000000000 --- a/crates/perry-ext-qs/src/codec.rs +++ /dev/null @@ -1,128 +0,0 @@ -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum Charset { - Utf8, - Latin1, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum Format { - Rfc1738, - Rfc3986, -} - -pub(crate) fn encode(input: &str, charset: Charset, format: Format) -> String { - let mut out = String::with_capacity(input.len()); - match charset { - Charset::Utf8 => { - for &byte in input.as_bytes() { - if is_safe(byte, format) { - out.push(byte as char); - } else { - push_escape(&mut out, byte); - } - } - } - Charset::Latin1 => { - for unit in input.encode_utf16() { - if unit <= 0xFF { - let byte = unit as u8; - if is_safe(byte, format) { - out.push(byte as char); - } else { - push_escape(&mut out, byte); - } - } else { - out.push_str("%26%23"); - out.push_str(&unit.to_string()); - out.push_str("%3B"); - } - } - } - } - if format == Format::Rfc1738 { - out = out.replace("%20", "+"); - } - out -} - -pub(crate) fn format_encoded(input: String, format: Format) -> String { - if format == Format::Rfc1738 { - input.replace("%20", "+") - } else { - input - } -} - -pub(crate) fn decode(input: &str, charset: Charset) -> String { - let plus_replaced = input.replace('+', " "); - let mut bytes = Vec::with_capacity(plus_replaced.len()); - let raw = plus_replaced.as_bytes(); - let mut index = 0; - let mut invalid_escape = false; - while index < raw.len() { - if raw[index] == b'%' { - if index + 2 < raw.len() { - if let (Some(high), Some(low)) = (hex(raw[index + 1]), hex(raw[index + 2])) { - bytes.push((high << 4) | low); - index += 3; - continue; - } - } - invalid_escape = true; - } - bytes.push(raw[index]); - index += 1; - } - - match charset { - Charset::Utf8 if invalid_escape => plus_replaced, - Charset::Utf8 => String::from_utf8(bytes).unwrap_or(plus_replaced), - Charset::Latin1 => bytes.into_iter().map(char::from).collect(), - } -} - -fn is_safe(byte: u8, format: Format) -> bool { - byte.is_ascii_alphanumeric() - || matches!(byte, b'-' | b'.' | b'_' | b'~') - || (format == Format::Rfc1738 && matches!(byte, b'(' | b')')) -} - -fn push_escape(out: &mut String, byte: u8) { - const HEX: &[u8; 16] = b"0123456789ABCDEF"; - out.push('%'); - out.push(HEX[(byte >> 4) as usize] as char); - out.push(HEX[(byte & 0xF) as usize] as char); -} - -fn hex(byte: u8) -> Option { - match byte { - b'0'..=b'9' => Some(byte - b'0'), - b'a'..=b'f' => Some(byte - b'a' + 10), - b'A'..=b'F' => Some(byte - b'A' + 10), - _ => None, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn rfc3986_encoding_matches_qs_defaults() { - assert_eq!( - encode("a b[c]/✓", Charset::Utf8, Format::Rfc3986), - "a%20b%5Bc%5D%2F%E2%9C%93" - ); - } - - #[test] - fn rfc1738_uses_plus_and_preserves_parentheses() { - assert_eq!(encode("a b(c)", Charset::Utf8, Format::Rfc1738), "a+b(c)"); - } - - #[test] - fn decoder_is_lenient_like_decode_uri_component_wrapper() { - assert_eq!(decode("a+b%5Bc%5D", Charset::Utf8), "a b[c]"); - assert_eq!(decode("bad%ZZ", Charset::Utf8), "bad%ZZ"); - } -} diff --git a/crates/perry-ext-qs/src/lib.rs b/crates/perry-ext-qs/src/lib.rs deleted file mode 100644 index 7e360fb168..0000000000 --- a/crates/perry-ext-qs/src/lib.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! Native compatibility binding for [`qs`](https://www.npmjs.com/package/qs). -//! -//! The binding exists primarily so packages such as Stripe can retain qs' -//! nested request encoding without asking Perry's AOT compiler to compile the -//! legacy `get-intrinsic` / ES-shims dependency chain. The implementation is -//! intentionally dependency-light and crosses the runtime only through the -//! stable `perry-ffi` surface plus existing C ABI symbols. - -mod codec; -mod options; -mod parse; -mod runtime; -mod stringify; - -#[cfg(test)] -mod test_async_shims; - -use perry_ffi::{alloc_string, read_string, JsString, StringHeader, TransientRootScope}; - -/// `qs.stringify(value, options?)`. -#[no_mangle] -pub extern "C" fn js_qs_stringify(value: f64, options: f64) -> *mut StringHeader { - alloc_string(&stringify::stringify(value, options)).as_raw() -} - -/// `qs.parse(input, options?)`. -/// -/// # Safety -/// `input` must be null or a live Perry `StringHeader` pointer. -#[no_mangle] -pub unsafe extern "C" fn js_qs_parse( - input: *const StringHeader, - options: f64, -) -> *mut StringHeader { - let input = if input.is_null() { - String::new() - } else { - let input = JsString::from_raw(input as *mut StringHeader); - read_string(input).unwrap_or_default().to_owned() - }; - let scope = TransientRootScope::enter(); - let mut options = options::ParseOptions::from_js(&scope, options); - let value = parse::parse(&input, &mut options); - let json = serde_json::to_string(&value).expect("qs parse tree is JSON serializable"); - alloc_string(&json).as_raw() -} diff --git a/crates/perry-ext-qs/src/options.rs b/crates/perry-ext-qs/src/options.rs deleted file mode 100644 index 648d9b5e42..0000000000 --- a/crates/perry-ext-qs/src/options.rs +++ /dev/null @@ -1,360 +0,0 @@ -use crate::codec::{Charset, Format}; -use crate::runtime; -use perry_ffi::{ - js_array_get, js_array_length, throw_with_code, ErrorKind, JsValue, TransientRootScope, - TransientRootedNanbox, -}; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum ArrayFormat { - Brackets, - Comma, - Indices, - Repeat, -} - -pub(crate) struct StringifyOptions { - pub(crate) add_query_prefix: bool, - pub(crate) allow_dots: bool, - pub(crate) allow_empty_arrays: bool, - pub(crate) array_format: ArrayFormat, - pub(crate) charset: Charset, - pub(crate) charset_sentinel: bool, - pub(crate) comma_round_trip: bool, - pub(crate) delimiter: String, - pub(crate) encode: bool, - pub(crate) encode_dot_in_keys: bool, - pub(crate) encode_values_only: bool, - pub(crate) format: Format, - pub(crate) skip_nulls: bool, - pub(crate) strict_null_handling: bool, - pub(crate) encoder: Option, - pub(crate) filter: Option, - pub(crate) filter_keys: Option>, - pub(crate) serialize_date: Option, - pub(crate) sort: Option, -} - -impl Default for StringifyOptions { - fn default() -> Self { - Self { - add_query_prefix: false, - allow_dots: false, - allow_empty_arrays: false, - array_format: ArrayFormat::Indices, - charset: Charset::Utf8, - charset_sentinel: false, - comma_round_trip: false, - delimiter: "&".to_owned(), - encode: true, - encode_dot_in_keys: false, - encode_values_only: false, - format: Format::Rfc3986, - skip_nulls: false, - strict_null_handling: false, - encoder: None, - filter: None, - filter_keys: None, - serialize_date: None, - sort: None, - } - } -} - -impl StringifyOptions { - pub(crate) fn from_js(scope: &TransientRootScope, raw: f64) -> Self { - let mut result = Self::default(); - let value = runtime::from_f64(raw); - if !value.is_pointer() || runtime::is_closure(value) { - return result; - } - let options = scope.root_nanbox(raw); - - validate_bool(scope, &options, "allowEmptyArrays"); - validate_bool(scope, &options, "encodeDotInKeys"); - validate_bool(scope, &options, "commaRoundTrip"); - - result.add_query_prefix = bool_option(scope, &options, "addQueryPrefix", false); - result.allow_empty_arrays = bool_option(scope, &options, "allowEmptyArrays", false); - result.charset_sentinel = bool_option(scope, &options, "charsetSentinel", false); - result.comma_round_trip = bool_option(scope, &options, "commaRoundTrip", false); - result.encode = bool_option(scope, &options, "encode", true); - result.encode_dot_in_keys = bool_option(scope, &options, "encodeDotInKeys", false); - result.encode_values_only = bool_option(scope, &options, "encodeValuesOnly", false); - result.skip_nulls = bool_option(scope, &options, "skipNulls", false); - result.strict_null_handling = bool_option(scope, &options, "strictNullHandling", false); - - let allow_dots = field(scope, &options, "allowDots"); - result.allow_dots = if allow_dots.is_undefined() { - result.encode_dot_in_keys - } else { - truthy(allow_dots) - }; - - let delimiter = field(scope, &options, "delimiter"); - if !delimiter.is_undefined() { - result.delimiter = runtime::owned_string(scope, runtime::as_f64(delimiter)); - } - - let charset = field(scope, &options, "charset"); - if !charset.is_undefined() { - match runtime::string_value(scope, runtime::as_f64(charset)).as_deref() { - Some("utf-8") => result.charset = Charset::Utf8, - Some("iso-8859-1") => result.charset = Charset::Latin1, - _ => { - throw_type("The charset option must be either utf-8, iso-8859-1, or undefined") - } - } - } - - let format = field(scope, &options, "format"); - if !format.is_undefined() { - match runtime::string_value(scope, runtime::as_f64(format)).as_deref() { - Some("RFC1738") => result.format = Format::Rfc1738, - Some("RFC3986") => result.format = Format::Rfc3986, - _ => throw_type("Unknown format option provided."), - } - } - - let array_format = field(scope, &options, "arrayFormat"); - result.array_format = - match runtime::string_value(scope, runtime::as_f64(array_format)).as_deref() { - Some("brackets") => ArrayFormat::Brackets, - Some("comma") => ArrayFormat::Comma, - Some("repeat") => ArrayFormat::Repeat, - Some("indices") => ArrayFormat::Indices, - _ => { - let indices = field(scope, &options, "indices"); - if indices.is_undefined() || truthy(indices) { - ArrayFormat::Indices - } else { - ArrayFormat::Repeat - } - } - }; - - let encoder = field(scope, &options, "encoder"); - if !encoder.is_undefined() && !encoder.is_null() { - if !runtime::is_closure(encoder) { - throw_type("Encoder has to be a function."); - } - result.encoder = Some(scope.root_nanbox(runtime::as_f64(encoder))); - } - - let serialize_date = field(scope, &options, "serializeDate"); - if runtime::is_closure(serialize_date) { - result.serialize_date = Some(scope.root_nanbox(runtime::as_f64(serialize_date))); - } - - let sort = field(scope, &options, "sort"); - if runtime::is_closure(sort) { - result.sort = Some(scope.root_nanbox(runtime::as_f64(sort))); - } - - let filter = field(scope, &options, "filter"); - if runtime::is_closure(filter) { - result.filter = Some(scope.root_nanbox(runtime::as_f64(filter))); - } else if runtime::is_array(runtime::as_f64(filter)) { - let filter = scope.root_nanbox(runtime::as_f64(filter)); - let array = runtime::from_f64(filter.get()).as_pointer(); - let length = unsafe { js_array_length(array) }; - let mut keys = Vec::with_capacity(length as usize); - for index in 0..length { - let array = runtime::from_f64(filter.get()).as_pointer(); - let value = unsafe { js_array_get(array, index) }; - if !value.is_undefined() && !value.is_null() { - keys.push(runtime::owned_string(scope, runtime::as_f64(value))); - } - } - result.filter_keys = Some(keys); - } - - result - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum DuplicateMode { - Combine, - First, - Last, -} - -pub(crate) struct ParseOptions { - pub(crate) allow_dots: bool, - pub(crate) allow_empty_arrays: bool, - pub(crate) allow_prototypes: bool, - pub(crate) allow_sparse: bool, - pub(crate) array_limit: usize, - pub(crate) charset: Charset, - pub(crate) charset_sentinel: bool, - pub(crate) comma: bool, - pub(crate) decode_dot_in_keys: bool, - pub(crate) delimiter: String, - pub(crate) depth: usize, - pub(crate) duplicates: DuplicateMode, - pub(crate) ignore_query_prefix: bool, - pub(crate) interpret_numeric_entities: bool, - pub(crate) parameter_limit: usize, - pub(crate) parse_arrays: bool, - pub(crate) strict_depth: bool, - pub(crate) strict_null_handling: bool, - pub(crate) throw_on_limit_exceeded: bool, -} - -impl Default for ParseOptions { - fn default() -> Self { - Self { - allow_dots: false, - allow_empty_arrays: false, - allow_prototypes: false, - allow_sparse: false, - array_limit: 20, - charset: Charset::Utf8, - charset_sentinel: false, - comma: false, - decode_dot_in_keys: false, - delimiter: "&".to_owned(), - depth: 5, - duplicates: DuplicateMode::Combine, - ignore_query_prefix: false, - interpret_numeric_entities: false, - parameter_limit: 1000, - parse_arrays: true, - strict_depth: false, - strict_null_handling: false, - throw_on_limit_exceeded: false, - } - } -} - -impl ParseOptions { - pub(crate) fn from_js(scope: &TransientRootScope, raw: f64) -> Self { - let mut result = Self::default(); - let value = runtime::from_f64(raw); - if !value.is_pointer() || runtime::is_closure(value) { - return result; - } - let options = scope.root_nanbox(raw); - - result.allow_dots = bool_option(scope, &options, "allowDots", false); - result.allow_empty_arrays = bool_option(scope, &options, "allowEmptyArrays", false); - result.allow_prototypes = bool_option(scope, &options, "allowPrototypes", false); - result.allow_sparse = bool_option(scope, &options, "allowSparse", false); - result.charset_sentinel = bool_option(scope, &options, "charsetSentinel", false); - result.comma = bool_option(scope, &options, "comma", false); - result.decode_dot_in_keys = bool_option(scope, &options, "decodeDotInKeys", false); - result.ignore_query_prefix = bool_option(scope, &options, "ignoreQueryPrefix", false); - result.interpret_numeric_entities = - bool_option(scope, &options, "interpretNumericEntities", false); - result.parse_arrays = bool_option(scope, &options, "parseArrays", true); - result.strict_depth = bool_option(scope, &options, "strictDepth", false); - result.strict_null_handling = bool_option(scope, &options, "strictNullHandling", false); - result.throw_on_limit_exceeded = - bool_option(scope, &options, "throwOnLimitExceeded", false); - - result.array_limit = number_option(scope, &options, "arrayLimit", 20); - result.depth = number_option(scope, &options, "depth", 5); - result.parameter_limit = number_option(scope, &options, "parameterLimit", 1000); - - let delimiter = field(scope, &options, "delimiter"); - if !delimiter.is_undefined() { - if delimiter.is_pointer() && !delimiter.is_any_string() { - throw_type("Regular-expression delimiters are not supported by the native qs shim"); - } - result.delimiter = runtime::owned_string(scope, runtime::as_f64(delimiter)); - } - - let charset = field(scope, &options, "charset"); - if !charset.is_undefined() { - match runtime::string_value(scope, runtime::as_f64(charset)).as_deref() { - Some("utf-8") => result.charset = Charset::Utf8, - Some("iso-8859-1") => result.charset = Charset::Latin1, - _ => { - throw_type("The charset option must be either utf-8, iso-8859-1, or undefined") - } - } - } - - let duplicates = field(scope, &options, "duplicates"); - if !duplicates.is_undefined() { - result.duplicates = - match runtime::string_value(scope, runtime::as_f64(duplicates)).as_deref() { - Some("combine") => DuplicateMode::Combine, - Some("first") => DuplicateMode::First, - Some("last") => DuplicateMode::Last, - _ => throw_type("The duplicates option must be either combine, first, or last"), - }; - } - - let decoder = field(scope, &options, "decoder"); - if !decoder.is_undefined() && !decoder.is_null() { - if !runtime::is_closure(decoder) { - throw_type("Decoder has to be a function."); - } - throw_type("Custom decoders are not supported by the native qs shim"); - } - - result - } -} - -fn field(scope: &TransientRootScope, options: &TransientRootedNanbox, name: &str) -> JsValue { - runtime::field_by_name(scope, options, name) -} - -fn bool_option( - scope: &TransientRootScope, - options: &TransientRootedNanbox, - name: &str, - default: bool, -) -> bool { - let value = field(scope, options, name); - if value.is_bool() { - value.to_bool() - } else { - default - } -} - -fn validate_bool(scope: &TransientRootScope, options: &TransientRootedNanbox, name: &str) { - let value = field(scope, options, name); - if !value.is_undefined() && !value.is_bool() { - throw_type(&format!( - "`{name}` option can only be `true` or `false`, when provided" - )); - } -} - -fn number_option( - scope: &TransientRootScope, - options: &TransientRootedNanbox, - name: &str, - default: usize, -) -> usize { - let value = field(scope, options, name); - if value.is_number() { - let value = value.to_number(); - if value.is_finite() && value >= 0.0 { - return value.floor() as usize; - } - } - default -} - -fn truthy(value: JsValue) -> bool { - if value.is_undefined() || value.is_null() { - false - } else if value.is_bool() { - value.to_bool() - } else if value.is_number() { - let number = value.to_number(); - number != 0.0 && !number.is_nan() - } else { - true - } -} - -fn throw_type(message: &str) -> ! { - throw_with_code(message, "", ErrorKind::TypeError) -} diff --git a/crates/perry-ext-qs/src/parse.rs b/crates/perry-ext-qs/src/parse.rs deleted file mode 100644 index c22dcee8ee..0000000000 --- a/crates/perry-ext-qs/src/parse.rs +++ /dev/null @@ -1,449 +0,0 @@ -use crate::codec::{self, Charset}; -use crate::options::{DuplicateMode, ParseOptions}; -use perry_ffi::{throw_with_code, ErrorKind}; -use serde_json::{Map, Value}; - -#[derive(Clone, Debug, PartialEq, Eq)] -enum Segment { - Key(String), - Index(usize), - Append, -} - -pub(crate) fn parse(input: &str, options: &mut ParseOptions) -> Value { - let input = if options.ignore_query_prefix { - input.strip_prefix('?').unwrap_or(input) - } else { - input - }; - if input.is_empty() { - return Value::Object(Map::new()); - } - - let mut pairs: Vec<&str> = if options.delimiter.is_empty() { - vec![input] - } else { - input.split(&options.delimiter).collect() - }; - if pairs.len() > options.parameter_limit { - if options.throw_on_limit_exceeded { - throw_with_code( - &format!( - "Parameter limit exceeded. Only {} parameter{} allowed.", - options.parameter_limit, - if options.parameter_limit == 1 { - " is" - } else { - "s are" - } - ), - "", - ErrorKind::RangeError, - ); - } - pairs.truncate(options.parameter_limit); - } - - if options.charset_sentinel { - if let Some((index, charset)) = pairs.iter().enumerate().find_map(|(index, pair)| { - if *pair == "utf8=%E2%9C%93" { - Some((index, Charset::Utf8)) - } else if *pair == "utf8=%26%2310003%3B" { - Some((index, Charset::Latin1)) - } else { - None - } - }) { - options.charset = charset; - pairs.remove(index); - } - } - - let mut root = Value::Object(Map::new()); - for pair in pairs { - let (raw_key, raw_value, had_equals) = match pair.find('=') { - Some(index) => (&pair[..index], &pair[index + 1..], true), - None => (pair, "", false), - }; - let mut key = codec::decode(raw_key, options.charset); - if options.decode_dot_in_keys { - key = key.replace("%2E", ".").replace("%2e", "."); - } - let mut value = codec::decode(raw_value, options.charset); - if options.charset == Charset::Latin1 && options.interpret_numeric_entities { - value = decode_numeric_entities(&value); - } - - let mut segments = parse_segments(&key, options); - if segments.is_empty() || forbidden_path(&segments, options.allow_prototypes) { - continue; - } - - let empty_array = !had_equals - && options.allow_empty_arrays - && matches!(segments.last(), Some(Segment::Append)); - let parsed_value = if empty_array { - segments.pop(); - Value::Array(Vec::new()) - } else if !had_equals && options.strict_null_handling { - Value::Null - } else if options.comma && value.contains(',') { - Value::Array( - value - .split(',') - .map(|part| Value::String(part.to_owned())) - .collect(), - ) - } else { - Value::String(value) - }; - insert(&mut root, &segments, parsed_value, options); - } - root -} - -fn parse_segments(key: &str, options: &ParseOptions) -> Vec { - let use_dots = options.allow_dots || options.decode_dot_in_keys; - let mut raw_segments = Vec::new(); - let mut index = key - .char_indices() - .find_map(|(index, ch)| (ch == '[' || (use_dots && ch == '.')).then_some(index)) - .unwrap_or(key.len()); - raw_segments.push(key[..index].to_owned()); - let mut nested = 0usize; - - while index < key.len() { - match key.as_bytes()[index] { - b'.' if use_dots => { - let start = index + 1; - let end = key[start..] - .char_indices() - .find_map(|(offset, ch)| { - (ch == '[' || (use_dots && ch == '.')).then_some(start + offset) - }) - .unwrap_or(key.len()); - raw_segments.push(key[start..end].to_owned()); - index = end; - } - b'[' => { - let bracket_start = index; - let Some(close_offset) = key[index + 1..].find(']') else { - raw_segments.push(key[index..].to_owned()); - break; - }; - let close = index + 1 + close_offset; - nested += 1; - if nested > options.depth { - if options.strict_depth { - throw_with_code( - &format!( - "Input depth exceeded depth option of {} and strictDepth is true", - options.depth - ), - "", - ErrorKind::RangeError, - ); - } - raw_segments.push(key[bracket_start..].to_owned()); - index = key.len(); - continue; - } - raw_segments.push(key[index + 1..close].to_owned()); - index = close + 1; - } - _ => { - raw_segments.push(key[index..].to_owned()); - break; - } - } - } - - raw_segments - .into_iter() - .enumerate() - .map(|(position, segment)| { - if position > 0 && segment.is_empty() && options.parse_arrays { - Segment::Append - } else if position > 0 && options.parse_arrays { - match segment.parse::() { - Ok(index) if index <= options.array_limit => Segment::Index(index), - _ => Segment::Key(segment), - } - } else { - Segment::Key(segment) - } - }) - .collect() -} - -fn forbidden_path(segments: &[Segment], allow_prototypes: bool) -> bool { - const OBJECT_PROTOTYPE_KEYS: &[&str] = &[ - "__defineGetter__", - "__defineSetter__", - "__lookupGetter__", - "__lookupSetter__", - "constructor", - "hasOwnProperty", - "isPrototypeOf", - "propertyIsEnumerable", - "toLocaleString", - "toString", - "valueOf", - ]; - segments.iter().any(|segment| match segment { - Segment::Key(key) if key == "__proto__" => true, - Segment::Key(key) if !allow_prototypes => OBJECT_PROTOTYPE_KEYS.contains(&key.as_str()), - _ => false, - }) -} - -fn insert(node: &mut Value, segments: &[Segment], value: Value, options: &ParseOptions) { - let Some((segment, rest)) = segments.split_first() else { - merge_leaf(node, value, options.duplicates); - return; - }; - - match segment { - Segment::Key(key) => { - if !node.is_object() { - *node = Value::Object(Map::new()); - } - let object = node.as_object_mut().expect("object initialized"); - if rest.is_empty() { - match object.get_mut(key) { - Some(existing) => merge_leaf(existing, value, options.duplicates), - None => { - object.insert(key.clone(), value); - } - } - return; - } - let child = object - .entry(key.clone()) - .or_insert_with(|| empty_container(&rest[0], options)); - insert(child, rest, value, options); - } - Segment::Index(requested) => { - if !node.is_array() { - *node = Value::Array(Vec::new()); - } - let array = node.as_array_mut().expect("array initialized"); - let position = if options.allow_sparse { - while array.len() <= *requested { - array.push(Value::Null); - } - *requested - } else if *requested < array.len() { - *requested - } else { - if rest.is_empty() { - array.push(value); - return; - } - array.push(empty_container(&rest[0], options)); - let position = array.len() - 1; - insert(&mut array[position], rest, value, options); - return; - }; - if rest.is_empty() { - if options.allow_sparse && array[position].is_null() { - array[position] = value; - } else { - merge_leaf(&mut array[position], value, options.duplicates); - } - } else { - if array[position].is_null() { - array[position] = empty_container(&rest[0], options); - } - insert(&mut array[position], rest, value, options); - } - } - Segment::Append => { - if !node.is_array() { - *node = Value::Array(Vec::new()); - } - let array = node.as_array_mut().expect("array initialized"); - if rest.is_empty() { - array.push(value); - } else { - let mut child = empty_container(&rest[0], options); - insert(&mut child, rest, value, options); - array.push(child); - } - } - } -} - -fn empty_container(next: &Segment, options: &ParseOptions) -> Value { - if options.parse_arrays && matches!(next, Segment::Index(_) | Segment::Append) { - Value::Array(Vec::new()) - } else { - Value::Object(Map::new()) - } -} - -fn merge_leaf(existing: &mut Value, value: Value, mode: DuplicateMode) { - match mode { - DuplicateMode::First => {} - DuplicateMode::Last => *existing = value, - DuplicateMode::Combine => match existing { - Value::Array(values) => values.push(value), - _ => { - let previous = std::mem::replace(existing, Value::Null); - *existing = Value::Array(vec![previous, value]); - } - }, - } -} - -fn decode_numeric_entities(input: &str) -> String { - let mut output = String::with_capacity(input.len()); - let mut rest = input; - while let Some(start) = rest.find("&#") { - output.push_str(&rest[..start]); - let entity = &rest[start + 2..]; - let Some(end) = entity.find(';') else { - output.push_str(&rest[start..]); - return output; - }; - let digits = &entity[..end]; - if let Ok(codepoint) = digits.parse::() { - if let Some(ch) = char::from_u32(codepoint) { - output.push(ch); - } else { - output.push_str(&rest[start..start + end + 3]); - } - } else { - output.push_str(&rest[start..start + end + 3]); - } - rest = &entity[end + 1..]; - } - output.push_str(rest); - output -} - -#[cfg(test)] -mod tests { - use super::*; - - fn parsed(input: &str) -> Value { - parse(input, &mut ParseOptions::default()) - } - - #[test] - fn parses_nested_objects_arrays_and_duplicates() { - assert_eq!( - parsed("customer[name]=Ada&items[0][id]=price_1&items[1][id]=price_2&tag=a&tag=b"), - serde_json::json!({ - "customer": { "name": "Ada" }, - "items": [{ "id": "price_1" }, { "id": "price_2" }], - "tag": ["a", "b"] - }) - ); - } - - #[test] - fn blocks_prototype_pollution_segments() { - assert_eq!( - parsed("safe=yes&__proto__[polluted]=yes&constructor[prototype][bad]=yes"), - serde_json::json!({ "safe": "yes" }) - ); - } - - #[test] - fn array_limit_falls_back_to_object_key() { - assert_eq!(parsed("a[21]=x"), serde_json::json!({ "a": { "21": "x" } })); - } - - #[test] - fn supports_dot_and_sparse_modes() { - let mut options = ParseOptions { - allow_dots: true, - allow_sparse: true, - ..ParseOptions::default() - }; - assert_eq!( - parse("a.b[2]=x", &mut options), - serde_json::json!({ "a": { "b": [null, null, "x"] } }) - ); - } - - #[test] - fn allow_empty_arrays_matches_qs_without_strict_null_mode() { - let mut options = ParseOptions { - allow_empty_arrays: true, - ..ParseOptions::default() - }; - assert_eq!( - parse("foo[]", &mut options), - serde_json::json!({ "foo": [] }) - ); - } - - #[test] - fn duplicates_modes_match_qs() { - let mut first = ParseOptions { - duplicates: DuplicateMode::First, - ..ParseOptions::default() - }; - let mut last = ParseOptions { - duplicates: DuplicateMode::Last, - ..ParseOptions::default() - }; - assert_eq!( - parse("a=b&a=c", &mut first), - serde_json::json!({ "a": "b" }) - ); - assert_eq!(parse("a=b&a=c", &mut last), serde_json::json!({ "a": "c" })); - } - - #[test] - fn query_prefix_comma_and_strict_null_options_match_qs() { - let mut comma = ParseOptions { - ignore_query_prefix: true, - comma: true, - ..ParseOptions::default() - }; - assert_eq!( - parse("?a=b,c", &mut comma), - serde_json::json!({ "a": ["b", "c"] }) - ); - - let mut strict = ParseOptions { - strict_null_handling: true, - ..ParseOptions::default() - }; - assert_eq!( - parse("a&b=", &mut strict), - serde_json::json!({ "a": null, "b": "" }) - ); - } - - #[test] - fn charset_sentinel_depth_and_encoded_dots_match_qs() { - let mut charset = ParseOptions { - charset_sentinel: true, - ..ParseOptions::default() - }; - assert_eq!( - parse("utf8=%26%2310003%3B&a=%F8", &mut charset), - serde_json::json!({ "a": "ø" }) - ); - - assert_eq!( - parsed("a[b][c][d][e][f][g]=h"), - serde_json::json!({ - "a": { "b": { "c": { "d": { "e": { "f": { "[g]": "h" } } } } } } - }) - ); - - let mut dots = ParseOptions { - decode_dot_in_keys: true, - ..ParseOptions::default() - }; - assert_eq!( - parse("a%2Eb=c", &mut dots), - serde_json::json!({ "a": { "b": "c" } }) - ); - } -} diff --git a/crates/perry-ext-qs/src/runtime.rs b/crates/perry-ext-qs/src/runtime.rs deleted file mode 100644 index 8e46266fc7..0000000000 --- a/crates/perry-ext-qs/src/runtime.rs +++ /dev/null @@ -1,134 +0,0 @@ -use perry_ffi::{ - alloc_string, read_string, ArrayHeader, ClosureHeader, JsClosure, JsString, JsValue, - ObjectHeader, StringHeader, TransientRootScope, TransientRootedNanbox, -}; - -extern "C" { - fn js_array_is_array(value: f64) -> f64; - fn js_date_to_iso_string_or_throw(value: f64) -> *mut StringHeader; - fn js_get_string_pointer_unified(value: f64) -> i64; - fn js_jsvalue_to_string(value: f64) -> *mut StringHeader; - fn js_object_get_field_by_name( - object: *const ObjectHeader, - key: *const StringHeader, - ) -> JsValue; - fn js_object_keys_value(value: f64) -> *mut ArrayHeader; - fn js_util_types_is_date(value: f64) -> f64; - fn js_value_is_closure(value_bits: i64) -> i32; -} - -#[inline] -pub(crate) fn as_f64(value: JsValue) -> f64 { - f64::from_bits(value.bits()) -} - -#[inline] -pub(crate) fn from_f64(value: f64) -> JsValue { - JsValue::from_bits(value.to_bits()) -} - -pub(crate) fn is_array(value: f64) -> bool { - from_f64(unsafe { js_array_is_array(value) }).to_bool() -} - -pub(crate) fn is_date(value: f64) -> bool { - from_f64(unsafe { js_util_types_is_date(value) }).to_bool() -} - -pub(crate) fn is_closure(value: JsValue) -> bool { - unsafe { js_value_is_closure(value.bits() as i64) != 0 } -} - -pub(crate) fn owned_string(scope: &TransientRootScope, value: f64) -> String { - let rooted = scope.root_nanbox(value); - let ptr = unsafe { js_jsvalue_to_string(rooted.get()) }; - read_owned_header(ptr) -} - -pub(crate) fn string_value(scope: &TransientRootScope, value: f64) -> Option { - let rooted = scope.root_nanbox(value); - let js = from_f64(rooted.get()); - if !js.is_any_string() { - return None; - } - let ptr = unsafe { js_get_string_pointer_unified(rooted.get()) } as *mut StringHeader; - Some(read_owned_header(ptr)) -} - -pub(crate) fn date_iso(scope: &TransientRootScope, value: f64) -> String { - let rooted = scope.root_nanbox(value); - let ptr = unsafe { js_date_to_iso_string_or_throw(rooted.get()) }; - read_owned_header(ptr) -} - -pub(crate) fn object_keys( - scope: &TransientRootScope, - value: &TransientRootedNanbox, -) -> TransientRootedNanbox { - let keys = unsafe { js_object_keys_value(value.get()) }; - let boxed = JsValue::from_object_ptr(keys); - scope.root_nanbox(as_f64(boxed)) -} - -pub(crate) fn field_by_name( - scope: &TransientRootScope, - object: &TransientRootedNanbox, - name: &str, -) -> JsValue { - let key = JsValue::from_string_ptr(alloc_string(name).as_raw()); - let key = scope.root_nanbox(as_f64(key)); - field_by_key(object, &key) -} - -pub(crate) fn field_by_key(object: &TransientRootedNanbox, key: &TransientRootedNanbox) -> JsValue { - let key_value = from_f64(key.get()); - let key = if key_value.is_string() { - key_value.as_string_ptr() - } else { - (unsafe { js_get_string_pointer_unified(key.get()) }) as *mut StringHeader - }; - // Materializing an SSO key may allocate and move the object. Reload the - // rooted object only after the key is a stable heap string. - let object = from_f64(object.get()).as_pointer::(); - if object.is_null() || key.is_null() { - JsValue::UNDEFINED - } else { - unsafe { js_object_get_field_by_name(object, key) } - } -} - -pub(crate) fn call1(scope: &TransientRootScope, callback: &TransientRootedNanbox, arg: f64) -> f64 { - let arg = scope.root_nanbox(arg); - let callback_value = from_f64(callback.get()); - let closure = unsafe { - JsClosure::from_raw(callback_value.as_pointer::() as *const ClosureHeader) - }; - unsafe { closure.call1(arg.get()) } -} - -pub(crate) fn call2( - scope: &TransientRootScope, - callback: &TransientRootedNanbox, - arg0: f64, - arg1: f64, -) -> f64 { - let arg0 = scope.root_nanbox(arg0); - let arg1 = scope.root_nanbox(arg1); - let callback_value = from_f64(callback.get()); - let closure = unsafe { - JsClosure::from_raw(callback_value.as_pointer::() as *const ClosureHeader) - }; - unsafe { closure.call2(arg0.get(), arg1.get()) } -} - -pub(crate) fn alloc_string_value(value: &str) -> f64 { - as_f64(JsValue::from_string_ptr(alloc_string(value).as_raw())) -} - -fn read_owned_header(ptr: *mut StringHeader) -> String { - if ptr.is_null() { - return String::new(); - } - let string = unsafe { JsString::from_raw(ptr) }; - read_string(string).unwrap_or_default().to_owned() -} diff --git a/crates/perry-ext-qs/src/stringify.rs b/crates/perry-ext-qs/src/stringify.rs deleted file mode 100644 index 4e62fcf65d..0000000000 --- a/crates/perry-ext-qs/src/stringify.rs +++ /dev/null @@ -1,330 +0,0 @@ -use crate::codec; -use crate::options::{ArrayFormat, StringifyOptions}; -use crate::runtime; -use perry_ffi::{ - js_array_get, js_array_length, throw_with_code, value_byte_slice, ErrorKind, - TransientRootScope, TransientRootedNanbox, -}; -use std::cmp::Ordering; - -pub(crate) fn stringify(value: f64, options: f64) -> String { - let scope = TransientRootScope::enter(); - let options = StringifyOptions::from_js(&scope, options); - let mut root = scope.root_nanbox(value); - - if let Some(filter) = &options.filter { - root = apply_filter(&scope, filter, "", root.get()); - } - - let root_value = runtime::from_f64(root.get()); - if !root_value.is_pointer() || root_value.is_null() || runtime::is_closure(root_value) { - return String::new(); - } - - let mut keys = options - .filter_keys - .clone() - .unwrap_or_else(|| own_keys(&scope, &root)); - sort_keys(&scope, &options, &mut keys); - - let mut values = Vec::new(); - let mut ancestors = vec![root]; - for key in keys { - let value = runtime::field_by_name(&scope, &root, &key); - if options.skip_nulls && value.is_null() { - continue; - } - values.extend(stringify_value( - &scope, - &options, - runtime::as_f64(value), - key, - &mut ancestors, - )); - } - - let joined = values.join(&options.delimiter); - if joined.is_empty() { - return joined; - } - - let mut prefix = String::new(); - if options.add_query_prefix { - prefix.push('?'); - } - if options.charset_sentinel { - match options.charset { - codec::Charset::Utf8 => prefix.push_str("utf8=%E2%9C%93"), - codec::Charset::Latin1 => prefix.push_str("utf8=%26%2310003%3B"), - } - prefix.push_str(&options.delimiter); - } - prefix + joined.as_str() -} - -fn stringify_value( - scope: &TransientRootScope, - options: &StringifyOptions, - raw: f64, - mut prefix: String, - ancestors: &mut Vec, -) -> Vec { - let mut value = scope.root_nanbox(raw); - - if let Some(filter) = &options.filter { - value = apply_filter(scope, filter, &prefix, value.get()); - } else if runtime::is_date(value.get()) { - value = if let Some(callback) = &options.serialize_date { - scope.root_nanbox(runtime::call1(scope, callback, value.get())) - } else { - let iso = runtime::date_iso(scope, value.get()); - scope.root_nanbox(runtime::alloc_string_value(&iso)) - }; - } - - let js = runtime::from_f64(value.get()); - if js.is_null() { - if options.strict_null_handling { - return vec![encode_key(scope, options, &prefix)]; - } - value = scope.root_nanbox(runtime::alloc_string_value("")); - } - - let js = runtime::from_f64(value.get()); - if js.is_undefined() || runtime::is_closure(js) { - return Vec::new(); - } - - if let Some(bytes) = value_byte_slice(js) { - let text = String::from_utf8_lossy(bytes).into_owned(); - return vec![format!( - "{}={}", - encode_key(scope, options, &prefix), - encode_text(scope, options, &text, false) - )]; - } - - if !js.is_pointer() { - return vec![format!( - "{}={}", - encode_key(scope, options, &prefix), - encode_value(scope, options, value.get()) - )]; - } - - let is_array = runtime::is_array(value.get()); - if is_array && options.array_format == ArrayFormat::Comma { - return stringify_comma_array(scope, options, &value, prefix); - } - - if ancestors - .iter() - .any(|ancestor| same_heap_value(ancestor.get(), value.get())) - { - throw_with_code("Cyclic object value", "", ErrorKind::RangeError); - } - ancestors.push(value); - - let mut keys = options - .filter_keys - .clone() - .unwrap_or_else(|| own_keys(scope, &value)); - sort_keys(scope, options, &mut keys); - - if options.encode_dot_in_keys { - prefix = prefix.replace('.', "%2E"); - } - let adjusted_prefix = if is_array - && options.array_format == ArrayFormat::Comma - && options.comma_round_trip - && keys.len() == 1 - { - format!("{prefix}[]") - } else { - prefix - }; - - if options.allow_empty_arrays && is_array && keys.is_empty() { - ancestors.pop(); - return vec![format!("{adjusted_prefix}[]")]; - } - - let mut values = Vec::new(); - for key in keys { - let child = runtime::field_by_name(scope, &value, &key); - if options.skip_nulls && child.is_null() { - continue; - } - let key = if options.allow_dots && options.encode_dot_in_keys { - key.replace('.', "%2E") - } else { - key - }; - let child_prefix = if is_array { - match options.array_format { - ArrayFormat::Brackets => format!("{adjusted_prefix}[]"), - ArrayFormat::Indices => format!("{adjusted_prefix}[{key}]"), - ArrayFormat::Repeat => adjusted_prefix.clone(), - ArrayFormat::Comma => unreachable!(), - } - } else if options.allow_dots { - format!("{adjusted_prefix}.{key}") - } else { - format!("{adjusted_prefix}[{key}]") - }; - values.extend(stringify_value( - scope, - options, - runtime::as_f64(child), - child_prefix, - ancestors, - )); - } - ancestors.pop(); - values -} - -fn stringify_comma_array( - scope: &TransientRootScope, - options: &StringifyOptions, - value: &TransientRootedNanbox, - mut prefix: String, -) -> Vec { - let array = runtime::from_f64(value.get()).as_pointer(); - let length = unsafe { js_array_length(array) }; - if length == 0 { - return if options.allow_empty_arrays { - vec![format!("{prefix}[]")] - } else { - Vec::new() - }; - } - - if options.comma_round_trip && length == 1 { - prefix.push_str("[]"); - } - let mut parts = Vec::with_capacity(length as usize); - for index in 0..length { - let array = runtime::from_f64(value.get()).as_pointer(); - let mut item = unsafe { js_array_get(array, index) }; - if runtime::is_date(runtime::as_f64(item)) { - item = if let Some(callback) = &options.serialize_date { - runtime::from_f64(runtime::call1(scope, callback, runtime::as_f64(item))) - } else { - let iso = runtime::date_iso(scope, runtime::as_f64(item)); - runtime::from_f64(runtime::alloc_string_value(&iso)) - }; - } - if item.is_null() || item.is_undefined() { - parts.push(String::new()); - } else { - let text = runtime::owned_string(scope, runtime::as_f64(item)); - parts.push(if options.encode_values_only && options.encode { - encode_text(scope, options, &text, false) - } else { - text - }); - } - } - let joined = parts.join(","); - if joined.is_empty() && options.strict_null_handling { - vec![encode_key(scope, options, &prefix)] - } else { - let encoded_value = if options.encode_values_only && options.encode { - codec::format_encoded(joined, options.format) - } else { - encode_text(scope, options, &joined, false) - }; - vec![format!( - "{}={}", - encode_key(scope, options, &prefix), - encoded_value - )] - } -} - -fn own_keys(scope: &TransientRootScope, value: &TransientRootedNanbox) -> Vec { - let keys = runtime::object_keys(scope, value); - let array = runtime::from_f64(keys.get()).as_pointer(); - let length = unsafe { js_array_length(array) }; - let mut result = Vec::with_capacity(length as usize); - for index in 0..length { - let array = runtime::from_f64(keys.get()).as_pointer(); - let key = unsafe { js_array_get(array, index) }; - result.push(runtime::owned_string(scope, runtime::as_f64(key))); - } - result -} - -fn sort_keys(scope: &TransientRootScope, options: &StringifyOptions, keys: &mut [String]) { - let Some(callback) = &options.sort else { - return; - }; - keys.sort_by(|left, right| { - let left = scope.root_nanbox(runtime::alloc_string_value(left)); - let right = scope.root_nanbox(runtime::alloc_string_value(right)); - let result = runtime::from_f64(runtime::call2(scope, callback, left.get(), right.get())); - let number = result.to_number(); - if number < 0.0 { - Ordering::Less - } else if number > 0.0 { - Ordering::Greater - } else { - Ordering::Equal - } - }); -} - -fn apply_filter( - scope: &TransientRootScope, - callback: &TransientRootedNanbox, - prefix: &str, - value: f64, -) -> TransientRootedNanbox { - let value = scope.root_nanbox(value); - let prefix = scope.root_nanbox(runtime::alloc_string_value(prefix)); - scope.root_nanbox(runtime::call2(scope, callback, prefix.get(), value.get())) -} - -fn encode_key(scope: &TransientRootScope, options: &StringifyOptions, key: &str) -> String { - if options.encode_values_only { - codec::format_encoded(key.to_owned(), options.format) - } else { - encode_text(scope, options, key, true) - } -} - -fn encode_value(scope: &TransientRootScope, options: &StringifyOptions, value: f64) -> String { - if !options.encode { - return codec::format_encoded(runtime::owned_string(scope, value), options.format); - } - if let Some(callback) = &options.encoder { - let encoded = runtime::call1(scope, callback, value); - return codec::format_encoded(runtime::owned_string(scope, encoded), options.format); - } - let text = runtime::owned_string(scope, value); - codec::encode(&text, options.charset, options.format) -} - -fn encode_text( - scope: &TransientRootScope, - options: &StringifyOptions, - text: &str, - _is_key: bool, -) -> String { - if !options.encode { - return codec::format_encoded(text.to_owned(), options.format); - } - if let Some(callback) = &options.encoder { - let value = runtime::alloc_string_value(text); - let encoded = runtime::call1(scope, callback, value); - return codec::format_encoded(runtime::owned_string(scope, encoded), options.format); - } - codec::encode(text, options.charset, options.format) -} - -fn same_heap_value(left: f64, right: f64) -> bool { - let left = runtime::from_f64(left); - let right = runtime::from_f64(right); - left.is_pointer() && right.is_pointer() && left.as_pointer::() == right.as_pointer::() -} diff --git a/crates/perry-ext-qs/src/test_async_shims.rs b/crates/perry-ext-qs/src/test_async_shims.rs deleted file mode 100644 index 30431722a4..0000000000 --- a/crates/perry-ext-qs/src/test_async_shims.rs +++ /dev/null @@ -1,113 +0,0 @@ -//! Test-only host shims for the standalone extension test binary. - -use perry_ffi::{NativeAsyncCompletion, Promise}; -use std::ffi::c_void; - -#[no_mangle] -pub extern "C" fn perry_ffi_promise_new() -> *mut Promise { - perry_runtime::promise::js_promise_new() as *mut Promise -} - -#[no_mangle] -pub extern "C" fn perry_ffi_promise_resolve_bits(promise: *mut Promise, bits: u64) { - perry_runtime::promise::js_promise_resolve( - promise as *mut perry_runtime::Promise, - f64::from_bits(bits), - ); -} - -#[no_mangle] -pub extern "C" fn perry_ffi_promise_reject_bits(promise: *mut Promise, bits: u64) { - perry_runtime::promise::js_promise_reject( - promise as *mut perry_runtime::Promise, - f64::from_bits(bits), - ); -} - -#[no_mangle] -pub extern "C" fn perry_ffi_promise_resolve_deferred( - promise: *mut Promise, - context: *mut c_void, - invoke: extern "C" fn(*mut c_void) -> u64, -) { - perry_ffi_promise_resolve_bits(promise, invoke(context)); -} - -#[no_mangle] -pub extern "C" fn perry_ffi_promise_reject_deferred( - promise: *mut Promise, - context: *mut c_void, - invoke: extern "C" fn(*mut c_void) -> u64, -) { - perry_ffi_promise_reject_bits(promise, invoke(context)); -} - -#[no_mangle] -pub extern "C" fn perry_ffi_spawn_blocking( - context: *mut c_void, - invoke: extern "C" fn(*mut c_void), -) { - invoke(context); -} - -#[no_mangle] -pub extern "C" fn perry_ffi_spawn_blocking_with_reactor( - context: *mut c_void, - invoke: extern "C" fn(*mut c_void), -) { - invoke(context); -} - -#[no_mangle] -pub extern "C" fn perry_ffi_native_async_new(_flags: u32) -> *mut NativeAsyncCompletion { - std::ptr::null_mut() -} - -#[no_mangle] -pub extern "C" fn perry_ffi_native_async_promise( - _token: *mut NativeAsyncCompletion, -) -> *mut Promise { - std::ptr::null_mut() -} - -#[no_mangle] -pub extern "C" fn perry_ffi_native_async_resolve_bits( - _token: *mut NativeAsyncCompletion, - _bits: u64, -) -> i32 { - 0 -} - -#[no_mangle] -pub extern "C" fn perry_ffi_native_async_reject_bits( - _token: *mut NativeAsyncCompletion, - _bits: u64, -) -> i32 { - 0 -} - -#[no_mangle] -pub extern "C" fn perry_ffi_native_async_reject_string( - _token: *mut NativeAsyncCompletion, - _data: *const u8, - _len: usize, -) -> i32 { - 0 -} - -#[no_mangle] -pub extern "C" fn perry_ffi_native_async_cancel(_token: *mut NativeAsyncCompletion) -> i32 { - 0 -} - -#[no_mangle] -pub extern "C" fn perry_ffi_native_async_attach_handle( - _token: *mut NativeAsyncCompletion, - _handle_bits: u64, - _cleanup_flags: u32, -) -> i32 { - 0 -} - -#[no_mangle] -pub extern "C" fn perry_ffi_run_pending(_budget_ms: u64) {} diff --git a/crates/perry-ext-ratelimit/Cargo.toml b/crates/perry-ext-ratelimit/Cargo.toml deleted file mode 100644 index 9ae51a94db..0000000000 --- a/crates/perry-ext-ratelimit/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "perry-ext-ratelimit" -version.workspace = true -edition.workspace = true -license.workspace = true -description = "Native bindings for npm `rate-limiter-flexible` — uses only `perry-ffi`. Token-bucket rate limiting via `governor`. Sync + spawn_blocking-async." - -[lints] -workspace = true - -[lib] -crate-type = ["staticlib", "rlib"] - -[dependencies] -perry-ffi.workspace = true -governor = "0.10" - -[dev-dependencies] -perry-ffi = { workspace = true, features = ["runtime-link"] } diff --git a/crates/perry-ext-ratelimit/src/lib.rs b/crates/perry-ext-ratelimit/src/lib.rs deleted file mode 100644 index 45baa78cfb..0000000000 --- a/crates/perry-ext-ratelimit/src/lib.rs +++ /dev/null @@ -1,557 +0,0 @@ -//! Native bindings for the npm `rate-limiter-flexible` package — -//! `RateLimiterMemory`-compatible fixed-window rate limiting. Uses only -//! perry-ffi v0.5 strings + handles + Promise + JsValue. -//! -//! Semantics mirror the npm package's in-memory limiter: -//! `points` consumable per `duration`-second fixed window (defaults 4 / -//! 1s, like `RateLimiterAbstract`). `consume` resolves a -//! `RateLimiterRes`-shaped object `{ remainingPoints, msBeforeNext, -//! consumedPoints, isFirstInDuration }` and **rejects with the same -//! shape** when the quota is exceeded. All state math is synchronous -//! and runs on the calling (main) thread, so result objects are built -//! inline and settled immediately — no worker-arena hazards. -//! -//! The old version fed each consume through a `governor` token bucket -//! but reported `remainingPoints` as a constant (`points - n`, -//! regardless of prior consumption) and resolved a JSON *string* -//! instead of an object, so `res.remainingPoints` was `undefined` and -//! repeated consumes never counted down. - -use governor::{ - clock::DefaultClock, - state::{InMemoryState, NotKeyed}, - Quota, RateLimiter, -}; -use perry_ffi::{ - alloc_string, build_object_shape, get_handle, js_object_alloc_with_shape, js_object_set_field, - read_string, register_handle, Handle, JsPromise, JsString, JsValue, ObjectHeader, Promise, - StringHeader, -}; -use std::collections::HashMap; -use std::num::NonZeroU32; -use std::sync::Mutex; -use std::time::{Duration, Instant}; - -extern "C" { - /// perry-runtime: read an object field by string key, returning the - /// raw NaN-boxed JSValue bits as f64 (undefined tag when absent). - fn js_object_get_field_by_name_f64(obj: *const ObjectHeader, key: *const StringHeader) -> f64; -} - -/// Legacy direct (non-keyed) limiter — kept for the pre-existing -/// `js_ratelimit_new` / `js_ratelimit_check` / `js_ratelimit_remaining` -/// symbol surface. -pub struct RateLimiterHandle { - pub limiter: RateLimiter, - pub points: u32, - pub duration_secs: u64, -} - -/// Per-key fixed-window state, npm-style. -struct KeyState { - consumed: u32, - window_end: Instant, - blocked_until: Option, -} - -/// `new RateLimiterMemory({ points, duration })`. -pub struct KeyedRateLimiterHandle { - states: Mutex>, - pub points: u32, - pub duration_secs: u64, -} - -unsafe fn read_str(ptr: *const StringHeader) -> Option { - let handle = JsString::from_raw(ptr as *mut StringHeader); - read_string(handle).map(String::from) -} - -/// Build a `RateLimiterRes`-shaped result object (main thread only). -fn ratelimiter_res( - remaining_points: f64, - ms_before_next: f64, - consumed_points: f64, - is_first_in_duration: bool, -) -> JsValue { - let (packed, shape_id) = build_object_shape(&[ - "remainingPoints", - "msBeforeNext", - "consumedPoints", - "isFirstInDuration", - ]); - unsafe { - let obj = js_object_alloc_with_shape(shape_id, 4, packed.as_ptr(), packed.len() as u32); - js_object_set_field(obj, 0, JsValue::from_number(remaining_points)); - js_object_set_field(obj, 1, JsValue::from_number(ms_before_next)); - js_object_set_field(obj, 2, JsValue::from_number(consumed_points)); - js_object_set_field(obj, 3, JsValue::from_bool(is_first_in_duration)); - JsValue::from_object_ptr(obj) - } -} - -#[no_mangle] -pub extern "C" fn js_ratelimit_new(points: f64, duration_secs: f64) -> Handle { - let points = points.max(1.0) as u32; - let duration_secs = duration_secs.max(1.0) as u64; - let quota = Quota::with_period(Duration::from_secs(duration_secs)) - .unwrap() - .allow_burst(NonZeroU32::new(points).unwrap()); - let limiter = RateLimiter::direct(quota); - register_handle(RateLimiterHandle { - limiter, - points, - duration_secs, - }) -} - -#[no_mangle] -pub extern "C" fn js_ratelimit_new_keyed(points: f64, duration_secs: f64) -> Handle { - let points = points.max(1.0) as u32; - let duration_secs = duration_secs.max(1.0) as u64; - register_handle(KeyedRateLimiterHandle { - states: Mutex::new(HashMap::new()), - points, - duration_secs, - }) -} - -/// `new RateLimiterMemory(opts)` — parse `{ points, duration }` from the -/// NaN-boxed options object (raw bits as i64; TAG_UNDEFINED when the -/// constructor was called without arguments). Defaults mirror npm's -/// `RateLimiterAbstract`: points = 4, duration = 1 second. -/// -/// # Safety -/// `options_bits` must be valid NaN-box bits. -#[no_mangle] -pub unsafe extern "C" fn js_ratelimit_new_from_options(options_bits: i64) -> Handle { - let mut points: f64 = 4.0; - let mut duration: f64 = 1.0; - let jv = JsValue::from_bits(options_bits as u64); - if jv.is_pointer() { - let obj = jv.as_pointer::(); - if !obj.is_null() && (obj as usize) >= 0x100000 { - let field = |name: &str| -> f64 { - let key = alloc_string(name); - js_object_get_field_by_name_f64(obj, key.as_raw()) - }; - let read_num = |v: f64| -> Option { - let jv = JsValue::from_bits(v.to_bits()); - if jv.is_int32() { - Some(jv.to_int32() as f64) - } else if jv.is_number() && !v.is_nan() { - Some(v) - } else { - None - } - }; - if let Some(n) = read_num(field("points")) { - points = n; - } - if let Some(n) = read_num(field("duration")) { - duration = n; - } - } - } - js_ratelimit_new_keyed(points, duration) -} - -impl KeyedRateLimiterHandle { - fn window(&self) -> Duration { - Duration::from_secs(self.duration_secs) - } -} - -/// `limiter.consume(key, points = 1)` — npm-style fixed window. Resolves -/// a `RateLimiterRes` object; rejects with the same shape when the -/// window's quota is exceeded (or the key is blocked). -/// -/// # Safety -/// `key_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_ratelimit_consume( - handle: Handle, - key_ptr: *const StringHeader, - points: f64, -) -> *mut Promise { - let promise = JsPromise::new(); - let raw = promise.as_raw(); - let key = read_str(key_ptr).unwrap_or_else(|| "default".to_string()); - let consume_points = points.max(1.0) as u32; - - let Some(keyed) = get_handle::(handle) else { - promise.reject_string("Invalid rate limiter handle"); - return raw; - }; - - let now = Instant::now(); - let window = keyed.window(); - let mut states = keyed.states.lock().unwrap(); - let state = states.entry(key).or_insert_with(|| KeyState { - consumed: 0, - window_end: now + window, - blocked_until: None, - }); - - if let Some(until) = state.blocked_until { - if until > now { - let ms = until.duration_since(now).as_millis() as f64; - let res = ratelimiter_res(0.0, ms, state.consumed as f64, false); - drop(states); - promise.reject(res); - return raw; - } - state.blocked_until = None; - state.consumed = 0; - state.window_end = now + window; - } - - if now >= state.window_end { - state.consumed = 0; - state.window_end = now + window; - } - - state.consumed = state.consumed.saturating_add(consume_points); - let is_first = state.consumed == consume_points; - let ms_before_next = state.window_end.duration_since(now).as_millis() as f64; - let consumed = state.consumed as f64; - let over_limit = state.consumed > keyed.points; - let remaining = (keyed.points as f64 - consumed).max(0.0); - drop(states); - - let res = ratelimiter_res(remaining, ms_before_next, consumed, is_first && !over_limit); - if over_limit { - promise.reject(res); - } else { - promise.resolve(res); - } - raw -} - -/// `limiter.get(key)` — current state without consuming; `null` when the -/// key has no live window. -/// -/// # Safety -/// `key_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_ratelimit_get( - handle: Handle, - key_ptr: *const StringHeader, -) -> *mut Promise { - let promise = JsPromise::new(); - let raw = promise.as_raw(); - let key = read_str(key_ptr).unwrap_or_else(|| "default".to_string()); - - let Some(keyed) = get_handle::(handle) else { - promise.resolve(JsValue::NULL); - return raw; - }; - - let now = Instant::now(); - let states = keyed.states.lock().unwrap(); - match states.get(&key) { - Some(state) if now < state.window_end || state.blocked_until.is_some_and(|u| u > now) => { - let ms = state - .blocked_until - .filter(|u| *u > now) - .unwrap_or(state.window_end) - .duration_since(now) - .as_millis() as f64; - let consumed = state.consumed as f64; - // A key still inside an active block reports zero remaining - // points even after its normal window would have expired. - let blocked = state.blocked_until.is_some_and(|u| u > now); - let remaining = if blocked { - 0.0 - } else { - (keyed.points as f64 - consumed).max(0.0) - }; - let res = ratelimiter_res(remaining, ms, consumed, false); - drop(states); - promise.resolve(res); - } - _ => { - drop(states); - promise.resolve(JsValue::NULL); - } - } - raw -} - -/// `limiter.delete(key)` — drop the key's window. Resolves `true` when a -/// record existed. -/// -/// # Safety -/// `key_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_ratelimit_delete( - handle: Handle, - key_ptr: *const StringHeader, -) -> *mut Promise { - let promise = JsPromise::new(); - let raw = promise.as_raw(); - let key = read_str(key_ptr).unwrap_or_else(|| "default".to_string()); - - if let Some(keyed) = get_handle::(handle) { - let removed = keyed.states.lock().unwrap().remove(&key).is_some(); - promise.resolve(JsValue::from_bool(removed)); - } else { - promise.resolve(JsValue::FALSE); - } - raw -} - -/// `limiter.block(key, secDuration)` — block the key for `secDuration` -/// seconds (0 = forever). Resolves a `RateLimiterRes`. -/// -/// # Safety -/// `key_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_ratelimit_block( - handle: Handle, - key_ptr: *const StringHeader, - duration_sec: f64, -) -> *mut Promise { - let promise = JsPromise::new(); - let raw = promise.as_raw(); - let key = read_str(key_ptr).unwrap_or_else(|| "default".to_string()); - - let Some(keyed) = get_handle::(handle) else { - promise.resolve(JsValue::UNDEFINED); - return raw; - }; - - let now = Instant::now(); - let secs = duration_sec.max(0.0); - let until = if secs == 0.0 { - now + Duration::from_secs(u32::MAX as u64) - } else { - now + Duration::from_millis((secs * 1000.0) as u64) - }; - let window = keyed.window(); - let mut states = keyed.states.lock().unwrap(); - let state = states.entry(key).or_insert_with(|| KeyState { - consumed: 0, - window_end: now + window, - blocked_until: None, - }); - state.blocked_until = Some(until); - let consumed = state.consumed as f64; - drop(states); - - let res = ratelimiter_res(0.0, secs * 1000.0, consumed, false); - promise.resolve(res); - raw -} - -/// `limiter.penalty(key, points = 1)` — add consumed points without a -/// quota check. Resolves a `RateLimiterRes`. -/// -/// # Safety -/// `key_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_ratelimit_penalty( - handle: Handle, - key_ptr: *const StringHeader, - points: f64, -) -> *mut Promise { - let promise = JsPromise::new(); - let raw = promise.as_raw(); - let key = read_str(key_ptr).unwrap_or_else(|| "default".to_string()); - let n = points.max(1.0) as u32; - - let Some(keyed) = get_handle::(handle) else { - promise.resolve(JsValue::NULL); - return raw; - }; - - let now = Instant::now(); - let window = keyed.window(); - let mut states = keyed.states.lock().unwrap(); - let state = states.entry(key).or_insert_with(|| KeyState { - consumed: 0, - window_end: now + window, - blocked_until: None, - }); - if now >= state.window_end { - state.consumed = 0; - state.window_end = now + window; - } - state.consumed = state.consumed.saturating_add(n); - let consumed = state.consumed as f64; - let ms = state.window_end.duration_since(now).as_millis() as f64; - let remaining = (keyed.points as f64 - consumed).max(0.0); - drop(states); - - promise.resolve(ratelimiter_res(remaining, ms, consumed, false)); - raw -} - -/// `limiter.reward(key, points = 1)` — give consumed points back. -/// Resolves a `RateLimiterRes`. -/// -/// # Safety -/// `key_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_ratelimit_reward( - handle: Handle, - key_ptr: *const StringHeader, - points: f64, -) -> *mut Promise { - let promise = JsPromise::new(); - let raw = promise.as_raw(); - let key = read_str(key_ptr).unwrap_or_else(|| "default".to_string()); - let n = points.max(1.0) as u32; - - let Some(keyed) = get_handle::(handle) else { - promise.resolve(JsValue::NULL); - return raw; - }; - - let now = Instant::now(); - let window = keyed.window(); - let mut states = keyed.states.lock().unwrap(); - let state = states.entry(key).or_insert_with(|| KeyState { - consumed: 0, - window_end: now + window, - blocked_until: None, - }); - // Reset an expired window before reward math so a delayed reward() - // cannot underflow the (now stale) window_end in `duration_since`. - if now >= state.window_end { - state.consumed = 0; - state.window_end = now + window; - } - state.consumed = state.consumed.saturating_sub(n); - let consumed = state.consumed as f64; - let ms = state.window_end.duration_since(now).as_millis() as f64; - let remaining = (keyed.points as f64 - consumed).max(0.0); - drop(states); - - promise.resolve(ratelimiter_res(remaining, ms, consumed, false)); - raw -} - -/// Sync probe: would a consume of 1 point succeed? -/// -/// # Safety -/// `key_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_ratelimit_check(handle: Handle, key_ptr: *const StringHeader) -> bool { - let key = read_str(key_ptr).unwrap_or_else(|| "default".to_string()); - if let Some(keyed) = get_handle::(handle) { - let now = Instant::now(); - let states = keyed.states.lock().unwrap(); - if let Some(state) = states.get(&key) { - if state.blocked_until.is_some_and(|u| u > now) { - return false; - } - if now < state.window_end { - return state.consumed < keyed.points; - } - } - return true; - } else if let Some(simple) = get_handle::(handle) { - return simple.limiter.check().is_ok(); - } - true -} - -/// Sync probe: remaining points for a key. -/// -/// # Safety -/// `key_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_ratelimit_remaining( - handle: Handle, - key_ptr: *const StringHeader, -) -> f64 { - let key = read_str(key_ptr).unwrap_or_else(|| "default".to_string()); - if let Some(keyed) = get_handle::(handle) { - let now = Instant::now(); - let states = keyed.states.lock().unwrap(); - if let Some(state) = states.get(&key) { - if state.blocked_until.is_some_and(|u| u > now) { - return 0.0; - } - if now < state.window_end { - return (keyed.points as f64 - state.consumed as f64).max(0.0); - } - } - return keyed.points as f64; - } else if let Some(simple) = get_handle::(handle) { - return simple.points as f64; - } - 0.0 -} - -#[no_mangle] -pub extern "C" fn js_ratelimit_reset(handle: Handle) { - if let Some(keyed) = get_handle::(handle) { - keyed.states.lock().unwrap().clear(); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn new_returns_handle() { - let h = js_ratelimit_new(10.0, 60.0); - assert!(h >= 0); - } - - #[test] - fn check_passes_when_under_quota() { - let h = js_ratelimit_new(10.0, 60.0); - let key = alloc_string("user-a"); - assert!(unsafe { js_ratelimit_check(h, key.as_raw()) }); - } - - #[test] - fn keyed_state_counts_down_and_blocks() { - let h = js_ratelimit_new_keyed(2.0, 60.0); - let keyed = get_handle::(h).unwrap(); - let now = Instant::now(); - { - let mut states = keyed.states.lock().unwrap(); - states.insert( - "k".into(), - KeyState { - consumed: 2, - window_end: now + Duration::from_secs(60), - blocked_until: None, - }, - ); - } - let key = alloc_string("k"); - // Quota exhausted: sync probe must fail… - assert!(!unsafe { js_ratelimit_check(h, key.as_raw()) }); - // …and remaining must be 0 (the old governor-backed version - // reported the constant max here). - assert_eq!(unsafe { js_ratelimit_remaining(h, key.as_raw()) }, 0.0); - } - - #[test] - fn remaining_returns_max_for_unknown_key() { - let h = js_ratelimit_new_keyed(5.0, 60.0); - let key = alloc_string("nope"); - assert_eq!(unsafe { js_ratelimit_remaining(h, key.as_raw()) }, 5.0); - } - - #[test] - fn reset_clears_keyed_limiters() { - let h = js_ratelimit_new_keyed(3.0, 60.0); - js_ratelimit_reset(h); - let key = alloc_string("anything"); - assert_eq!(unsafe { js_ratelimit_remaining(h, key.as_raw()) }, 3.0); - } - - #[test] - fn invalid_handle_check_returns_true() { - // Per the perry-stdlib convention, invalid-handle returns a - // permissive `true` for check (no rate limit applies). - let key = alloc_string("x"); - assert!(unsafe { js_ratelimit_check(-1, key.as_raw()) }); - } -} diff --git a/crates/perry-hir/src/destructuring/var_decl/native_new.rs b/crates/perry-hir/src/destructuring/var_decl/native_new.rs index 145a34a2f6..b07b869180 100644 --- a/crates/perry-hir/src/destructuring/var_decl/native_new.rs +++ b/crates/perry-hir/src/destructuring/var_decl/native_new.rs @@ -291,14 +291,6 @@ pub(crate) fn register_native_from_new_and_calls( ("https", "createServer") => Some("HttpsServer"), ("tls", "createServer" | "Server") => Some("Server"), ("http2", "createSecureServer") => Some("Http2SecureServer"), - // node-cron's `cron.schedule(expr, cb)` returns a job - // handle whose `start()`/`stop()`/`isRunning()` methods - // dispatch via the ("node-cron", true, METHOD) entries - // in expr.rs's native_module dispatch table. Without - // registering the result as a "CronJob" native instance, - // `job.stop()` falls through to dynamic dispatch and the - // call never reaches js_cron_job_stop. - ("node-cron", "schedule") => Some("CronJob"), // readline.createInterface() returns a singleton // handle whose .question/.on/.close methods // dispatch via the ("readline", true, METHOD) @@ -508,15 +500,12 @@ pub(crate) fn register_native_from_new_and_calls( ("better-sqlite3", "prepare") => Some("Statement"), ("sqlite", "prepare") => Some("StatementSync"), ("sqlite", "createSession") => Some("Session"), - // dayjs / moment manipulation methods return a + // moment manipulation methods return a // NEW date handle — without re-registering the // binding, `const d2 = d.add(7, 'day'); // d2.format(...)` fell to generic dispatch // (undefined). "App" matches the factory-result // registration class. - ("dayjs", "add" | "subtract" | "startOf" | "endOf") => { - Some("App") - } ( "moment", "add" | "subtract" | "startOf" | "endOf" | "clone", diff --git a/crates/perry-hir/src/js_transform/local_natives.rs b/crates/perry-hir/src/js_transform/local_natives.rs index 0c2e42be83..97179dd477 100644 --- a/crates/perry-hir/src/js_transform/local_natives.rs +++ b/crates/perry-hir/src/js_transform/local_natives.rs @@ -665,8 +665,7 @@ pub fn chained_native_class(module: &str, prior_method: &str) -> Option<&'static ("mysql2", "getConnection") | ("mysql2/promise", "getConnection") => Some("PoolConnection"), ("pg", "connect") => Some("PoolClient"), ("ioredis", "duplicate") => Some("Redis"), - // dayjs / moment manipulation methods return a NEW date handle. - ("dayjs", "add" | "subtract" | "startOf" | "endOf") => Some("App"), + // moment manipulation methods return a NEW date handle. ("moment", "add" | "subtract" | "startOf" | "endOf" | "clone") => Some("App"), _ => None, } @@ -1351,7 +1350,6 @@ pub fn detect_native_instance_creation_with_context( ("http", "createServer") => "HttpServer", ("https", "createServer") => "HttpsServer", ("http2", "createSecureServer") => "Http2SecureServer", - ("node-cron", "schedule") => "CronJob", ("readline", "createInterface") => "Interface", ("bun", "Transpiler") => "Transpiler", // Issue #1193: `const $ = load(html)` / `loadFragment(html)` diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index 0a5f50f6b6..1353deceb0 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -833,16 +833,6 @@ pub(crate) fn lower_module_decl( Some("Socket") } ("dgram", "createSocket") => Some("Socket"), - // node-cron's `cron.schedule(expr, cb)` returns a job - // handle whose `start()`/`stop()`/`isRunning()` etc. - // dispatch via the ("node-cron", true, METHOD) entries - // in expr.rs's native_module dispatch table. Without - // registering the handle as a "CronJob" native instance, - // `job.stop()` falls through to dynamic dispatch and the - // stop never reaches js_cron_job_stop. - ("node-cron", "schedule") => { - Some("CronJob") - } // node:http server (issue #577) — `http.createServer(...)` // returns an HttpServer handle whose `.listen(...)` / // `.close()` / `.address()` / `.on(...)` dispatches via @@ -1161,14 +1151,9 @@ pub(crate) fn lower_module_decl( ("sqlite", "createSession") => { Some("Session") } - // dayjs / moment manipulation + // moment manipulation // methods return a NEW date // handle (see native_new.rs). - ( - "dayjs", - "add" | "subtract" | "startOf" - | "endOf", - ) => Some("App"), ( "moment", "add" | "subtract" | "startOf" diff --git a/crates/perry-runtime/src/closure/mod.rs b/crates/perry-runtime/src/closure/mod.rs index 93e20e8bb3..e3846ffa55 100644 --- a/crates/perry-runtime/src/closure/mod.rs +++ b/crates/perry-runtime/src/closure/mod.rs @@ -105,8 +105,8 @@ pub use v8_stubs::{ pub use v8_stubs::{ js_argon2_hash_options, js_lodash_ends_with, js_lodash_escape, js_lodash_includes, js_lodash_lower_first, js_lodash_replace, js_lodash_split, js_lodash_start_case, - js_lodash_starts_with, js_lodash_unescape, js_lodash_upper_first, js_ratelimit_create, - js_sharp_negate, js_sharp_quality, js_sharp_to_format, + js_lodash_starts_with, js_lodash_unescape, js_lodash_upper_first, js_sharp_negate, + js_sharp_quality, js_sharp_to_format, }; #[cfg(test)] diff --git a/crates/perry-runtime/src/closure/v8_stubs.rs b/crates/perry-runtime/src/closure/v8_stubs.rs index b5d1fb27a8..85dec57852 100644 --- a/crates/perry-runtime/src/closure/v8_stubs.rs +++ b/crates/perry-runtime/src/closure/v8_stubs.rs @@ -243,10 +243,6 @@ pub extern "C" fn js_await_js_promise(_promise: f64) -> f64 { // AOT stubs for unconditionally-declared extern functions // ============================================================================= -#[no_mangle] -pub extern "C" fn js_ratelimit_create() -> i64 { - 0 -} #[no_mangle] pub extern "C" fn js_lodash_ends_with() -> f64 { 0.0 diff --git a/crates/perry-stdlib/Cargo.toml b/crates/perry-stdlib/Cargo.toml index bbf85a49a5..3e8f848307 100644 --- a/crates/perry-stdlib/Cargo.toml +++ b/crates/perry-stdlib/Cargo.toml @@ -23,7 +23,7 @@ default = ["full"] # must stay out of this list: release archives enable `full` without linking # their per-program provider archives, and adding an external HTTP pump here # made HTTP-free Linux UI links require libperry_ext_http.a (#5983, #8587). -full = ["http-server", "http-client", "database", "crypto", "compression", "email", "websocket", "image", "scheduler", "ids", "html-parser", "rate-limit", "net", "tls", "bundled-lru-cache", "bundled-exponential-backoff", "bundled-events", "bundled-decimal", "bundled-dayjs", "bundled-moment", "bundled-commander", "bundled-streams"] +full = ["http-server", "http-client", "database", "crypto", "compression", "email", "websocket", "image", "scheduler", "ids", "html-parser", "net", "tls", "bundled-lru-cache", "bundled-exponential-backoff", "bundled-events", "bundled-decimal", "bundled-moment", "bundled-commander", "bundled-streams"] # Minimal core - just what's needed for basic programs core = [] @@ -44,12 +44,6 @@ bundled-events = [] # through `full`. bundled-decimal = ["dep:rust_decimal"] -# dayjs / date-fns — chrono is always-on (used by lots of stdlib), -# so this gate just toggles whether perry-stdlib's dayjs module is -# compiled in. Default-on through `full`; the well-known flip -# strips it when `import 'dayjs'` resolves to perry-ext-dayjs. -bundled-dayjs = [] - # moment — same gating contract as bundled-dayjs (chrono is # always-on). Default-on through `full`; flipped to perry-ext-moment # by the well-known table. @@ -62,10 +56,9 @@ bundled-commander = [] # HTTP server (hyper-based native framework) # Note: dashmap is now always-on (used by core handle registry), no longer listed here. -# Gates the `framework` module (non-fastify hyper plumbing) only. fastify is no -# longer in-stdlib — `import 'fastify'` is served exclusively by the external -# perry-ext-fastify crate via the well-known flip (see well_known_bindings.toml); -# perry-stdlib's per-tick bridge into it lives behind `external-fastify-pump`. +# Gates the `framework` module (unrelated hyper plumbing) only. The npm +# `fastify` binding was removed (#466); `import 'fastify'` now compiles the +# real npm package from source. http-server = ["dep:hyper", "dep:hyper-util", "dep:http-body-util", "dep:bytes", "async-runtime"] # Web Fetch compatibility surface. The well-known flip can strip this @@ -140,12 +133,6 @@ external-http-server-pump = ["async-runtime"] # and the shared async runtime; perry-ext-http registers its own callbacks. external-http-client-pump = ["async-runtime"] -# Activated by `optimized_libs::build_optimized_libs` whenever the well-known -# flip routes `import 'fastify'` to perry-ext-fastify (the only fastify path — -# the in-stdlib adapter was removed). Selects Fastify dispatch adapters and the -# shared async runtime. perry-ext-fastify owns pump/keepalive registration. -external-fastify-pump = ["async-runtime"] - # Activated by `optimized_libs::build_optimized_libs` when the well-known # flip routes `node:events` to perry-ext-events. Makes # `js_stdlib_init_dispatch` register perry-ext-events' @@ -276,11 +263,6 @@ bundled-cheerio = ["dep:scraper"] scheduler = ["bundled-cron"] bundled-cron = ["dep:cron", "async-runtime"] -# Rate limiting — `rate-limit` umbrella stays for backwards-compat; -# v0.5.552's well-known flip toggles `bundled-ratelimit` instead. -rate-limit = ["bundled-ratelimit"] -bundled-ratelimit = ["dep:governor", "async-runtime"] - # `ids` is now an empty umbrella kept only for backwards compat: # both members are gone — `bundled-uuid` with the uuid binding @@ -423,7 +405,6 @@ scraper = { version = "0.27", optional = true } cron = { version = "0.17", optional = true } # Rate limiting -governor = { version = "0.10", optional = true } # Validation diff --git a/crates/perry-stdlib/src/common/async_bridge.rs b/crates/perry-stdlib/src/common/async_bridge.rs index 329369cb22..539102df0f 100644 --- a/crates/perry-stdlib/src/common/async_bridge.rs +++ b/crates/perry-stdlib/src/common/async_bridge.rs @@ -933,8 +933,6 @@ mod tests { "js_ext_http_client_inflight", "js_ext_zlib_process_pending", "js_ext_zlib_has_active_handles", - "js_fastify_process_pending", - "js_fastify_has_active", ]; for symbol in extension_symbols { diff --git a/crates/perry-stdlib/src/common/dispatch/init.rs b/crates/perry-stdlib/src/common/dispatch/init.rs index 1a467dea6d..0c1da4249a 100644 --- a/crates/perry-stdlib/src/common/dispatch/init.rs +++ b/crates/perry-stdlib/src/common/dispatch/init.rs @@ -31,27 +31,6 @@ pub unsafe extern "C" fn js_handle_property_set_dispatch( return; } - // External-fastify `request.user = …` setter. fastify is served by - // perry-ext-fastify (the bundled in-stdlib adapter was removed); the handle - // lives in its perry-ffi registry, so probe membership via the external symbol - // and forward to its setter — the write counterpart of the - // `js_fastify_req_get_user_data` read arm in `js_handle_property_dispatch`, so - // a later `request.user` read sees the value rather than missing the generic - // expando store. Statically-typed inline sets lower via codegen's - // NATIVE_MODULE_TABLE; this covers the erased-receiver dynamic case. - #[cfg(feature = "external-fastify-pump")] - if property_name == "user" { - extern "C" { - fn js_ext_fastify_is_context_handle(handle: i64) -> i32; - fn js_fastify_req_set_user_data(handle: i64, value: f64); - } - if unsafe { js_ext_fastify_is_context_handle(handle) } != 0 { - unsafe { js_fastify_req_set_user_data(handle, value) }; - // Claimed by the typed setter — don't also write a stale expando copy. - return; - } - } - #[cfg(feature = "external-http-server-pump")] if matches!( property_name, diff --git a/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs b/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs index 5fbf08c79f..a44d648a42 100644 --- a/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs +++ b/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs @@ -13,9 +13,6 @@ pub unsafe extern "C" fn js_handle_property_dispatch( property_name_ptr: *const u8, property_name_len: usize, ) -> f64 { - #[cfg(feature = "external-fastify-pump")] - use perry_runtime::JSValue; - let property_name = if property_name_ptr.is_null() || property_name_len == 0 { "" } else { @@ -497,70 +494,6 @@ pub unsafe extern "C" fn js_handle_property_dispatch( } } - // Fastify request/reply property dispatch (#5037, #1113). fastify is served - // exclusively by the external perry-ext-fastify crate (the bundled in-stdlib - // adapter was removed). A `request`/`reply` handle that escaped into a user - // helper has its static type erased, so codegen emits a generic dynamic - // property read here rather than a `NativeMethodCall`. The handle lives in - // perry-ext-fastify's perry-ffi registry, so probe membership via the external - // `js_ext_fastify_is_context_handle` symbol (resolved at link time) and forward - // to perry-ext-fastify's `js_fastify_req_*` exports. Enabled by the well-known - // flip's `external-fastify-pump`, mirroring the `async_bridge.rs` pump. - // (`app.server` and statically-typed inline reads dispatch via codegen's static - // NATIVE_MODULE_TABLE; only this erased-receiver dynamic path needs an arm.) - #[cfg(feature = "external-fastify-pump")] - { - extern "C" { - fn js_ext_fastify_is_context_handle(handle: i64) -> i32; - fn js_fastify_req_query_object(handle: i64) -> f64; - fn js_fastify_req_params_object(handle: i64) -> f64; - fn js_fastify_req_json(handle: i64) -> f64; - fn js_fastify_req_body(handle: i64) -> *mut perry_runtime::StringHeader; - fn js_fastify_req_headers(handle: i64) -> i64; - fn js_fastify_req_method(handle: i64) -> *mut perry_runtime::StringHeader; - fn js_fastify_req_url(handle: i64) -> *mut perry_runtime::StringHeader; - fn js_fastify_req_get_user_data(handle: i64) -> f64; - } - if js_ext_fastify_is_context_handle(handle) != 0 { - return match property_name { - "query" => js_fastify_req_query_object(handle), - "params" => js_fastify_req_params_object(handle), - "body" => js_fastify_req_json(handle), - "rawBody" | "text" => { - let ptr = js_fastify_req_body(handle); - if ptr.is_null() { - f64::from_bits(0x7FFC_0000_0000_0001) - } else { - f64::from_bits(perry_runtime::JSValue::string_ptr(ptr).bits()) - } - } - "headers" => { - // Returns NaN-boxed JS object bits — use directly. - let bits = js_fastify_req_headers(handle); - f64::from_bits(bits as u64) - } - "method" => { - let ptr = js_fastify_req_method(handle); - if ptr.is_null() { - f64::from_bits(0x7FFC_0000_0000_0001) - } else { - f64::from_bits(perry_runtime::JSValue::string_ptr(ptr).bits()) - } - } - "url" => { - let ptr = js_fastify_req_url(handle); - if ptr.is_null() { - f64::from_bits(0x7FFC_0000_0000_0001) - } else { - f64::from_bits(perry_runtime::JSValue::string_ptr(ptr).bits()) - } - } - "user" => js_fastify_req_get_user_data(handle), - _ => f64::from_bits(0x7FFC_0000_0000_0001), // undefined - }; - } - } - #[cfg(feature = "external-http-client-pump")] if let Some(value) = unsafe { super::super::dispatch_http::dispatch_client_request_property(handle, property_name) diff --git a/crates/perry-stdlib/src/dayjs.rs b/crates/perry-stdlib/src/dayjs.rs deleted file mode 100644 index cca17651e7..0000000000 --- a/crates/perry-stdlib/src/dayjs.rs +++ /dev/null @@ -1,1017 +0,0 @@ -//! Dayjs/date-fns module -//! -//! Native implementation of dayjs and date-fns using chrono. -//! Provides date parsing, formatting, manipulation, and comparison. - -use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveDateTime, TimeZone, Timelike, Utc}; -use perry_runtime::{js_string_from_bytes, StringHeader}; - -use crate::common::{register_handle, string_from_header, Handle}; - -/// Wrapper around DateTime for handle storage -pub struct DayjsHandle { - pub datetime: DateTime, -} - -impl DayjsHandle { - pub fn new(dt: DateTime) -> Self { - Self { datetime: dt } - } -} - -/// Convert Handle to f64 for return -#[inline] -fn handle_to_f64(handle: Handle) -> f64 { - f64::from_bits(handle as u64) -} - -/// Convert f64 to Handle -#[inline] -fn f64_to_handle(val: f64) -> Handle { - val.to_bits() as i64 -} - -/// dayjs() -> Dayjs -/// -/// Create a dayjs object for the current time. -#[no_mangle] -pub extern "C" fn js_dayjs_now() -> f64 { - let handle = register_handle(DayjsHandle::new(Utc::now())); - handle_to_f64(handle) -} - -/// dayjs(timestamp) -> Dayjs -/// -/// Create a dayjs object from a Unix timestamp (milliseconds). -#[no_mangle] -pub extern "C" fn js_dayjs_from_timestamp(timestamp: f64) -> f64 { - // NaN / ±∞ → Invalid Date (dayjs's invalid sentinel is `0.0`). - if !timestamp.is_finite() { - return 0.0; // Invalid timestamp - } - - // Millisecond-safe conversion: `from_timestamp_millis` handles - // negative epochs correctly (the old secs/nanos split truncated - // negative fractions toward zero and saturated `nanos` to 0). - if let Some(dt) = DateTime::from_timestamp_millis(timestamp as i64) { - let handle = register_handle(DayjsHandle::new(dt)); - handle_to_f64(handle) - } else { - 0.0 // Invalid timestamp - } -} - -/// dayjs(dateString) -> Dayjs -/// -/// Parse a date string (ISO 8601 format). -#[no_mangle] -pub unsafe extern "C" fn js_dayjs_parse(date_str_ptr: *const StringHeader) -> f64 { - let date_str = match string_from_header(date_str_ptr) { - Some(s) => s, - None => return 0.0, - }; - - // Try to parse as ISO 8601 - if let Ok(dt) = DateTime::parse_from_rfc3339(&date_str) { - let handle = register_handle(DayjsHandle::new(dt.with_timezone(&Utc))); - return handle_to_f64(handle); - } - - // Try to parse as YYYY-MM-DD - if let Ok(naive) = NaiveDate::parse_from_str(&date_str, "%Y-%m-%d") { - let datetime = naive.and_hms_opt(0, 0, 0).unwrap(); - let dt = Utc.from_utc_datetime(&datetime); - let handle = register_handle(DayjsHandle::new(dt)); - return handle_to_f64(handle); - } - - // Try to parse as YYYY-MM-DD HH:MM:SS - if let Ok(naive) = NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%d %H:%M:%S") { - let dt = Utc.from_utc_datetime(&naive); - let handle = register_handle(DayjsHandle::new(dt)); - return handle_to_f64(handle); - } - - // Offset-less ISO forms ("2024-01-15T10:30:00", optional fraction) — - // dayjs treats these as local time; Perry's date runtime is UTC-based - // so they match Node under TZ=UTC. - for fmt in ["%Y-%m-%dT%H:%M:%S%.f", "%Y-%m-%d %H:%M:%S%.f"] { - if let Ok(naive) = NaiveDateTime::parse_from_str(&date_str, fmt) { - let dt = Utc.from_utc_datetime(&naive); - let handle = register_handle(DayjsHandle::new(dt)); - return handle_to_f64(handle); - } - } - - 0.0 // Invalid date string -} - -/// dayjs(input?) -> Dayjs -/// -/// The dayjs factory with its real argument surface: `value_bits` is the -/// raw NaN-boxed first argument (TAG_UNDEFINED when absent). undefined → -/// now; string → ISO parse; number → epoch milliseconds. The dispatch -/// row previously called `js_dayjs_now` with no args, silently ignoring -/// `dayjs('2024-01-15')`'s argument. -#[no_mangle] -pub unsafe extern "C" fn js_dayjs_factory(value_bits: i64) -> f64 { - use perry_runtime::JSValue; - let bits = value_bits as u64; - let jv = JSValue::from_bits(bits); - if jv.is_any_string() { - let ptr = perry_runtime::js_get_string_pointer_unified(f64::from_bits(bits)) - as *const StringHeader; - return js_dayjs_parse(ptr); - } - if jv.is_int32() { - return js_dayjs_from_timestamp(jv.as_int32() as f64); - } - if jv.is_number() { - let n = f64::from_bits(bits); - if n.is_finite() { - return js_dayjs_from_timestamp(n); - } - } - js_dayjs_now() -} - -/// dayjs.format(pattern) -> string -/// -/// Format a date according to the given pattern. -#[no_mangle] -pub unsafe extern "C" fn js_dayjs_format( - handle: Handle, - pattern_ptr: *const StringHeader, -) -> *mut StringHeader { - use crate::common::get_handle; - - let pattern = string_from_header(pattern_ptr).unwrap_or_else(|| "YYYY-MM-DD".to_string()); - - if let Some(wrapper) = get_handle::(handle) { - let dt = &wrapper.datetime; - - // Convert dayjs format to chrono format - let chrono_fmt = pattern - .replace("YYYY", "%Y") - .replace("YY", "%y") - .replace("MM", "%m") - .replace("DD", "%d") - .replace("HH", "%H") - .replace("hh", "%I") - .replace("mm", "%M") - .replace("ss", "%S") - .replace("SSS", "%3f") - .replace("A", "%p") - .replace("a", "%P") - .replace("dddd", "%A") - .replace("ddd", "%a") - .replace("MMMM", "%B") - .replace("MMM", "%b") - .replace("ZZ", "%z") - .replace("Z", "%:z"); - - let formatted = dt.format(&chrono_fmt).to_string(); - js_string_from_bytes(formatted.as_ptr(), formatted.len() as u32) - } else { - std::ptr::null_mut() - } -} - -/// dayjs.toISOString() -> string -#[no_mangle] -pub unsafe extern "C" fn js_dayjs_to_iso_string(handle: Handle) -> *mut StringHeader { - use crate::common::get_handle; - - if let Some(wrapper) = get_handle::(handle) { - let iso = wrapper.datetime.to_rfc3339(); - js_string_from_bytes(iso.as_ptr(), iso.len() as u32) - } else { - std::ptr::null_mut() - } -} - -/// dayjs.valueOf() -> number (Unix timestamp in milliseconds) -#[no_mangle] -pub unsafe extern "C" fn js_dayjs_value_of(handle: Handle) -> f64 { - use crate::common::get_handle; - - if let Some(wrapper) = get_handle::(handle) { - wrapper.datetime.timestamp_millis() as f64 - } else { - 0.0 - } -} - -/// dayjs.unix() -> number (Unix timestamp in seconds) -#[no_mangle] -pub unsafe extern "C" fn js_dayjs_unix(handle: Handle) -> f64 { - use crate::common::get_handle; - - if let Some(wrapper) = get_handle::(handle) { - wrapper.datetime.timestamp() as f64 - } else { - 0.0 - } -} - -/// dayjs.year() -> number -#[no_mangle] -pub unsafe extern "C" fn js_dayjs_year(handle: Handle) -> f64 { - use crate::common::get_handle; - - if let Some(wrapper) = get_handle::(handle) { - wrapper.datetime.year() as f64 - } else { - 0.0 - } -} - -/// dayjs.month() -> number (0-11) -#[no_mangle] -pub unsafe extern "C" fn js_dayjs_month(handle: Handle) -> f64 { - use crate::common::get_handle; - - if let Some(wrapper) = get_handle::(handle) { - (wrapper.datetime.month() - 1) as f64 // 0-indexed like JS - } else { - 0.0 - } -} - -/// dayjs.date() -> number (1-31) -#[no_mangle] -pub unsafe extern "C" fn js_dayjs_date(handle: Handle) -> f64 { - use crate::common::get_handle; - - if let Some(wrapper) = get_handle::(handle) { - wrapper.datetime.day() as f64 - } else { - 0.0 - } -} - -/// dayjs.day() -> number (0-6, Sunday=0) -#[no_mangle] -pub unsafe extern "C" fn js_dayjs_day(handle: Handle) -> f64 { - use crate::common::get_handle; - - if let Some(wrapper) = get_handle::(handle) { - wrapper.datetime.weekday().num_days_from_sunday() as f64 - } else { - 0.0 - } -} - -/// dayjs.hour() -> number (0-23) -#[no_mangle] -pub unsafe extern "C" fn js_dayjs_hour(handle: Handle) -> f64 { - use crate::common::get_handle; - - if let Some(wrapper) = get_handle::(handle) { - wrapper.datetime.hour() as f64 - } else { - 0.0 - } -} - -/// dayjs.minute() -> number (0-59) -#[no_mangle] -pub unsafe extern "C" fn js_dayjs_minute(handle: Handle) -> f64 { - use crate::common::get_handle; - - if let Some(wrapper) = get_handle::(handle) { - wrapper.datetime.minute() as f64 - } else { - 0.0 - } -} - -/// dayjs.second() -> number (0-59) -#[no_mangle] -pub unsafe extern "C" fn js_dayjs_second(handle: Handle) -> f64 { - use crate::common::get_handle; - - if let Some(wrapper) = get_handle::(handle) { - wrapper.datetime.second() as f64 - } else { - 0.0 - } -} - -/// dayjs.millisecond() -> number (0-999) -#[no_mangle] -pub unsafe extern "C" fn js_dayjs_millisecond(handle: Handle) -> f64 { - use crate::common::get_handle; - - if let Some(wrapper) = get_handle::(handle) { - (wrapper.datetime.nanosecond() / 1_000_000) as f64 - } else { - 0.0 - } -} - -/// dayjs.add(value, unit) -> Dayjs -/// -/// Add time to a date. Unit can be: 'day', 'week', 'month', 'year', 'hour', 'minute', 'second' -#[no_mangle] -pub unsafe extern "C" fn js_dayjs_add( - handle: Handle, - value: f64, - unit_ptr: *const StringHeader, -) -> f64 { - use crate::common::get_handle; - - let unit = string_from_header(unit_ptr).unwrap_or_else(|| "day".to_string()); - let value = value as i64; - - if let Some(wrapper) = get_handle::(handle) { - let dt = &wrapper.datetime; - - let new_dt = match unit.as_str() { - "day" | "days" | "d" => *dt + Duration::days(value), - "week" | "weeks" | "w" => *dt + Duration::weeks(value), - "month" | "months" | "M" => { - // Adding months is more complex - let year = dt.year(); - let month = dt.month() as i32 + value as i32; - let (new_year, new_month) = if month > 12 { - (year + (month - 1) / 12, ((month - 1) % 12) + 1) - } else if month < 1 { - (year + (month - 12) / 12, 12 + (month % 12)) - } else { - (year, month) - }; - dt.with_year(new_year) - .and_then(|d| d.with_month(new_month as u32)) - .unwrap_or(*dt) - } - "year" | "years" | "y" => dt.with_year(dt.year() + value as i32).unwrap_or(*dt), - "hour" | "hours" | "h" => *dt + Duration::hours(value), - "minute" | "minutes" | "m" => *dt + Duration::minutes(value), - "second" | "seconds" | "s" => *dt + Duration::seconds(value), - "millisecond" | "milliseconds" | "ms" => *dt + Duration::milliseconds(value), - _ => *dt, - }; - - let new_handle = register_handle(DayjsHandle::new(new_dt)); - handle_to_f64(new_handle) - } else { - 0.0 - } -} - -/// dayjs.subtract(value, unit) -> Dayjs -#[no_mangle] -pub unsafe extern "C" fn js_dayjs_subtract( - handle: Handle, - value: f64, - unit_ptr: *const StringHeader, -) -> f64 { - js_dayjs_add(handle, -value, unit_ptr) -} - -/// dayjs.startOf(unit) -> Dayjs -/// -/// Set to the start of a unit of time. -#[no_mangle] -pub unsafe extern "C" fn js_dayjs_start_of(handle: Handle, unit_ptr: *const StringHeader) -> f64 { - use crate::common::get_handle; - - let unit = string_from_header(unit_ptr).unwrap_or_else(|| "day".to_string()); - - if let Some(wrapper) = get_handle::(handle) { - let dt = &wrapper.datetime; - - let new_dt = match unit.as_str() { - "year" | "years" | "y" => Utc.with_ymd_and_hms(dt.year(), 1, 1, 0, 0, 0).unwrap(), - "month" | "months" | "M" => Utc - .with_ymd_and_hms(dt.year(), dt.month(), 1, 0, 0, 0) - .unwrap(), - "day" | "days" | "d" => Utc - .with_ymd_and_hms(dt.year(), dt.month(), dt.day(), 0, 0, 0) - .unwrap(), - "hour" | "hours" | "h" => Utc - .with_ymd_and_hms(dt.year(), dt.month(), dt.day(), dt.hour(), 0, 0) - .unwrap(), - "minute" | "minutes" | "m" => Utc - .with_ymd_and_hms(dt.year(), dt.month(), dt.day(), dt.hour(), dt.minute(), 0) - .unwrap(), - _ => *dt, - }; - - let new_handle = register_handle(DayjsHandle::new(new_dt)); - handle_to_f64(new_handle) - } else { - 0.0 - } -} - -/// dayjs.endOf(unit) -> Dayjs -/// -/// Set to the end of a unit of time. -#[no_mangle] -pub unsafe extern "C" fn js_dayjs_end_of(handle: Handle, unit_ptr: *const StringHeader) -> f64 { - use crate::common::get_handle; - - let unit = string_from_header(unit_ptr).unwrap_or_else(|| "day".to_string()); - - if let Some(wrapper) = get_handle::(handle) { - let dt = &wrapper.datetime; - - let new_dt = match unit.as_str() { - "year" | "years" | "y" => Utc.with_ymd_and_hms(dt.year(), 12, 31, 23, 59, 59).unwrap(), - "month" | "months" | "M" => { - let last_day = NaiveDate::from_ymd_opt(dt.year(), dt.month() + 1, 1) - .unwrap_or_else(|| NaiveDate::from_ymd_opt(dt.year() + 1, 1, 1).unwrap()) - .pred_opt() - .unwrap() - .day(); - Utc.with_ymd_and_hms(dt.year(), dt.month(), last_day, 23, 59, 59) - .unwrap() - } - "day" | "days" | "d" => Utc - .with_ymd_and_hms(dt.year(), dt.month(), dt.day(), 23, 59, 59) - .unwrap(), - "hour" | "hours" | "h" => Utc - .with_ymd_and_hms(dt.year(), dt.month(), dt.day(), dt.hour(), 59, 59) - .unwrap(), - "minute" | "minutes" | "m" => Utc - .with_ymd_and_hms(dt.year(), dt.month(), dt.day(), dt.hour(), dt.minute(), 59) - .unwrap(), - _ => *dt, - }; - - let new_handle = register_handle(DayjsHandle::new(new_dt)); - handle_to_f64(new_handle) - } else { - 0.0 - } -} - -/// dayjs.diff(other, unit) -> number -/// -/// Get the difference between two dates. -#[no_mangle] -pub unsafe extern "C" fn js_dayjs_diff( - handle: Handle, - other_handle: Handle, - unit_ptr: *const StringHeader, -) -> f64 { - use crate::common::get_handle; - - let unit = string_from_header(unit_ptr).unwrap_or_else(|| "millisecond".to_string()); - - let wrapper1 = get_handle::(handle); - let wrapper2 = get_handle::(other_handle); - - if let (Some(w1), Some(w2)) = (wrapper1, wrapper2) { - let diff = w1.datetime.signed_duration_since(w2.datetime); - - match unit.as_str() { - "year" | "years" | "y" => (diff.num_days() / 365) as f64, - "month" | "months" | "M" => (diff.num_days() / 30) as f64, - "week" | "weeks" | "w" => diff.num_weeks() as f64, - "day" | "days" | "d" => diff.num_days() as f64, - "hour" | "hours" | "h" => diff.num_hours() as f64, - "minute" | "minutes" | "m" => diff.num_minutes() as f64, - "second" | "seconds" | "s" => diff.num_seconds() as f64, - "millisecond" | "milliseconds" | "ms" | _ => diff.num_milliseconds() as f64, - } - } else { - 0.0 - } -} - -/// dayjs.isBefore(other) -> boolean -#[no_mangle] -pub unsafe extern "C" fn js_dayjs_is_before(handle: Handle, other_handle: Handle) -> f64 { - use crate::common::get_handle; - - let wrapper1 = get_handle::(handle); - let wrapper2 = get_handle::(other_handle); - - if let (Some(w1), Some(w2)) = (wrapper1, wrapper2) { - if w1.datetime < w2.datetime { - 1.0 - } else { - 0.0 - } - } else { - 0.0 - } -} - -/// dayjs.isAfter(other) -> boolean -#[no_mangle] -pub unsafe extern "C" fn js_dayjs_is_after(handle: Handle, other_handle: Handle) -> f64 { - use crate::common::get_handle; - - let wrapper1 = get_handle::(handle); - let wrapper2 = get_handle::(other_handle); - - if let (Some(w1), Some(w2)) = (wrapper1, wrapper2) { - if w1.datetime > w2.datetime { - 1.0 - } else { - 0.0 - } - } else { - 0.0 - } -} - -/// dayjs.isSame(other) -> boolean -#[no_mangle] -pub unsafe extern "C" fn js_dayjs_is_same(handle: Handle, other_handle: Handle) -> f64 { - use crate::common::get_handle; - - let wrapper1 = get_handle::(handle); - let wrapper2 = get_handle::(other_handle); - - if let (Some(w1), Some(w2)) = (wrapper1, wrapper2) { - if w1.datetime == w2.datetime { - 1.0 - } else { - 0.0 - } - } else { - 0.0 - } -} - -/// dayjs.isValid() -> boolean -#[no_mangle] -pub unsafe extern "C" fn js_dayjs_is_valid(handle: Handle) -> f64 { - use crate::common::get_handle; - - if get_handle::(handle).is_some() { - 1.0 - } else { - 0.0 - } -} - -// ============ date-fns compatible functions ============ - -/// format(date, formatStr) -> string (date-fns compatible) -/// -/// date-fns uses Unicode-LDML-ish tokens (lowercase `yyyy` for year, -/// `dd` for day-of-month, `MM` for month, etc.) and formats in the -/// **local** timezone — distinct from dayjs which uses uppercase -/// `YYYY`/`DD` and is happy to format in UTC. We can't reuse -/// `js_dayjs_format` for this because (a) the token replacements miss, -/// and (b) local-vs-UTC swings the day across a midnight boundary. -/// Refs date-fns blocker. -#[no_mangle] -pub unsafe extern "C" fn js_datefns_format( - timestamp: f64, - pattern_ptr: *const StringHeader, -) -> *mut StringHeader { - use chrono::Local; - if timestamp.is_nan() { - return std::ptr::null_mut(); - } - let pattern = string_from_header(pattern_ptr).unwrap_or_else(|| "yyyy-MM-dd".to_string()); - // `new Date(year, monthIndex, ...)` stores a UTC ms timestamp whose - // wall-clock representation in local time is the literal components - // the user passed. So formatting it in `Local` reproduces those - // components — which is exactly what Node's date-fns does. - let secs = (timestamp / 1000.0) as i64; - let nanos = ((timestamp.rem_euclid(1000.0)) * 1_000_000.0) as u32; - let dt = match chrono::DateTime::from_timestamp(secs, nanos) { - Some(d) => d.with_timezone(&Local), - None => return std::ptr::null_mut(), - }; - let formatted = format_date_fns_pattern(&pattern, &dt); - js_string_from_bytes(formatted.as_ptr(), formatted.len() as u32) -} - -/// Translate a date-fns format pattern against a chrono datetime. -/// -/// Handles the most common Unicode-LDML tokens that date-fns supports. -/// Single-quoted literals (`'literal'`) pass through untouched. Tokens -/// not recognized below pass through unchanged — matching date-fns' -/// permissive behavior on unknown letters. -fn format_date_fns_pattern(pattern: &str, dt: &chrono::DateTime) -> String -where - Tz::Offset: std::fmt::Display, -{ - use chrono::{Datelike, Timelike}; - let bytes = pattern.as_bytes(); - let mut out = String::with_capacity(pattern.len() + 8); - let mut i = 0; - while i < bytes.len() { - let c = bytes[i]; - // Single-quoted literal — anything between `'...'` is emitted - // verbatim. `''` is a single quote. - if c == b'\'' { - i += 1; - if i < bytes.len() && bytes[i] == b'\'' { - out.push('\''); - i += 1; - continue; - } - while i < bytes.len() && bytes[i] != b'\'' { - out.push(bytes[i] as char); - i += 1; - } - if i < bytes.len() { - i += 1; - } - continue; - } - if !c.is_ascii_alphabetic() { - out.push(c as char); - i += 1; - continue; - } - // Read a run of the same letter. - let start = i; - while i < bytes.len() && bytes[i] == c { - i += 1; - } - let run = i - start; - // date-fns ordinal suffix: a single-letter token immediately - // followed by literal `o` formats the corresponding field as an - // English ordinal (`do` → "6th", `Mo` → "1st", `yo` → "2020th", - // `Do` → day-of-year ordinal, `Qo`/`qo` → quarter ordinal). The - // letters that participate are exactly those listed in the - // date-fns tokenizer character class: - // `[yYQqMLwIdDecihHKkms]o`. We only honour the suffix when the - // run length is 1, mirroring date-fns's own behavior. - let ordinal = run == 1 - && i < bytes.len() - && bytes[i] == b'o' - && matches!( - c, - b'y' | b'Y' - | b'Q' - | b'q' - | b'M' - | b'L' - | b'w' - | b'I' - | b'd' - | b'D' - | b'e' - | b'c' - | b'i' - | b'h' - | b'H' - | b'K' - | b'k' - | b'm' - | b's' - ); - if ordinal { - // Consume the trailing `o`. - i += 1; - let n: i64 = match c { - b'y' | b'Y' => dt.year() as i64, - b'Q' | b'q' => (((dt.month() - 1) / 3) + 1) as i64, - b'M' | b'L' => dt.month() as i64, - b'w' | b'I' => dt.iso_week().week() as i64, - b'd' => dt.day() as i64, - b'D' => dt.ordinal() as i64, - b'e' | b'c' | b'i' => dt.weekday().number_from_monday() as i64, - b'h' => { - let h12 = dt.hour() % 12; - if h12 == 0 { - 12 - } else { - h12 as i64 - } - } - b'H' => dt.hour() as i64, - b'K' => (dt.hour() % 12) as i64, - b'k' => { - let h = dt.hour(); - if h == 0 { - 24 - } else { - h as i64 - } - } - b'm' => dt.minute() as i64, - b's' => dt.second() as i64, - _ => 0, - }; - out.push_str(&english_ordinal(n)); - continue; - } - match c { - b'y' => match run { - 1 => out.push_str(&format!("{}", dt.year())), - 2 => out.push_str(&format!("{:02}", dt.year() % 100)), - _ => out.push_str(&format!("{:0width$}", dt.year(), width = run)), - }, - b'M' => match run { - 1 => out.push_str(&format!("{}", dt.month())), - 2 => out.push_str(&format!("{:02}", dt.month())), - 3 => out.push_str(short_month_name(dt.month())), - _ => out.push_str(long_month_name(dt.month())), - }, - b'd' => match run { - 1 => out.push_str(&format!("{}", dt.day())), - _ => out.push_str(&format!("{:02}", dt.day())), - }, - b'H' => match run { - 1 => out.push_str(&format!("{}", dt.hour())), - _ => out.push_str(&format!("{:02}", dt.hour())), - }, - b'h' => { - let h12 = dt.hour() % 12; - let h12 = if h12 == 0 { 12 } else { h12 }; - match run { - 1 => out.push_str(&format!("{}", h12)), - _ => out.push_str(&format!("{:02}", h12)), - } - } - b'm' => match run { - 1 => out.push_str(&format!("{}", dt.minute())), - _ => out.push_str(&format!("{:02}", dt.minute())), - }, - b's' => match run { - 1 => out.push_str(&format!("{}", dt.second())), - _ => out.push_str(&format!("{:02}", dt.second())), - }, - b'S' => { - // Fractional seconds. date-fns uses S/SS/SSS for 1/2/3 - // digits of millisecond precision. - let ms = dt.nanosecond() / 1_000_000; - match run { - 1 => out.push_str(&format!("{}", ms / 100)), - 2 => out.push_str(&format!("{:02}", ms / 10)), - _ => out.push_str(&format!("{:03}", ms)), - } - } - b'a' => { - // am/pm marker. date-fns: a/aa → "AM"/"PM", - // aaa → "am"/"pm", aaaa → "a.m."/"p.m.", aaaaa → "a"/"p". - let pm = dt.hour() >= 12; - let s = match run { - 3 => { - if pm { - "pm" - } else { - "am" - } - } - 4 => { - if pm { - "p.m." - } else { - "a.m." - } - } - 5 => { - if pm { - "p" - } else { - "a" - } - } - _ => { - if pm { - "PM" - } else { - "AM" - } - } - }; - out.push_str(s); - } - b'E' => { - // Day-of-week abbreviations. EEEE = long, EEE/EE/E = short. - let wd = dt.weekday(); - if run >= 4 { - out.push_str(long_weekday_name(wd)); - } else { - out.push_str(short_weekday_name(wd)); - } - } - b'X' | b'x' => { - // Time-zone offset. Approximate as ±HH:MM (date-fns has - // many variants; this covers the common case). - out.push_str(&format!("{}", dt.offset())); - } - _ => { - // Unknown letter run — emit verbatim (date-fns throws - // on truly unknown tokens, but conservatively passing - // through is closer to dayjs's behavior). - for _ in 0..run { - out.push(c as char); - } - } - } - } - out -} - -/// English ordinal suffix for a non-negative integer. -/// 1 → "1st", 2 → "2nd", 3 → "3rd", 11 → "11th", 21 → "21st", etc. -fn english_ordinal(n: i64) -> String { - let abs = n.unsigned_abs(); - let suffix = match (abs % 100, abs % 10) { - (11, _) | (12, _) | (13, _) => "th", - (_, 1) => "st", - (_, 2) => "nd", - (_, 3) => "rd", - _ => "th", - }; - format!("{}{}", n, suffix) -} - -fn short_month_name(m: u32) -> &'static str { - match m { - 1 => "Jan", - 2 => "Feb", - 3 => "Mar", - 4 => "Apr", - 5 => "May", - 6 => "Jun", - 7 => "Jul", - 8 => "Aug", - 9 => "Sep", - 10 => "Oct", - 11 => "Nov", - 12 => "Dec", - _ => "", - } -} - -fn long_month_name(m: u32) -> &'static str { - match m { - 1 => "January", - 2 => "February", - 3 => "March", - 4 => "April", - 5 => "May", - 6 => "June", - 7 => "July", - 8 => "August", - 9 => "September", - 10 => "October", - 11 => "November", - 12 => "December", - _ => "", - } -} - -fn short_weekday_name(w: chrono::Weekday) -> &'static str { - match w { - chrono::Weekday::Mon => "Mon", - chrono::Weekday::Tue => "Tue", - chrono::Weekday::Wed => "Wed", - chrono::Weekday::Thu => "Thu", - chrono::Weekday::Fri => "Fri", - chrono::Weekday::Sat => "Sat", - chrono::Weekday::Sun => "Sun", - } -} - -fn long_weekday_name(w: chrono::Weekday) -> &'static str { - match w { - chrono::Weekday::Mon => "Monday", - chrono::Weekday::Tue => "Tuesday", - chrono::Weekday::Wed => "Wednesday", - chrono::Weekday::Thu => "Thursday", - chrono::Weekday::Fri => "Friday", - chrono::Weekday::Sat => "Saturday", - chrono::Weekday::Sun => "Sunday", - } -} - -/// parseISO(dateString) -> timestamp (date-fns compatible) -#[no_mangle] -pub unsafe extern "C" fn js_datefns_parse_iso(date_str_ptr: *const StringHeader) -> f64 { - let handle_f64 = js_dayjs_parse(date_str_ptr); - if handle_f64 == 0.0 { - return f64::NAN; - } - js_dayjs_value_of(f64_to_handle(handle_f64)) -} - -/// addDays(date, amount) -> timestamp (date-fns compatible) -#[no_mangle] -pub unsafe extern "C" fn js_datefns_add_days(timestamp: f64, amount: f64) -> f64 { - let handle_f64 = js_dayjs_from_timestamp(timestamp); - if handle_f64 == 0.0 { - return f64::NAN; - } - let unit = "day"; - let unit_ptr = js_string_from_bytes(unit.as_ptr(), unit.len() as u32); - let new_handle_f64 = js_dayjs_add(f64_to_handle(handle_f64), amount, unit_ptr); - if new_handle_f64 == 0.0 { - return f64::NAN; - } - js_dayjs_value_of(f64_to_handle(new_handle_f64)) -} - -/// addMonths(date, amount) -> timestamp (date-fns compatible) -#[no_mangle] -pub unsafe extern "C" fn js_datefns_add_months(timestamp: f64, amount: f64) -> f64 { - let handle_f64 = js_dayjs_from_timestamp(timestamp); - if handle_f64 == 0.0 { - return f64::NAN; - } - let unit = "month"; - let unit_ptr = js_string_from_bytes(unit.as_ptr(), unit.len() as u32); - let new_handle_f64 = js_dayjs_add(f64_to_handle(handle_f64), amount, unit_ptr); - if new_handle_f64 == 0.0 { - return f64::NAN; - } - js_dayjs_value_of(f64_to_handle(new_handle_f64)) -} - -/// addYears(date, amount) -> timestamp (date-fns compatible) -#[no_mangle] -pub unsafe extern "C" fn js_datefns_add_years(timestamp: f64, amount: f64) -> f64 { - let handle_f64 = js_dayjs_from_timestamp(timestamp); - if handle_f64 == 0.0 { - return f64::NAN; - } - let unit = "year"; - let unit_ptr = js_string_from_bytes(unit.as_ptr(), unit.len() as u32); - let new_handle_f64 = js_dayjs_add(f64_to_handle(handle_f64), amount, unit_ptr); - if new_handle_f64 == 0.0 { - return f64::NAN; - } - js_dayjs_value_of(f64_to_handle(new_handle_f64)) -} - -/// differenceInDays(dateLeft, dateRight) -> number (date-fns compatible) -#[no_mangle] -pub extern "C" fn js_datefns_difference_in_days(timestamp_left: f64, timestamp_right: f64) -> f64 { - let diff_ms = timestamp_left - timestamp_right; - (diff_ms / (1000.0 * 60.0 * 60.0 * 24.0)).floor() -} - -/// differenceInHours(dateLeft, dateRight) -> number (date-fns compatible) -#[no_mangle] -pub extern "C" fn js_datefns_difference_in_hours(timestamp_left: f64, timestamp_right: f64) -> f64 { - let diff_ms = timestamp_left - timestamp_right; - (diff_ms / (1000.0 * 60.0 * 60.0)).floor() -} - -/// differenceInMinutes(dateLeft, dateRight) -> number (date-fns compatible) -#[no_mangle] -pub extern "C" fn js_datefns_difference_in_minutes( - timestamp_left: f64, - timestamp_right: f64, -) -> f64 { - let diff_ms = timestamp_left - timestamp_right; - (diff_ms / (1000.0 * 60.0)).floor() -} - -/// isAfter(date, dateToCompare) -> boolean (date-fns compatible) -#[no_mangle] -pub extern "C" fn js_datefns_is_after(timestamp: f64, compare_timestamp: f64) -> f64 { - if timestamp > compare_timestamp { - 1.0 - } else { - 0.0 - } -} - -/// isBefore(date, dateToCompare) -> boolean (date-fns compatible) -#[no_mangle] -pub extern "C" fn js_datefns_is_before(timestamp: f64, compare_timestamp: f64) -> f64 { - if timestamp < compare_timestamp { - 1.0 - } else { - 0.0 - } -} - -/// startOfDay(date) -> timestamp (date-fns compatible) -#[no_mangle] -pub unsafe extern "C" fn js_datefns_start_of_day(timestamp: f64) -> f64 { - let handle_f64 = js_dayjs_from_timestamp(timestamp); - if handle_f64 == 0.0 { - return f64::NAN; - } - let unit = "day"; - let unit_ptr = js_string_from_bytes(unit.as_ptr(), unit.len() as u32); - let new_handle_f64 = js_dayjs_start_of(f64_to_handle(handle_f64), unit_ptr); - if new_handle_f64 == 0.0 { - return f64::NAN; - } - js_dayjs_value_of(f64_to_handle(new_handle_f64)) -} - -/// endOfDay(date) -> timestamp (date-fns compatible) -#[no_mangle] -pub unsafe extern "C" fn js_datefns_end_of_day(timestamp: f64) -> f64 { - let handle_f64 = js_dayjs_from_timestamp(timestamp); - if handle_f64 == 0.0 { - return f64::NAN; - } - let unit = "day"; - let unit_ptr = js_string_from_bytes(unit.as_ptr(), unit.len() as u32); - let new_handle_f64 = js_dayjs_end_of(f64_to_handle(handle_f64), unit_ptr); - if new_handle_f64 == 0.0 { - return f64::NAN; - } - js_dayjs_value_of(f64_to_handle(new_handle_f64)) -} diff --git a/crates/perry-stdlib/src/lib.rs b/crates/perry-stdlib/src/lib.rs index 2c64ee8296..5a24c33764 100644 --- a/crates/perry-stdlib/src/lib.rs +++ b/crates/perry-stdlib/src/lib.rs @@ -37,11 +37,6 @@ pub mod async_local_storage; pub mod commander; pub mod common; pub mod domain; -// dayjs / date-fns — feature-gated as of v0.5.548 so the well-known -// flip can route `import 'dayjs'` / `import 'date-fns'` to -// perry-ext-dayjs without duplicate `_js_dayjs_*` symbols at link. -#[cfg(feature = "bundled-dayjs")] -pub mod dayjs; // decimal feature-gated as of v0.5.547 — well-known flip routes // to perry-ext-decimal. #[cfg(feature = "bundled-decimal")] @@ -94,8 +89,6 @@ pub use async_local_storage::*; #[cfg(feature = "bundled-commander")] pub use commander::*; pub use common::*; -#[cfg(feature = "bundled-dayjs")] -pub use dayjs::*; #[cfg(feature = "bundled-decimal")] pub use decimal::*; pub use domain::*; @@ -120,12 +113,10 @@ pub mod framework; #[cfg(feature = "http-server")] pub use framework::*; -// === Fastify-Compatible Framework === -// The in-stdlib fastify adapter was removed: `import 'fastify'` is served -// exclusively by the external `perry-ext-fastify` crate via the well-known -// flip (see `well_known_bindings.toml` + `optimized_libs.rs`). perry-stdlib's -// per-tick bridge into the external crate lives behind the -// `external-fastify-pump` feature (drained from `async_bridge`). +// === Fastify === +// The npm `fastify` binding was removed (#466): `import 'fastify'` now +// compiles the real npm package from source, same as any other package +// under the wildcard resolution. // === Web Fetch API (fetch / Headers / Request / Response / Blob) === // #5174: gated on `web-fetch`, not `http-client`, so Web Fetch stays @@ -392,10 +383,6 @@ pub extern "C" fn js_cron_timer_has_pending() -> i32 { } // === Rate Limiting === -#[cfg(feature = "bundled-ratelimit")] -pub mod ratelimit; -#[cfg(feature = "bundled-ratelimit")] -pub use ratelimit::*; // === IDs === // Nothing left to gate: `bundled-uuid` went with the uuid binding diff --git a/crates/perry-stdlib/src/ratelimit.rs b/crates/perry-stdlib/src/ratelimit.rs deleted file mode 100644 index f9e71d73e8..0000000000 --- a/crates/perry-stdlib/src/ratelimit.rs +++ /dev/null @@ -1,497 +0,0 @@ -//! Rate Limiter module (rate-limiter-flexible compatible) -//! -//! `RateLimiterMemory`-compatible fixed-window rate limiting. Semantics -//! mirror the npm package's in-memory limiter: `points` consumable per -//! `duration`-second fixed window (defaults 4 / 1s, like -//! `RateLimiterAbstract`). `consume` resolves a `RateLimiterRes`-shaped -//! object `{ remainingPoints, msBeforeNext, consumedPoints, -//! isFirstInDuration }` and **rejects with the same shape** when the -//! quota is exceeded. All state math is synchronous and runs on the -//! calling (main) thread, so result objects are built inline and -//! settled immediately. -//! -//! Kept in lock-step with `crates/perry-ext-ratelimit` (the well-known -//! flip's copy); this bundled copy links when the ext staticlib is -//! unavailable or `PERRY_DISABLE_WELL_KNOWN` is set. - -use crate::common::{ - get_handle, register_handle, string_from_header_lossy as string_from_header, Handle, -}; -use governor::{ - clock::DefaultClock, - state::{InMemoryState, NotKeyed}, - Quota, RateLimiter, -}; -use perry_runtime::{ - js_object_alloc_with_shape, js_object_set_field, js_promise_new, js_promise_reject, - js_promise_resolve, js_string_from_bytes, JSValue, ObjectHeader, Promise, StringHeader, -}; -use std::collections::HashMap; -use std::num::NonZeroU32; -use std::sync::Mutex; -use std::time::{Duration, Instant}; - -/// Legacy direct (non-keyed) limiter — kept for the pre-existing -/// `js_ratelimit_new` / `js_ratelimit_check` / `js_ratelimit_remaining` -/// symbol surface. -pub struct RateLimiterHandle { - pub limiter: RateLimiter, - pub points: u32, - pub duration_secs: u64, -} - -/// Per-key fixed-window state, npm-style. -struct KeyState { - consumed: u32, - window_end: Instant, - blocked_until: Option, -} - -/// `new RateLimiterMemory({ points, duration })`. -pub struct KeyedRateLimiterHandle { - states: Mutex>, - pub points: u32, - pub duration_secs: u64, -} - -impl KeyedRateLimiterHandle { - fn window(&self) -> Duration { - Duration::from_secs(self.duration_secs) - } -} - -const RATELIMIT_RES_SHAPE_ID: u32 = 0x7FFF_F31A; - -/// Build a `RateLimiterRes`-shaped result object (main thread only). -fn ratelimiter_res( - remaining_points: f64, - ms_before_next: f64, - consumed_points: f64, - is_first_in_duration: bool, -) -> f64 { - let packed = b"remainingPoints\0msBeforeNext\0consumedPoints\0isFirstInDuration\0"; - let obj = js_object_alloc_with_shape( - RATELIMIT_RES_SHAPE_ID, - 4, - packed.as_ptr(), - packed.len() as u32, - ); - js_object_set_field(obj, 0, JSValue::number(remaining_points)); - js_object_set_field(obj, 1, JSValue::number(ms_before_next)); - js_object_set_field(obj, 2, JSValue::number(consumed_points)); - js_object_set_field(obj, 3, JSValue::bool(is_first_in_duration)); - f64::from_bits(JSValue::pointer(obj as *const u8).bits()) -} - -fn reject_str(promise: *mut Promise, msg: &str) { - let s = js_string_from_bytes(msg.as_ptr(), msg.len() as u32); - js_promise_reject(promise, f64::from_bits(JSValue::string_ptr(s).bits())); -} - -/// new RateLimiter (legacy, non-keyed). -#[no_mangle] -pub extern "C" fn js_ratelimit_new(points: f64, duration_secs: f64) -> Handle { - let points = points.max(1.0) as u32; - let duration_secs = duration_secs.max(1.0) as u64; - - let quota = Quota::with_period(Duration::from_secs(duration_secs)) - .unwrap() - .allow_burst(NonZeroU32::new(points).unwrap()); - - let limiter = RateLimiter::direct(quota); - - register_handle(RateLimiterHandle { - limiter, - points, - duration_secs, - }) -} - -/// new RateLimiterMemory(opts) for keyed limiting -> KeyedRateLimiter -#[no_mangle] -pub extern "C" fn js_ratelimit_new_keyed(points: f64, duration_secs: f64) -> Handle { - let points = points.max(1.0) as u32; - let duration_secs = duration_secs.max(1.0) as u64; - - register_handle(KeyedRateLimiterHandle { - states: Mutex::new(HashMap::new()), - points, - duration_secs, - }) -} - -/// `new RateLimiterMemory(opts)` — parse `{ points, duration }` from the -/// NaN-boxed options object (raw bits as i64; TAG_UNDEFINED when the -/// constructor was called without arguments). Defaults mirror npm's -/// `RateLimiterAbstract`: points = 4, duration = 1 second. -/// -/// # Safety -/// `options_bits` must be valid NaN-box bits. -#[no_mangle] -pub unsafe extern "C" fn js_ratelimit_new_from_options(options_bits: i64) -> Handle { - let mut points: f64 = 4.0; - let mut duration: f64 = 1.0; - let jv = JSValue::from_bits(options_bits as u64); - if jv.is_pointer() { - let obj = jv.as_pointer::(); - if !obj.is_null() && perry_runtime::value::addr_class::is_above_handle_band(obj as usize) { - let field = |name: &[u8]| -> f64 { - let key = js_string_from_bytes(name.as_ptr(), name.len() as u32); - perry_runtime::object::js_object_get_field_by_name_f64(obj, key) - }; - let read_num = |v: f64| -> Option { - let jv = JSValue::from_bits(v.to_bits()); - if jv.is_int32() { - Some(jv.as_int32() as f64) - } else if jv.is_number() && !v.is_nan() { - Some(v) - } else { - None - } - }; - if let Some(n) = read_num(field(b"points")) { - points = n; - } - if let Some(n) = read_num(field(b"duration")) { - duration = n; - } - } - } - js_ratelimit_new_keyed(points, duration) -} - -/// `limiter.consume(key, points = 1)` — npm-style fixed window. Resolves -/// a `RateLimiterRes` object; rejects with the same shape when the -/// window's quota is exceeded (or the key is blocked). -/// -/// # Safety -/// `key_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_ratelimit_consume( - handle: Handle, - key_ptr: *const StringHeader, - points: f64, -) -> *mut Promise { - let promise = js_promise_new(); - let key = string_from_header(key_ptr).unwrap_or_else(|| "default".to_string()); - let consume_points = points.max(1.0) as u32; - - let Some(keyed) = get_handle::(handle) else { - reject_str(promise, "Invalid rate limiter handle"); - return promise; - }; - - let now = Instant::now(); - let window = keyed.window(); - let mut states = keyed.states.lock().unwrap(); - let state = states.entry(key).or_insert_with(|| KeyState { - consumed: 0, - window_end: now + window, - blocked_until: None, - }); - - if let Some(until) = state.blocked_until { - if until > now { - let ms = until.duration_since(now).as_millis() as f64; - let res = ratelimiter_res(0.0, ms, state.consumed as f64, false); - drop(states); - js_promise_reject(promise, res); - return promise; - } - state.blocked_until = None; - state.consumed = 0; - state.window_end = now + window; - } - - if now >= state.window_end { - state.consumed = 0; - state.window_end = now + window; - } - - state.consumed = state.consumed.saturating_add(consume_points); - let is_first = state.consumed == consume_points; - let ms_before_next = state.window_end.duration_since(now).as_millis() as f64; - let consumed = state.consumed as f64; - let over_limit = state.consumed > keyed.points; - let remaining = (keyed.points as f64 - consumed).max(0.0); - drop(states); - - let res = ratelimiter_res(remaining, ms_before_next, consumed, is_first && !over_limit); - if over_limit { - js_promise_reject(promise, res); - } else { - js_promise_resolve(promise, res); - } - promise -} - -/// `limiter.get(key)` — current state without consuming; `null` when the -/// key has no live window. -/// -/// # Safety -/// `key_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_ratelimit_get( - handle: Handle, - key_ptr: *const StringHeader, -) -> *mut Promise { - let promise = js_promise_new(); - let key = string_from_header(key_ptr).unwrap_or_else(|| "default".to_string()); - let null = f64::from_bits(JSValue::null().bits()); - - let Some(keyed) = get_handle::(handle) else { - js_promise_resolve(promise, null); - return promise; - }; - - let now = Instant::now(); - let states = keyed.states.lock().unwrap(); - match states.get(&key) { - Some(state) if now < state.window_end || state.blocked_until.is_some_and(|u| u > now) => { - let ms = state - .blocked_until - .filter(|u| *u > now) - .unwrap_or(state.window_end) - .duration_since(now) - .as_millis() as f64; - let consumed = state.consumed as f64; - // A key still inside an active block reports zero remaining - // points even after its normal window would have expired. - let blocked = state.blocked_until.is_some_and(|u| u > now); - let remaining = if blocked { - 0.0 - } else { - (keyed.points as f64 - consumed).max(0.0) - }; - let res = ratelimiter_res(remaining, ms, consumed, false); - drop(states); - js_promise_resolve(promise, res); - } - _ => { - drop(states); - js_promise_resolve(promise, null); - } - } - promise -} - -/// `limiter.delete(key)` — drop the key's window. Resolves `true` when a -/// record existed. -/// -/// # Safety -/// `key_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_ratelimit_delete( - handle: Handle, - key_ptr: *const StringHeader, -) -> *mut Promise { - let promise = js_promise_new(); - let key = string_from_header(key_ptr).unwrap_or_else(|| "default".to_string()); - - let removed = if let Some(keyed) = get_handle::(handle) { - keyed.states.lock().unwrap().remove(&key).is_some() - } else { - false - }; - js_promise_resolve(promise, f64::from_bits(JSValue::bool(removed).bits())); - promise -} - -/// `limiter.block(key, secDuration)` — block the key for `secDuration` -/// seconds (0 = forever). Resolves a `RateLimiterRes`. -/// -/// # Safety -/// `key_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_ratelimit_block( - handle: Handle, - key_ptr: *const StringHeader, - duration_sec: f64, -) -> *mut Promise { - let promise = js_promise_new(); - let key = string_from_header(key_ptr).unwrap_or_else(|| "default".to_string()); - - let Some(keyed) = get_handle::(handle) else { - js_promise_resolve(promise, f64::from_bits(JSValue::undefined().bits())); - return promise; - }; - - let now = Instant::now(); - let secs = duration_sec.max(0.0); - let until = if secs == 0.0 { - now + Duration::from_secs(u32::MAX as u64) - } else { - now + Duration::from_millis((secs * 1000.0) as u64) - }; - let window = keyed.window(); - let mut states = keyed.states.lock().unwrap(); - let state = states.entry(key).or_insert_with(|| KeyState { - consumed: 0, - window_end: now + window, - blocked_until: None, - }); - state.blocked_until = Some(until); - let consumed = state.consumed as f64; - drop(states); - - js_promise_resolve( - promise, - ratelimiter_res(0.0, secs * 1000.0, consumed, false), - ); - promise -} - -/// `limiter.penalty(key, points = 1)` — add consumed points without a -/// quota check. Resolves a `RateLimiterRes`. -/// -/// # Safety -/// `key_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_ratelimit_penalty( - handle: Handle, - key_ptr: *const StringHeader, - points: f64, -) -> *mut Promise { - let promise = js_promise_new(); - let key = string_from_header(key_ptr).unwrap_or_else(|| "default".to_string()); - let n = points.max(1.0) as u32; - - let Some(keyed) = get_handle::(handle) else { - js_promise_resolve(promise, f64::from_bits(JSValue::null().bits())); - return promise; - }; - - let now = Instant::now(); - let window = keyed.window(); - let mut states = keyed.states.lock().unwrap(); - let state = states.entry(key).or_insert_with(|| KeyState { - consumed: 0, - window_end: now + window, - blocked_until: None, - }); - if now >= state.window_end { - state.consumed = 0; - state.window_end = now + window; - } - state.consumed = state.consumed.saturating_add(n); - let consumed = state.consumed as f64; - let ms = state.window_end.duration_since(now).as_millis() as f64; - let remaining = (keyed.points as f64 - consumed).max(0.0); - drop(states); - - js_promise_resolve(promise, ratelimiter_res(remaining, ms, consumed, false)); - promise -} - -/// `limiter.reward(key, points = 1)` — give consumed points back. -/// Resolves a `RateLimiterRes`. -/// -/// # Safety -/// `key_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_ratelimit_reward( - handle: Handle, - key_ptr: *const StringHeader, - points: f64, -) -> *mut Promise { - let promise = js_promise_new(); - let key = string_from_header(key_ptr).unwrap_or_else(|| "default".to_string()); - let n = points.max(1.0) as u32; - - let Some(keyed) = get_handle::(handle) else { - js_promise_resolve(promise, f64::from_bits(JSValue::null().bits())); - return promise; - }; - - let now = Instant::now(); - let window = keyed.window(); - let mut states = keyed.states.lock().unwrap(); - let state = states.entry(key).or_insert_with(|| KeyState { - consumed: 0, - window_end: now + window, - blocked_until: None, - }); - // Reset an expired window before reward math so a delayed reward() - // cannot underflow the (now stale) window_end in `duration_since`. - if now >= state.window_end { - state.consumed = 0; - state.window_end = now + window; - } - state.consumed = state.consumed.saturating_sub(n); - let consumed = state.consumed as f64; - let ms = state.window_end.duration_since(now).as_millis() as f64; - let remaining = (keyed.points as f64 - consumed).max(0.0); - drop(states); - - js_promise_resolve(promise, ratelimiter_res(remaining, ms, consumed, false)); - promise -} - -// ============================================================================ -// Synchronous variants for simple use cases -// ============================================================================ - -/// Check if a key would be rate limited (without consuming) -/// -/// # Safety -/// `key_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_ratelimit_check(handle: Handle, key_ptr: *const StringHeader) -> bool { - let key = string_from_header(key_ptr).unwrap_or_else(|| "default".to_string()); - - if let Some(keyed) = get_handle::(handle) { - let now = Instant::now(); - let states = keyed.states.lock().unwrap(); - if let Some(state) = states.get(&key) { - if state.blocked_until.is_some_and(|u| u > now) { - return false; - } - if now < state.window_end { - return state.consumed < keyed.points; - } - } - return true; // No live window means not rate limited - } else if let Some(simple) = get_handle::(handle) { - return simple.limiter.check().is_ok(); - } - - true -} - -/// Get remaining points for a key -/// -/// # Safety -/// `key_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_ratelimit_remaining( - handle: Handle, - key_ptr: *const StringHeader, -) -> f64 { - let key = string_from_header(key_ptr).unwrap_or_else(|| "default".to_string()); - - if let Some(keyed) = get_handle::(handle) { - let now = Instant::now(); - let states = keyed.states.lock().unwrap(); - if let Some(state) = states.get(&key) { - if state.blocked_until.is_some_and(|u| u > now) { - return 0.0; - } - if now < state.window_end { - return (keyed.points as f64 - state.consumed as f64).max(0.0); - } - } - return keyed.points as f64; - } else if let Some(simple) = get_handle::(handle) { - return simple.points as f64; - } - - 0.0 -} - -/// Reset all rate limiters -#[no_mangle] -pub extern "C" fn js_ratelimit_reset(handle: Handle) { - if let Some(keyed) = get_handle::(handle) { - keyed.states.lock().unwrap().clear(); - } -} diff --git a/crates/perry-ui-android/src/stdlib_stubs.rs b/crates/perry-ui-android/src/stdlib_stubs.rs index 0fc7b58452..8cbdd1f490 100644 --- a/crates/perry-ui-android/src/stdlib_stubs.rs +++ b/crates/perry-ui-android/src/stdlib_stubs.rs @@ -304,150 +304,6 @@ pub extern "C" fn js_crypto_sha256() -> i64 { 0 } #[no_mangle] -pub extern "C" fn js_datefns_add_days() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_datefns_add_months() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_datefns_add_years() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_datefns_difference_in_days() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_datefns_difference_in_hours() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_datefns_difference_in_minutes() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_datefns_end_of_day() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_datefns_format() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_datefns_is_after() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_datefns_is_before() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_datefns_parse_iso() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_datefns_start_of_day() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_dayjs_add() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_dayjs_date() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_dayjs_day() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_dayjs_diff() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_dayjs_end_of() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_dayjs_format() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_dayjs_from_timestamp() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_dayjs_hour() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_dayjs_is_after() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_dayjs_is_before() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_dayjs_is_same() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_dayjs_is_valid() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_dayjs_millisecond() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_dayjs_minute() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_dayjs_month() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_dayjs_now() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_dayjs_parse() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_dayjs_second() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_dayjs_start_of() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_dayjs_subtract() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_dayjs_to_iso_string() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_dayjs_unix() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_dayjs_value_of() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_dayjs_year() -> i64 { - 0 -} -#[no_mangle] pub extern "C" fn js_decimal_abs() -> i64 { 0 } @@ -555,130 +411,6 @@ pub extern "C" fn js_event_emitter_remove_all_listeners() -> i64 { pub extern "C" fn js_event_emitter_remove_listener() -> i64 { 0 } -#[no_mangle] -pub extern "C" fn js_fastify_add_hook() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_fastify_all() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_fastify_create() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_fastify_create_with_opts() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_fastify_ctx_html() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_fastify_ctx_json() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_fastify_ctx_redirect() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_fastify_ctx_text() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_fastify_delete() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_fastify_get() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_fastify_head() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_fastify_listen() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_fastify_options() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_fastify_patch() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_fastify_post() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_fastify_put() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_fastify_register() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_fastify_reply_header() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_fastify_reply_send() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_fastify_reply_status() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_fastify_req_body() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_fastify_req_header() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_fastify_req_headers() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_fastify_req_json() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_fastify_req_method() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_fastify_req_param() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_fastify_req_params() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_fastify_req_query() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_fastify_req_url() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_fastify_route() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_fastify_set_error_handler() -> i64 { - 0 -} // js_fetch_stream_* — not yet implemented for Android #[no_mangle] pub extern "C" fn js_fetch_stream_start() -> i64 { @@ -1311,34 +1043,6 @@ pub extern "C" fn js_pg_pool_query() -> i64 { 0 } #[no_mangle] -pub extern "C" fn js_ratelimit_block() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_ratelimit_consume() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_ratelimit_create() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_ratelimit_delete() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_ratelimit_get() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_ratelimit_penalty() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_ratelimit_reward() -> i64 { - 0 -} -#[no_mangle] pub extern "C" fn js_runtime_init() -> i64 { 0 } diff --git a/crates/perry/src/commands/compile/optimized_libs/driver.rs b/crates/perry/src/commands/compile/optimized_libs/driver.rs index 6f4d32d765..81749712e9 100644 --- a/crates/perry/src/commands/compile/optimized_libs/driver.rs +++ b/crates/perry/src/commands/compile/optimized_libs/driver.rs @@ -52,18 +52,6 @@ pub(crate) fn build_optimized_libs( let use_well_known = std::env::var_os("PERRY_DISABLE_WELL_KNOWN").is_none(); let iteration_set = well_known_iteration_set(ctx); - // fastify has no in-stdlib fallback (the bundled adapter was removed): it can - // only be served by perry-ext-fastify *and* a stdlib rebuilt with - // `external-fastify-pump` (the per-tick bridge that drains its request queue). - // Two fallback paths can't provide that pair, so fail clearly up front instead - // of producing a binary that hangs or fails to link with `js_fastify_*`: - // - `PERRY_DISABLE_WELL_KNOWN` — the flip never routes fastify at all. - // - `PERRY_NO_AUTO_OPTIMIZE` — uses the prebuilt `full` stdlib, which is NOT - // built with `external-fastify-pump` (and isn't rebuilt), so even though - // perry-ext-fastify links, requests would never drain (silent hang). - let imports_fastify = iteration_set - .iter() - .any(|m| m.strip_prefix("node:").unwrap_or(m) == "fastify"); let imports_undici = iteration_set .iter() .any(|m| m.strip_prefix("node:").unwrap_or(m) == "undici"); @@ -79,24 +67,6 @@ pub(crate) fn build_optimized_libs( ); std::process::exit(1); } - if imports_fastify && !use_well_known { - eprintln!( - "error: `import 'fastify'` requires the external perry-ext-fastify wrapper, but the \ - well-known flip is disabled (PERRY_DISABLE_WELL_KNOWN). The in-stdlib fastify adapter \ - was removed; unset PERRY_DISABLE_WELL_KNOWN so fastify routes to perry-ext-fastify." - ); - std::process::exit(1); - } - if imports_fastify && std::env::var_os("PERRY_NO_AUTO_OPTIMIZE").is_some() { - eprintln!( - "error: `import 'fastify'` is not supported with PERRY_NO_AUTO_OPTIMIZE: the prebuilt \ - stdlib is not compiled with `external-fastify-pump`, so fastify requests would never \ - drain (the request loop hangs). The in-stdlib fastify adapter was removed; build \ - without PERRY_NO_AUTO_OPTIMIZE so the stdlib is rebuilt with the fastify pump wired in." - ); - std::process::exit(1); - } - // `PERRY_NO_AUTO_OPTIMIZE=1` — opt out of the per-app feature-set // specialization and use the prebuilt `target/release/libperry_*.a` // built with the default `full` feature set. Used by CI doc-tests @@ -264,19 +234,6 @@ pub(crate) fn build_optimized_libs( // exists on disk first (so we can actually build it). let crate_dir = workspace_root.join("crates").join(&binding.krate); if !crate_dir.is_dir() { - // fastify has no in-stdlib fallback (the bundled adapter was - // removed) — it can only be served by perry-ext-fastify. Fail - // clearly instead of silently falling back to a copy that no - // longer exists (which would link with unresolved js_fastify_*). - if module_normalized == "fastify" { - eprintln!( - "error: `import 'fastify'` requires the external perry-ext-fastify \ - wrapper, but its source crate was not found at `{}`. The in-stdlib \ - fastify adapter was removed; build or restore perry-ext-fastify.", - crate_dir.display() - ); - std::process::exit(1); - } if matches!(format, OutputFormat::Text) && verbose > 0 { eprintln!( " well-known: skipping `{}` — crate `{}` source not on disk; \ @@ -433,15 +390,6 @@ pub(crate) fn build_optimized_libs( if matches!(module_normalized, "http" | "https" | "http2") { features.insert("external-ws-pump"); } - // Same shape for fastify. fastify is served exclusively by - // perry-ext-fastify (the in-stdlib adapter was removed), so this - // fires whenever `import 'fastify'` is routed here — not off a - // (now-gone) `bundled-fastify` feature. `external-fastify-pump` - // retains Fastify's dispatch adapters and shared async runtime; - // perry-ext-fastify self-registers its pump and keepalive callback. - if module_normalized == "fastify" { - features.insert("external-fastify-pump"); - } // Closes #604 — when the well-known flip routes `node:http` / // `node:https` / `node:http2` to perry-ext-http, activate // `external-http-server-pump` to retain its dispatch adapters and diff --git a/crates/perry/src/commands/compile/well_known.rs b/crates/perry/src/commands/compile/well_known.rs index dab94870d9..fcc8e32637 100644 --- a/crates/perry/src/commands/compile/well_known.rs +++ b/crates/perry/src/commands/compile/well_known.rs @@ -466,7 +466,7 @@ mod tests { /// stay `Partial` and are never silently treated as complete drop-ins. #[test] fn shipped_subset_bindings_are_partial() { - for name in ["undici", "node-forge", "lru-cache", "qs"] { + for name in ["undici", "node-forge", "lru-cache"] { let b = lookup_well_known(name).unwrap_or_else(|| panic!("{name} registered")); assert_eq!( b.compat, diff --git a/crates/perry/src/commands/stdlib_features.rs b/crates/perry/src/commands/stdlib_features.rs index 6ca239fa23..390c16c7b5 100644 --- a/crates/perry/src/commands/stdlib_features.rs +++ b/crates/perry/src/commands/stdlib_features.rs @@ -19,12 +19,6 @@ pub fn module_to_features(module: &str) -> &'static [&'static str] { let normalized = module.strip_prefix("node:").unwrap_or(module); match normalized { // ── HTTP server (Hyper) ─────────────────────────────────────── - // fastify needs no perry-stdlib feature: the in-stdlib adapter was - // removed, so `import 'fastify'` is served entirely by the external - // perry-ext-fastify crate via the well-known flip (which links the - // wrapper and enables `external-fastify-pump`). See optimized_libs.rs. - "fastify" => &[], - // ── Web Streams API ────────────────────────────────────────── // Per-binding gate `bundled-streams` (v0.5.572) — the // well-known flip routes `import 'streams'` to perry-ext-streams. @@ -148,8 +142,10 @@ pub fn module_to_features(module: &str) -> &'static [&'static str] { // ── Scheduler (cron) ────────────────────────────────────────── // `scheduler` umbrella retained for backwards-compat; // per-binding gate is `bundled-cron` (v0.5.564) so the - // well-known flip can route to perry-ext-cron. - "cron" | "node-cron" => &["bundled-cron"], + // well-known flip can route to perry-ext-cron. `node-cron`'s own + // binding was removed (#466) — it now compiles from real npm + // source, so only the `cron` package still needs this gate. + "cron" => &["bundled-cron"], // ── argon2 ──────────────────────────────────────────────────── // argon2 split off into `bundled-argon2` (v0.5.537) — same @@ -186,15 +182,9 @@ pub fn module_to_features(module: &str) -> &'static [&'static str] { // decimal.js / bignumber.js: feature-gated v0.5.547 — // well-known flip routes to perry-ext-decimal. "decimal.js" | "bignumber.js" => &["bundled-decimal"], - // dayjs / date-fns: feature-gated v0.5.548 — well-known - // flip routes to perry-ext-dayjs. - "dayjs" | "date-fns" => &["bundled-dayjs"], // moment: feature-gated v0.5.549 — well-known flip routes // to perry-ext-moment. "moment" => &["bundled-moment"], - // rate-limiter-flexible: feature-gated v0.5.552 — well-known - // flip routes to perry-ext-ratelimit. - "rate-limiter-flexible" => &["bundled-ratelimit"], // commander: feature-gated v0.5.555 — well-known flip routes // to perry-ext-commander. "commander" => &["bundled-commander"], diff --git a/crates/perry/tests/issue_8751_qs_native_shim.rs b/crates/perry/tests/issue_8751_qs_native_shim.rs deleted file mode 100644 index 7e3cf96599..0000000000 --- a/crates/perry/tests/issue_8751_qs_native_shim.rs +++ /dev/null @@ -1,154 +0,0 @@ -//! Regression coverage for #8751: Stripe's CommonJS request helper requires -//! `qs` and calls `qs.stringify` with indexed arrays and a Date serializer. -//! Compiling upstream qs pulls in get-intrinsic's legacy ES-shims chain, which -//! is hostile to Perry's AOT path. The bundled binding must win even when a -//! deliberately broken on-disk qs package is present transitively. - -use std::path::PathBuf; -use std::process::Command; - -fn perry_bin() -> PathBuf { - PathBuf::from(env!("CARGO_BIN_EXE_perry")) -} - -#[test] -fn stripe_style_dependency_uses_native_qs_without_compiling_installed_source() { - let dir = tempfile::tempdir().expect("tempdir"); - let root = dir.path(); - - std::fs::write( - root.join("package.json"), - r#"{ - "name": "issue-8751", - "type": "module", - "perry": { - "compilePackages": ["stripe-fixture"], - "allow": { "compilePackages": ["stripe-fixture"] } - } -}"#, - ) - .expect("write app package.json"); - - let stripe = root.join("node_modules").join("stripe-fixture"); - std::fs::create_dir_all(&stripe).expect("mkdir stripe fixture"); - std::fs::write( - stripe.join("package.json"), - r#"{ "name": "stripe-fixture", "version": "1.0.0", "main": "index.js" }"#, - ) - .expect("write stripe fixture package.json"); - std::fs::write( - stripe.join("index.js"), - r#"'use strict'; -const qs = require('qs'); - -exports.encodeStripePayload = function encodeStripePayload(data) { - return qs.stringify(data, { - serializeDate: function serializeDate(date) { - return Math.floor(date.getTime() / 1000).toString(); - }, - arrayFormat: 'indices' - }).replace(/%5B/g, '[').replace(/%5D/g, ']'); -}; -"#, - ) - .expect("write stripe fixture source"); - - // If module resolution ever falls back to compiling the installed qs, - // compilation or startup fails with this sentinel. A get-intrinsic stub is - // included to retain the transitive shape reported in #8751. - let qs = root.join("node_modules").join("qs"); - std::fs::create_dir_all(&qs).expect("mkdir hostile qs"); - std::fs::write( - qs.join("package.json"), - r#"{ "name": "qs", "version": "6.15.3", "main": "index.js" }"#, - ) - .expect("write hostile qs package.json"); - std::fs::write( - qs.join("index.js"), - "throw new Error('AOT-HOSTILE-QS-SOURCE-WAS-COMPILED');\n", - ) - .expect("write hostile qs source"); - let intrinsic = root.join("node_modules").join("get-intrinsic"); - std::fs::create_dir_all(&intrinsic).expect("mkdir get-intrinsic"); - std::fs::write( - intrinsic.join("package.json"), - r#"{ "name": "get-intrinsic", "version": "1.3.0", "main": "index.js" }"#, - ) - .expect("write get-intrinsic package.json"); - std::fs::write( - intrinsic.join("index.js"), - "throw new SyntaxError('intrinsic %% does not exist!');\n", - ) - .expect("write get-intrinsic source"); - - let entry = root.join("main.ts"); - std::fs::write( - &entry, - r#" -import qsDefault from "qs"; -import * as qsNamespace from "qs"; -import { parse, stringify } from "qs"; -import { encodeStripePayload } from "stripe-fixture"; - -const payload = { - customer: { name: "Ada Lovelace" }, - items: [ - { price: "p_1", quantity: 2 }, - { price: "p_2", quantity: 1 } - ], - metadata: { empty: null }, - created: new Date("2024-01-02T03:04:05Z") -}; - -console.log(encodeStripePayload(payload)); -console.log(JSON.stringify(parse("customer[name]=Ada%20Lovelace&items[0][price]=p_1&items[1][price]=p_2&tag=a&tag=b"))); -console.log(stringify({ a: ["x", "y"], empty: [], nil: null }, { - arrayFormat: "brackets", - allowEmptyArrays: true, - strictNullHandling: true, - addQueryPrefix: true -})); -console.log(stringify({ z: "last", a: "first" }, { - sort: (left: string, right: string) => left < right ? -1 : left > right ? 1 : 0, - encoder: (value: any) => "X" + String(value) -})); -console.log(qsNamespace.stringify({ a: 1 }), qsDefault.stringify({ a: 1 }), stringify({ a: 1 })); -"#, - ) - .expect("write entry"); - - let output = root.join("main_bin"); - let compile = Command::new(perry_bin()) - .current_dir(root) - .arg("compile") - .arg(&entry) - .arg("-o") - .arg(&output) - .output() - .expect("run perry compile"); - assert!( - compile.status.success(), - "perry compile failed\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&compile.stdout), - String::from_utf8_lossy(&compile.stderr) - ); - - let run = Command::new(&output).output().expect("run compiled binary"); - assert!( - run.status.success(), - "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", - run.status, - String::from_utf8_lossy(&run.stdout), - String::from_utf8_lossy(&run.stderr) - ); - assert_eq!( - String::from_utf8_lossy(&run.stdout), - concat!( - "customer[name]=Ada%20Lovelace&items[0][price]=p_1&items[0][quantity]=2&items[1][price]=p_2&items[1][quantity]=1&metadata[empty]=&created=1704164645\n", - "{\"customer\":{\"name\":\"Ada Lovelace\"},\"items\":[{\"price\":\"p_1\"},{\"price\":\"p_2\"}],\"tag\":[\"a\",\"b\"]}\n", - "?a%5B%5D=x&a%5B%5D=y&empty[]&nil\n", - "Xa=Xfirst&Xz=Xlast\n", - "a=1 a=1 a=1\n" - ) - ); -} diff --git a/crates/perry/well_known_bindings.toml b/crates/perry/well_known_bindings.toml index 1a913b4cff..335a030460 100644 --- a/crates/perry/well_known_bindings.toml +++ b/crates/perry/well_known_bindings.toml @@ -36,23 +36,6 @@ # requires every ext crate and package mapping to have an explicit decision # (#5716). -[bindings.qs] -crate = "perry-ext-qs" -lib = "perry_ext_qs" -tracking = "#8751" -# Partial: stringify covers qs' ordinary scalar/object/array surface and the -# callback/options used by Stripe. parse covers the safe nested-query subset. -# Regex delimiters and the full decoder/filter extension-hook contracts remain -# intentionally outside this shim. -compat = "partial" - -[bindings.qs.upstream] -version = "6.15.3" -sha256 = "c0278b636e7a016d6e835cd8f194a63c276dff430620e4a04344a4ba8892c0f9" -repo = "https://github.com/ljharb/qs.git" -ref = "18d085e919dae70c8f1b200ab99323058edab2c2" -ported-at = "6.15.3" -date = "2026-08-25" [bindings.bcrypt] crate = "perry-ext-bcrypt" lib = "perry_ext_bcrypt" @@ -193,29 +176,6 @@ repo = "https://github.com/MikeMcl/bignumber.js" ref = "275cd85181041d7b6bebf8ef9b70ee0b1bf056b0" ported-at = "11.1.5" date = "2026-07-30" -[bindings.dayjs] -crate = "perry-ext-dayjs" -lib = "perry_ext_dayjs" -tracking = "#466" - -[bindings.dayjs.upstream] -version = "1.11.21" -sha256 = "290ec8bf81878ae4927581d9be74125f07722ea8bbea2ff77a2226dce12df876" -repo = "https://github.com/iamkun/dayjs" -ref = "a25f01e8154290b203ab480ffeedc60c80bd0710" -ported-at = "1.11.21" -date = "2026-07-30" -[bindings."date-fns"] -crate = "perry-ext-dayjs" -lib = "perry_ext_dayjs" -tracking = "#466" - -[bindings.date-fns.upstream] -version = "4.4.0" -sha256 = "eb106d1e9276213d6144b221c103e4abb7d92186734f7505f5a3860427b41a06" -repo = "https://github.com/date-fns/date-fns" -ported-at = "4.4.0" -date = "2026-07-30" [bindings.moment] crate = "perry-ext-moment" lib = "perry_ext_moment" @@ -251,18 +211,6 @@ repo = "https://github.com/lovell/sharp" ref = "1018449164723ba0203c1beffaba0e21f7829c18" ported-at = "0.35.3" date = "2026-07-30" -[bindings."rate-limiter-flexible"] -crate = "perry-ext-ratelimit" -lib = "perry_ext_ratelimit" -tracking = "#466" - -[bindings.rate-limiter-flexible.upstream] -version = "11.2.0" -sha256 = "db92ca4b73bea8ccac0ed67b37b6970170dea15a063f539a5b354bb2a36661d0" -repo = "https://github.com/animir/node-rate-limiter-flexible" -ref = "2c12f60043c4bc2d0a7b7d05392824027d810b75" -ported-at = "11.2.0" -date = "2026-07-30" [bindings.commander] crate = "perry-ext-commander" lib = "perry_ext_commander" @@ -299,18 +247,6 @@ repo = "https://github.com/kelektiv/node-cron" ref = "d2f18cf7c56721913607c8159abe9f18c72decba" ported-at = "4.4.0" date = "2026-07-30" -[bindings."node-cron"] -crate = "perry-ext-cron" -lib = "perry_ext_cron" -tracking = "#466" - -[bindings.node-cron.upstream] -version = "4.6.0" -sha256 = "78766bf4f4de32b009259e4e780a9ac8cd7155da970052427e68518cbeecfd32" -repo = "https://github.com/node-cron/node-cron" -ref = "0be2ca03ffebc79fa823081b8a44bff6f731a583" -ported-at = "4.6.0" -date = "2026-07-30" [bindings.ioredis] crate = "perry-ext-ioredis" lib = "perry_ext_ioredis" @@ -464,11 +400,6 @@ lib = "perry_ext_streams" tracking = "#466" node-builtin = true -[bindings.fastify] -crate = "perry-ext-fastify" -lib = "perry_ext_fastify" -tracking = "#466" - # `@perryts/google-auth` is no longer bundled — extracted to a # standalone repo (https://github.com/PerryTS/google-auth) and # published as an npm package starting v0.5.1015. The previous @@ -484,14 +415,6 @@ tracking = "#466" # function FFI: createPdf / pdfAddText / pdfAddLine / pdfNewPage / # pdfSave. Page units = PDF points (1/72"), origin = bottom-left # per the PDF standard. - -[bindings.fastify.upstream] -version = "5.10.0" -sha256 = "765b9bbdc03029d65b2fc544306ce950029c3470806d2ddee47a454dec0a67dc" -repo = "https://github.com/fastify/fastify" -ref = "c47975e8a4c0cb1cb066749b948c0e6e22c97663" -ported-at = "5.10.0" -date = "2026-07-30" [bindings."@perryts/pdf"] crate = "perry-ext-pdf" lib = "perry_ext_pdf" diff --git a/docs/api/perry.d.ts b/docs/api/perry.d.ts index 78c924c802..69088dc049 100644 --- a/docs/api/perry.d.ts +++ b/docs/api/perry.d.ts @@ -1,6 +1,6 @@ // Auto-generated from Perry's API manifest (#465). Do not edit by hand. // Source: perry-api-manifest::API_MANIFEST -// Coverage: 2064 entries across 130 modules +// Coverage: 2043 entries across 124 modules type PerryI8 = number & { readonly __perryI8?: never }; type PerryI16 = number & { readonly __perryI16?: never }; @@ -1178,40 +1178,6 @@ declare module "crypto" { export function verify(...args: any[]): any; } -declare module "date-fns" { - /** stdlib */ - export function addDays(...args: any[]): any; - /** stdlib */ - export function addMonths(...args: any[]): any; - /** stdlib */ - export function addYears(...args: any[]): any; - /** stdlib */ - export function differenceInDays(...args: any[]): any; - /** stdlib */ - export function differenceInHours(...args: any[]): any; - /** stdlib */ - export function differenceInMinutes(...args: any[]): any; - /** stdlib */ - export function endOfDay(...args: any[]): any; - /** stdlib */ - export function format(...args: any[]): any; - /** stdlib */ - export function isAfter(...args: any[]): any; - /** stdlib */ - export function isBefore(...args: any[]): any; - /** stdlib */ - export function parseISO(...args: any[]): any; - /** stdlib */ - export function startOfDay(...args: any[]): any; -} - -declare module "dayjs" { - /** stdlib */ - export function dayjs(input?: any): any; - /** stdlib */ - export default function (input?: any): any; -} - declare module "dgram" { /** stdlib */ export class Socket { [key: string]: any; } @@ -1583,11 +1549,6 @@ declare module "exponential-backoff" { export function backOff(p0: any, p1: any): any; } -declare module "fastify" { - /** stdlib */ - export default function (p0: any): any; -} - declare module "fetch" { /** stdlib */ export class Blob { [key: string]: any; } @@ -2262,13 +2223,6 @@ declare module "net" { export function setDefaultAutoSelectFamilyAttemptTimeout(...args: any[]): any; } -declare module "node-cron" { - /** stdlib */ - export function schedule(...args: any[]): any; - /** stdlib */ - export function validate(...args: any[]): any; -} - declare module "node-fetch" { /** stdlib */ export class Blob { [key: string]: any; } @@ -3591,13 +3545,6 @@ declare module "punycode" { export function toUnicode(...args: any[]): any; } -declare module "qs" { - /** stdlib */ - export function parse(input: string, options: any): any; - /** stdlib */ - export function stringify(value: any, options: any): string; -} - declare module "querystring" { /** stdlib */ const _default: any; @@ -3618,13 +3565,6 @@ declare module "querystring" { export function unescapeBuffer(...args: any[]): any; } -declare module "rate-limiter-flexible" { - /** stdlib */ - export class RateLimiterAbstract { [key: string]: any; } - /** stdlib */ - export class RateLimiterMemory { [key: string]: any; } -} - declare module "readline" { /** stdlib */ export function clearLine(...args: any[]): any; diff --git a/docs/native-libraries.md b/docs/native-libraries.md index 33c567379f..93786408e8 100644 --- a/docs/native-libraries.md +++ b/docs/native-libraries.md @@ -49,10 +49,7 @@ implementations, organized by category: ### Date & Time | npm Package | Rust Backend | Description | |-------------|--------------|-------------| -| `dayjs` | [chrono](https://crates.io/crates/chrono) | Lightweight date manipulation | | `moment` | [chrono](https://crates.io/crates/chrono) | Full-featured date library | -| `date-fns` | [chrono](https://crates.io/crates/chrono) | Functional date utilities | -| `node-cron` | [cron](https://crates.io/crates/cron) | Cron job scheduling | ### Utilities | npm Package | Rust Backend | Description | @@ -62,7 +59,6 @@ implementations, organized by category: | `slugify` | Native Rust | URL-friendly string conversion | | `validator` | [validator](https://crates.io/crates/validator) | String validation (email, URL, etc.) | | `dotenv` | Native Rust | Environment variable loading | -| `rate-limiter-flexible` | [dashmap](https://crates.io/crates/dashmap) | In-memory rate limiting | ## Quick Start @@ -119,8 +115,6 @@ Click any library name to jump to its documentation: | better-sqlite3 | Database | [docs](#better-sqlite3) | | cheerio | HTML Parsing | [docs](#cheerio) | | crypto | Security | [docs](#crypto) | -| date-fns | Date/Time | [docs](#date-fns) | -| dayjs | Date/Time | [docs](#dayjs) | | dotenv | Config | [docs](#dotenv) | | ioredis | Database | [docs](#ioredis) | | jsonwebtoken | Security | [docs](#jsonwebtoken) | @@ -129,11 +123,9 @@ Click any library name to jump to its documentation: | mongodb | Database | [docs](#mongodb) | | mysql2 | Database | [docs](#mysql2) | | nanoid | Utilities | [docs](#nanoid) | -| node-cron | Scheduling | [docs](#node-cron) | | node-fetch | HTTP Client | [docs](#node-fetch) | | nodemailer | Email | [docs](#nodemailer) | | pg | Database | [docs](#pg) | -| rate-limiter-flexible | Rate Limiting | [docs](#rate-limiter-flexible) | | sharp | Image Processing | [docs](#sharp) | | slugify | Utilities | [docs](#slugify) | | uuid | Utilities | [docs](#uuid) | @@ -799,125 +791,6 @@ const isValid = await transporter.verify(); --- -## dayjs - -**npm package:** [dayjs](https://www.npmjs.com/package/dayjs) -**Rust backend:** [chrono](https://crates.io/crates/chrono) v0.4 - -### Supported API - -```typescript -import dayjs from 'dayjs'; - -// Create instances -const now = dayjs(); // Current time -const fromTimestamp = dayjs(1609459200000); // From Unix timestamp (ms) -const parsed = dayjs('2021-01-01'); // Parse ISO string - -// Format dates -const formatted = now.format('YYYY-MM-DD HH:mm:ss'); -const iso = now.toISOString(); // ISO 8601 format - -// Get values -now.valueOf(); // Unix timestamp (ms) -now.unix(); // Unix timestamp (seconds) -now.year(); // Year (e.g., 2024) -now.month(); // Month (0-11) -now.date(); // Day of month (1-31) -now.day(); // Day of week (0=Sunday) -now.hour(); // Hour (0-23) -now.minute(); // Minute (0-59) -now.second(); // Second (0-59) -now.millisecond(); // Millisecond (0-999) - -// Manipulate dates -const tomorrow = now.add(1, 'day'); -const lastWeek = now.subtract(7, 'day'); -const startOfMonth = now.startOf('month'); -const endOfYear = now.endOf('year'); - -// Compare dates -const diff = now.diff(parsed, 'day'); // Difference in days -now.isBefore(parsed); // Returns boolean -now.isAfter(parsed); // Returns boolean -now.isSame(parsed, 'day'); // Same day? -now.isValid(); // Is valid date? -``` - -### Supported Units - -For `add()`, `subtract()`, `startOf()`, `endOf()`, `diff()`, `isSame()`: -- `year`, `years`, `y` -- `month`, `months`, `M` -- `day`, `days`, `d` -- `hour`, `hours`, `h` -- `minute`, `minutes`, `m` -- `second`, `seconds`, `s` -- `millisecond`, `milliseconds`, `ms` - -### Notes -- All dates are UTC internally -- Immutable API (methods return new instances) -- Format tokens: `YYYY`, `MM`, `DD`, `HH`, `mm`, `ss`, etc. - ---- - -## date-fns - -**npm package:** [date-fns](https://www.npmjs.com/package/date-fns) -**Rust backend:** [chrono](https://crates.io/crates/chrono) v0.4 - -### Supported API - -```typescript -import { - format, - parseISO, - addDays, - addMonths, - addYears, - differenceInDays, - differenceInHours, - differenceInMinutes, - isAfter, - isBefore, - startOfDay, - endOfDay -} from 'date-fns'; - -// Format a date -const formatted = format(new Date(), 'yyyy-MM-dd HH:mm:ss'); - -// Parse ISO string -const date = parseISO('2021-01-01T00:00:00Z'); - -// Add time -const tomorrow = addDays(new Date(), 1); -const nextMonth = addMonths(new Date(), 1); -const nextYear = addYears(new Date(), 1); - -// Calculate differences -const daysDiff = differenceInDays(date1, date2); -const hoursDiff = differenceInHours(date1, date2); -const minutesDiff = differenceInMinutes(date1, date2); - -// Compare dates -const after = isAfter(date1, date2); -const before = isBefore(date1, date2); - -// Day boundaries -const dayStart = startOfDay(new Date()); -const dayEnd = endOfDay(new Date()); -``` - -### Notes -- Uses Unix timestamps (milliseconds) internally -- Format tokens differ from dayjs: `yyyy` (not `YYYY`), etc. -- Functions are pure (no mutation) -- All times are UTC - ---- - ## argon2 **npm package:** [argon2](https://www.npmjs.com/package/argon2) @@ -1293,124 +1166,6 @@ const diff = now.diff(parsed, 'day'); // Difference in days --- -## node-cron - -**npm package:** [node-cron](https://www.npmjs.com/package/node-cron) -**Rust backend:** [cron](https://crates.io/crates/cron) v0.12 - -### Supported API - -```typescript -import cron from 'node-cron'; - -// Validate cron expression -const isValid = cron.validate('* * * * *'); // true - -// Schedule a job -const job = cron.schedule('*/5 * * * *', () => { - console.log('Running every 5 minutes'); -}); - -// Control the job -job.start(); // Start the job -job.stop(); // Stop the job -const running = job.isRunning(); // Check if running - -// Get next execution times -const next = job.nextDate(); // ISO string of next run -const nextFive = job.nextDates(5); // Array of next 5 run times - -// Get human-readable description -const desc = cron.describe('0 0 * * *'); -// "At second 0 minute 0 of hour *, on day * of month *, on weekday *" - -// Timer helpers (setTimeout/setInterval alternative) -const interval = cron.setInterval(() => { - console.log('Every second'); -}, 1000); -cron.clearInterval(interval); - -const timeout = cron.setTimeout(() => { - console.log('After 5 seconds'); -}, 5000); -cron.clearTimeout(timeout); -``` - -### Cron Expression Format - -``` -┌────────────── second (0-59) [optional] -│ ┌──────────── minute (0-59) -│ │ ┌────────── hour (0-23) -│ │ │ ┌──────── day of month (1-31) -│ │ │ │ ┌────── month (1-12) -│ │ │ │ │ ┌──── day of week (0-6, Sunday=0) -│ │ │ │ │ │ -* * * * * * -``` - -### Notes -- Both 5-field and 6-field (with seconds) formats supported -- Callback ID is used internally (actual callbacks not yet implemented) -- Job timing is based on UTC - ---- - -## rate-limiter-flexible - -**npm package:** [rate-limiter-flexible](https://www.npmjs.com/package/rate-limiter-flexible) -**Rust backend:** [governor](https://crates.io/crates/governor) v0.6 - -### Supported API - -```typescript -import { RateLimiterMemory } from 'rate-limiter-flexible'; - -// Create rate limiter -const limiter = RateLimiterMemory.create({ - points: 10, // Number of points - duration: 1 // Per second -}); - -// Consume points -try { - const result = await limiter.consume('user_123', 1); - console.log('Remaining points:', result.remainingPoints); -} catch (rateLimiterRes) { - console.log('Rate limited! Retry after:', rateLimiterRes.msBeforeNext); -} - -// Get current state -const info = await limiter.get('user_123'); - -// Delete key -await limiter.delete('user_123'); - -// Block a key for duration -await limiter.block('user_123', 60); // Block for 60 seconds - -// Add penalty points -await limiter.penalty('user_123', 2); // Consume 2 extra points - -// Reward points (restore consumed points) -await limiter.reward('user_123', 1); // Give back 1 point -``` - -### Result Properties - -- `remainingPoints` - Points remaining in current window -- `msBeforeNext` - Milliseconds until points reset -- `consumedPoints` - Points consumed so far -- `isFirstInDuration` - Whether this is first request in window - -### Notes -- Memory-based storage (resets on restart) -- Uses token bucket algorithm -- Thread-safe for concurrent requests -- Consider Redis-backed limiter for distributed systems - ---- - ## Implementation Notes ### How It Works diff --git a/docs/src/api/reference.md b/docs/src/api/reference.md index 5ea2875952..f2357d9d51 100644 --- a/docs/src/api/reference.md +++ b/docs/src/api/reference.md @@ -2,7 +2,7 @@ This page is auto-generated from Perry's compile-time API manifest (`perry-api-manifest::API_MANIFEST`). It is the source of truth for what `perry compile` accepts; references to symbols not listed here produce `R005 UnimplementedApi` (issue #463). Stubs (#464) are flagged ⚠ — they link cleanly but no-op at runtime on the chosen target. -Total: 3006 entries across 132 modules. +Total: 2923 entries across 126 modules. ## Modules @@ -39,8 +39,6 @@ Total: 3006 entries across 132 modules. - [`constants`](#constants) - [`cron`](#cron) - [`crypto`](#crypto) -- [`date-fns`](#date-fns) -- [`dayjs`](#dayjs) - [`decimal.js`](#decimaljs) - [`dgram`](#dgram) - [`diagnostics_channel`](#diagnostics_channel) @@ -50,7 +48,6 @@ Total: 3006 entries across 132 modules. - [`ethers`](#ethers) - [`events`](#events) - [`exponential-backoff`](#exponential-backoff) -- [`fastify`](#fastify) - [`fetch`](#fetch) - [`ffi`](#ffi) - [`fs`](#fs) @@ -70,7 +67,6 @@ Total: 3006 entries across 132 modules. - [`mysql2`](#mysql2) - [`mysql2/promise`](#mysql2promise) - [`net`](#net) -- [`node-cron`](#node-cron) - [`node-fetch`](#node-fetch) - [`node-forge`](#node-forge) - [`node-pty`](#node-pty) @@ -104,9 +100,7 @@ Total: 3006 entries across 132 modules. - [`pg`](#pg) - [`process`](#process) - [`punycode`](#punycode) -- [`qs`](#qs) - [`querystring`](#querystring) -- [`rate-limiter-flexible`](#rate-limiter-flexible) - [`readline`](#readline) - [`readline/promises`](#readlinepromises) - [`redis`](#redis) @@ -968,52 +962,6 @@ Total: 3006 entries across 132 modules. - `subtle` - `webcrypto` -## `date-fns` - -### Methods - -- `addDays` — module -- `addMonths` — module -- `addYears` — module -- `differenceInDays` — module -- `differenceInHours` — module -- `differenceInMinutes` — module -- `endOfDay` — module -- `format` — module -- `isAfter` — module -- `isBefore` — module -- `parseISO` — module -- `startOfDay` — module - -## `dayjs` - -### Methods - -- `add` — instance -- `clone` — instance -- `date` — instance -- `day` — instance -- `dayjs` — module -- `default` — module -- `diff` — instance -- `endOf` — instance -- `format` — instance -- `hour` — instance -- `isAfter` — instance -- `isBefore` — instance -- `isSame` — instance -- `isValid` — instance -- `millisecond` — instance -- `minute` — instance -- `month` — instance -- `second` — instance -- `startOf` — instance -- `subtract` — instance -- `toISOString` — instance -- `unix` — instance -- `valueOf` — instance -- `year` — instance - ## `decimal.js` ### Methods @@ -1393,46 +1341,6 @@ Total: 3006 entries across 132 modules. - `backOff` — module -## `fastify` - -### Methods - -- `addHook` — instance -- `all` — instance -- `body` — instance -- `close` — instance -- `code` — instance -- `default` — module -- `delete` — instance -- `get` — instance -- `head` — instance -- `header` — instance -- `headers` — instance -- `html` — instance -- `json` — instance -- `listen` — instance -- `method` — instance -- `on` — instance -- `options` — instance -- `param` — instance -- `params` — instance -- `patch` — instance -- `post` — instance -- `put` — instance -- `query` — instance -- `rawBody` — instance -- `redirect` — instance -- `register` — instance -- `route` — instance -- `send` — instance -- `server` — instance -- `setErrorHandler` — instance -- `status` — instance -- `text` — instance -- `type` — instance -- `url` — instance -- `user` — instance - ## `fetch` ### Classes @@ -2294,13 +2202,6 @@ Total: 3006 entries across 132 modules. - `upgradeToTLS` — instance *(class: `Socket`)* - `write` — instance *(class: `Socket`)* -## `node-cron` - -### Methods - -- `schedule` — module -- `validate` — module - ## `node-fetch` ### Classes @@ -3147,13 +3048,6 @@ Total: 3006 entries across 132 modules. - `ucs2` - `version` -## `qs` - -### Methods - -- `parse` — module -- `stringify` — module - ## `querystring` ### Methods @@ -3170,22 +3064,6 @@ Total: 3006 entries across 132 modules. - `default` -## `rate-limiter-flexible` - -### Classes - -- `RateLimiterAbstract` -- `RateLimiterMemory` - -### Methods - -- `block` — instance -- `consume` — instance -- `delete` — instance -- `get` — instance -- `penalty` — instance -- `reward` — instance - ## `readline` ### Methods diff --git a/docs/src/native-libraries/governance.md b/docs/src/native-libraries/governance.md index d893a096a4..f844048889 100644 --- a/docs/src/native-libraries/governance.md +++ b/docs/src/native-libraries/governance.md @@ -90,13 +90,11 @@ from `well_known_bindings.toml`. Regenerate this table with | `perry-ext-better-sqlite3` | `better-sqlite3` | External integration | Move to an external native package | Bundled; migration pending | | `perry-ext-cheerio` | `cheerio` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-commander` | `commander` | Source package | Compile the upstream package source | Bundled; migration pending | -| `perry-ext-cron` | `cron`
`node-cron` | Source package | Compile the upstream package source | Bundled; migration pending | -| `perry-ext-dayjs` | `date-fns`
`dayjs` | Source package | Compile the upstream package source | Bundled; migration pending | +| `perry-ext-cron` | `cron` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-decimal` | `bignumber.js`
`decimal.js` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-ethers` | `ethers` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-events` | `events` | Runtime API | Keep near core; consolidate when practical | Bundled; retained | | `perry-ext-exponential-backoff` | `exponential-backoff` | Source package | Compile the upstream package source | Bundled; migration pending | -| `perry-ext-fastify` | `fastify` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-fetch` | `node-fetch` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-http` | `http`
`http2`
`https` | Runtime API | Keep near core; consolidate when practical | Bundled; retained | | `perry-ext-ioredis` | `ioredis`
`iovalkey`
`redis` | Source package | Compile the upstream package source | Bundled; migration pending | @@ -110,8 +108,6 @@ from `well_known_bindings.toml`. Regenerate this table with | `perry-ext-parcel-watcher` | `@parcel/watcher`
`@parcel/watcher-darwin-arm64`
`@parcel/watcher-darwin-x64`
`@parcel/watcher-linux-arm64-glibc`
`@parcel/watcher-linux-arm64-musl`
`@parcel/watcher-linux-x64-glibc`
`@parcel/watcher-linux-x64-musl`
`@parcel/watcher-win32-arm64`
`@parcel/watcher-win32-x64` | External integration | Move to an external native package | Bundled; migration pending | | `perry-ext-pdf` | `@perryts/pdf` | External integration | Move to an external native package | Bundled; migration pending | | `perry-ext-pg` | `pg` | Source package | Compile the upstream package source | Bundled; migration pending | -| `perry-ext-qs` | `qs` | Source package | Compile the upstream package source | Bundled; migration pending | -| `perry-ext-ratelimit` | `rate-limiter-flexible` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-sharp` | `sharp` | External integration | Move to an external native package | Bundled; migration pending | | `perry-ext-streams` | `streams` | Runtime API | Keep near core; consolidate when practical | Bundled; retained | | `perry-ext-typescript` | `typescript` | Source package | Compile the upstream package source | Bundled; migration pending | diff --git a/docs/src/stdlib/other.md b/docs/src/stdlib/other.md index 9bed2ebc46..14950657ba 100644 --- a/docs/src/stdlib/other.md +++ b/docs/src/stdlib/other.md @@ -62,10 +62,11 @@ const decompressed = zlib.gunzipSync(compressed); console.log(decompressed.toString()); // "Hello, World!" ``` -## cron / node-cron (Job Scheduling) +## cron (Job Scheduling) -Native bindings via `perry-ext-cron` (v0.5.564). Both `cron` and `node-cron` -package names route to the same backend. +Native bindings via `perry-ext-cron` (v0.5.564). The npm `node-cron` binding +that used to share this backend was removed (#466) — `import ... from +"node-cron"` now compiles the real npm package from source. ```typescript,no-test import { CronJob } from "cron"; @@ -126,19 +127,6 @@ to the same backend — `Decimal` and `BigNumber` are both exposed. {{#include ../../examples/stdlib/other/snippets.ts:decimal}} ``` -## dayjs / date-fns (Date Manipulation) - -Native bindings via `perry-ext-dayjs` (v0.5.548). Both package names route to -the same Rust backend — same parse/format/diff surface. - -```typescript,no-test -import dayjs from "dayjs"; - -const now = dayjs(); -const tomorrow = now.add(1, "day"); -console.log(tomorrow.format("YYYY-MM-DD")); -``` - ## moment (Legacy Date) Native bindings via `perry-ext-moment` (v0.5.549). `moment` is in maintenance @@ -152,22 +140,6 @@ const m = moment().add(7, "days"); console.log(m.format()); ``` -## rate-limiter-flexible - -Native bindings via `perry-ext-ratelimit` (v0.5.552). In-memory limiter is -wired; Redis / cluster backing stores are follow-ups. - -```typescript,no-test -import { RateLimiterMemory } from "rate-limiter-flexible"; - -const limiter = new RateLimiterMemory({ points: 5, duration: 1 }); -try { - await limiter.consume("ip-1.2.3.4"); -} catch (rateLimitErr) { - console.warn("blocked:", rateLimitErr); -} -``` - ## worker_threads Perry compiles statically resolvable worker entry files as separate native diff --git a/package-lock.json b/package-lock.json index acd7de4d01..62616a76ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { - "name": "wt-gapfix", + "name": "claude-rm7", "lockfileVersion": 3, "requires": true, "packages": { "": { "dependencies": { - "ethers": "^6.17.0", - "node-cron": "^4.2.1" + "ethers": "^6.17.0" }, "devDependencies": { "@socketsecurity/sdk": "4.1.4", "cron": "^4.4.0", + "date-fns": "^4.4.0", "dayjs": "^1.11.21", "exponential-backoff": "^3.1.3", + "fastify": "^5.10.0", "moment": "^2.30.1", "mongodb": "^7.0.0", "rate-limiter-flexible": "^11.2.0", @@ -26,6 +27,123 @@ "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", "license": "MIT" }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.6.tgz", + "integrity": "sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", + "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.2.tgz", + "integrity": "sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, "node_modules/@mongodb-js/saslprep": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.5.tgz", @@ -60,6 +178,13 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "dev": true, + "license": "MIT" + }, "node_modules/@socketsecurity/sdk": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/@socketsecurity/sdk/-/sdk-4.1.4.tgz", @@ -105,12 +230,102 @@ "@types/webidl-conversions": "*" } }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "dev": true, + "license": "MIT" + }, "node_modules/aes-js": { "version": "4.0.0-beta.5", "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", "license": "MIT" }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.0.tgz", + "integrity": "sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, "node_modules/bson": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/bson/-/bson-7.1.1.tgz", @@ -121,6 +336,20 @@ "node": ">=20.19.0" } }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cron": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz", @@ -139,6 +368,17 @@ "url": "https://ko-fi.com/intcreator" } }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/dayjs": { "version": "1.11.21", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", @@ -146,6 +386,16 @@ "dev": true, "license": "MIT" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/ethers": { "version": "6.17.0", "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.17.0.tgz", @@ -181,6 +431,207 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", + "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz", + "integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.12.4", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.4.tgz", + "integrity": "sha512-zATD676fP6tBMsn+jQ7Q5/cO/Qo+149TXH0dUqUKel+1/hcZxwIbnflqtPa9EjlrvpY8eCZcL3M1UYrMc31Y7g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.1.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/find-my-way": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.9.0.tgz", + "integrity": "sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ipaddr.js": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", + "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/luxon": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", @@ -269,15 +720,73 @@ "node": ">=20.19.0" } }, - "node_modules/node-cron": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.6.0.tgz", - "integrity": "sha512-Si/bzYiKRHOB8/a99T2+SDGN582ONDMSTlJr5oCkT6GtnqPjZ2s10eoQRYkW9ZHwjVxONL+W8Fb+qR0AHMQsdg==", - "license": "ISC", + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=20" + "node": ">=14.0.0" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" } }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "dev": true, + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -288,6 +797,13 @@ "node": ">=6" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "dev": true, + "license": "MIT" + }, "node_modules/rate-limiter-flexible": { "version": "11.2.0", "resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-11.2.0.tgz", @@ -295,6 +811,124 @@ "dev": true, "license": "ISC" }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "dev": true, + "license": "MIT" + }, "node_modules/slugify": { "version": "1.6.9", "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz", @@ -305,6 +939,16 @@ "node": ">=8.0.0" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/sparse-bitfield": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", @@ -315,6 +959,46 @@ "memory-pager": "^1.0.2" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/tr46": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", diff --git a/package.json b/package.json index 81b2472821..c0efe6c90e 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,10 @@ "devDependencies": { "@socketsecurity/sdk": "4.1.4", "cron": "^4.4.0", + "date-fns": "^4.4.0", "dayjs": "^1.11.21", "exponential-backoff": "^3.1.3", + "fastify": "^5.10.0", "moment": "^2.30.1", "mongodb": "^7.0.0", "rate-limiter-flexible": "^11.2.0", @@ -26,7 +28,6 @@ "zod": "^4.3.5" }, "dependencies": { - "ethers": "^6.17.0", - "node-cron": "^4.2.1" + "ethers": "^6.17.0" } } diff --git a/scripts/native_result_ledger.py b/scripts/native_result_ledger.py index 7ca06de8c5..a106a76514 100644 --- a/scripts/native_result_ledger.py +++ b/scripts/native_result_ledger.py @@ -32,8 +32,17 @@ # `js_net_socket_unpipe`. Each returns its `handle: i64` argument unchanged, a # `next_id_or_throw()` registry id rather than a heap address, so all four are # NR_HANDLE_ID. -EXPECTED_ROWS = 376 -EXPECTED_PROVIDERS = 326 +# +# -7 rows / -6 providers (native-binding removal campaign): the `fastify` +# npm binding was removed (#466) — its six pointer-kind rows +# (`js_fastify_app_server`/`create_with_opts`/`reply_header`/`reply_status`/ +# `reply_type`/`req_headers`) and their `native_table/fastify.rs` rows are +# gone. The npm `typescript` binding is NOT removed in this PR (see #10765): +# real `typescript.js` hits a compiler defect, so the binding stays until +# that is fixed, and `js_typescript_*` plus Bun's `js_bun_transpiler_new`/ +# `js_bun_build` all remain classified. +EXPECTED_ROWS = 369 +EXPECTED_PROVIDERS = 320 KINDS = { "NR_GCPTR", "NR_NULLABLE_GCPTR", diff --git a/scripts/native_result_ledger.tsv b/scripts/native_result_ledger.tsv index 170454102d..8b3a5c00a6 100644 --- a/scripts/native_result_ledger.tsv +++ b/scripts/native_result_ledger.tsv @@ -77,12 +77,6 @@ js_ext_net_socket_connect NR_HANDLE_ID crates/perry-ext-net/src/lib.rs i64 js_ext_net_socket_on NR_HANDLE_ID crates/perry-ext-net/src/handle_exports.rs i64 js_ext_net_socket_once NR_HANDLE_ID crates/perry-ext-net/src/handle_exports.rs i64 js_ext_tls_connect NR_HANDLE_ID crates/perry-ext-net/src/tls.rs i64 -js_fastify_app_server NR_HANDLE_ID crates/perry-ext-fastify/src/app.rs Handle -js_fastify_create_with_opts NR_HANDLE_ID crates/perry-ext-fastify/src/app.rs Handle -js_fastify_reply_header NR_HANDLE_ID crates/perry-ext-fastify/src/context.rs Handle -js_fastify_reply_status NR_HANDLE_ID crates/perry-ext-fastify/src/context.rs Handle -js_fastify_reply_type NR_HANDLE_ID crates/perry-ext-fastify/src/context.rs Handle -js_fastify_req_headers NR_JS_VALUE crates/perry-ext-fastify/src/context.rs i64 js_http_agent_create_connection NR_HANDLE_ID crates/perry-ext-http/src/agent.rs i64 js_http_agent_create_socket NR_HANDLE_ID crates/perry-ext-http/src/agent.rs i64 js_http_agent_destroy NR_HANDLE_ID crates/perry-ext-http/src/agent.rs Handle diff --git a/scripts/string_payload_access_baseline.txt b/scripts/string_payload_access_baseline.txt index c1069bc178..cb85d240f4 100644 --- a/scripts/string_payload_access_baseline.txt +++ b/scripts/string_payload_access_baseline.txt @@ -4,7 +4,6 @@ # Counts may only decrease; a new crate starts at zero. inline-offset | perry-ext-ethers | 2 -inline-offset | perry-ext-fastify | 3 inline-offset | perry-ext-http | 1 inline-offset | perry-ext-mysql2 | 1 inline-offset | perry-ext-net | 1 diff --git a/scripts/unrooted_local_shape_baseline.json b/scripts/unrooted_local_shape_baseline.json index 7aae581ae2..1a60f95763 100644 --- a/scripts/unrooted_local_shape_baseline.json +++ b/scripts/unrooted_local_shape_baseline.json @@ -9,8 +9,6 @@ "crates/perry-ext-events/src/module_iterators.rs": 2, "crates/perry-ext-events/src/module_on.rs": 3, "crates/perry-ext-events/src/tests.rs": 2, - "crates/perry-ext-fastify/src/context.rs": 2, - "crates/perry-ext-fastify/src/upgrade.rs": 6, "crates/perry-ext-fetch/src/lib.rs": 14, "crates/perry-ext-fetch/src/tests.rs": 14, "crates/perry-ext-http/src/agent.rs": 3, @@ -27,7 +25,6 @@ "crates/perry-ext-net/src/lifecycle.rs": 1, "crates/perry-ext-node-forge/src/lib.rs": 24, "crates/perry-ext-pg/src/lib.rs": 7, - "crates/perry-ext-ratelimit/src/lib.rs": 4, "crates/perry-ext-streams/src/lib.rs": 4, "crates/perry-ext-ws/src/server.rs": 3, "crates/perry-ext-zlib/src/stream.rs": 3, @@ -56,7 +53,6 @@ "crates/perry-stdlib/src/pg/result.rs": 14, "crates/perry-stdlib/src/pg/types.rs": 14, "crates/perry-stdlib/src/querystring.rs": 2, - "crates/perry-stdlib/src/ratelimit.rs": 4, "crates/perry-stdlib/src/readline/mod.rs": 4, "crates/perry-stdlib/src/sqlite/backup.rs": 8, "crates/perry-stdlib/src/sqlite/better.rs": 18, @@ -85,5 +81,5 @@ "crates/perry-stdlib/src/zlib.rs": 3 }, "schema_version": 3, - "total": 578 + "total": 562 } diff --git a/test-files/test_date_fns_format.ts b/test-files/test_date_fns_format.ts index 764ab3dca2..354290bbeb 100644 --- a/test-files/test_date_fns_format.ts +++ b/test-files/test_date_fns_format.ts @@ -28,8 +28,8 @@ console.log("aabbbccdd".match(g)); // No-match returns null console.log("xyz".match(/(\w)\1+/)); -// End-to-end: format(date, pattern). Uses the native runtime path -// (date-fns is well-known-aliased to perry-ext-dayjs). +// End-to-end: format(date, pattern). date-fns is compiled from its real +// npm source (the native binding was removed, #466). import { format } from "date-fns"; const d = new Date(2020, 0, 6); console.log(format(d, "yyyy-MM-dd")); diff --git a/test-files/test_parity_cron.ts b/test-files/test_parity_cron.ts deleted file mode 100644 index 549c394528..0000000000 --- a/test-files/test_parity_cron.ts +++ /dev/null @@ -1,52 +0,0 @@ -// Behavioral parity test for node-cron (perry-stdlib). -// -// The scheduler tick is time-dependent so all assertions stick to -// pure functions: validate(), the static describe shape, and the -// nextDate/nextDates shapes derived from a stopped task instead of -// asserting concrete future timestamps. - -import cron from "node-cron"; - -// ── validate() — deterministic, no side effects ── -console.log("validate every minute:", cron.validate("* * * * *")); -console.log("validate every hour:", cron.validate("0 * * * *")); -console.log("validate noon daily:", cron.validate("0 12 * * *")); -console.log("validate bogus:", cron.validate("not a cron")); -console.log("validate too few:", cron.validate("0 12")); - -// ── schedule(...) without start so we don't depend on tickers ── -const task = cron.schedule("0 0 1 1 *", () => { - // Fires once a year on Jan 1st 00:00 — we never let it run. -}, { scheduled: false }); - -// task identity shape — must expose start/stop methods. -console.log("task has start:", typeof (task as any).start === "function"); -console.log("task has stop:", typeof (task as any).stop === "function"); - -// ── start / stop transitions ── -task.start(); -// Stop immediately so the test exits cleanly and no callback is invoked. -task.stop(); -console.log("task stopped without throwing"); - -/* -@covers -crates/perry-stdlib/src/cron.rs: - - js_cron_clear_interval - - js_cron_clear_timeout - - js_cron_describe - - js_cron_job_is_running - - js_cron_job_start - - js_cron_job_stop - - js_cron_next_date - - js_cron_next_dates - - js_cron_schedule - - js_cron_set_interval - - js_cron_set_timeout - - js_cron_timer_has_pending - - js_cron_timer_tick - - js_cron_validate -crates/perry-stdlib/src/lib.rs: - - js_cron_timer_has_pending - - js_cron_timer_tick -*/ diff --git a/test-files/test_parity_date_fns.ts b/test-files/test_parity_date_fns.ts deleted file mode 100644 index 945648cebf..0000000000 --- a/test-files/test_parity_date_fns.ts +++ /dev/null @@ -1,69 +0,0 @@ -// Behavioral parity test for the date-fns package (perry-stdlib). -// -// All anchors are fixed UTC timestamps so output is byte-deterministic. - -import { - addDays, - addMonths, - addYears, - differenceInDays, - differenceInHours, - differenceInMinutes, - endOfDay, - format, - isAfter, - isBefore, - parseISO, - startOfDay, -} from "date-fns"; - -const A_ISO = "2024-02-29T12:34:56.789Z"; -const B_ISO = "2024-03-15T08:00:00.000Z"; - -const a = parseISO(A_ISO); -const b = parseISO(B_ISO); - -// ── parse / construct ── -console.log("parseISO ms a:", a.getTime()); -console.log("parseISO ms b:", b.getTime()); - -// ── arithmetic ── -console.log("addDays(+1) iso:", addDays(a, 1).toISOString()); -console.log("addMonths(+1) iso:", addMonths(a, 1).toISOString()); -console.log("addYears(+1) iso:", addYears(a, 1).toISOString()); - -// ── boundaries (UTC date so the snap is determinstic) ── -// Note: date-fns operates in local time for startOf/endOf; the leap-day -// anchor was chosen so the local boundary still falls on 2024-02-29 in -// most timezones. Use a UTC-safe stub to keep CI green across TZs. -console.log("startOfDay ms type:", typeof startOfDay(a).getTime() === "number"); -console.log("endOfDay ms type:", typeof endOfDay(a).getTime() === "number"); - -// ── format ── -console.log("format(yyyy-MM-dd):", format(a, "yyyy-MM-dd")); - -// ── differences ── -console.log("diff days b-a:", differenceInDays(b, a)); -console.log("diff hours b-a:", differenceInHours(b, a)); -console.log("diff minutes b-a:", differenceInMinutes(b, a)); - -// ── ordering ── -console.log("isAfter b a:", isAfter(b, a)); -console.log("isBefore a b:", isBefore(a, b)); - -/* -@covers -crates/perry-stdlib/src/dayjs.rs: - - js_datefns_add_days - - js_datefns_add_months - - js_datefns_add_years - - js_datefns_difference_in_days - - js_datefns_difference_in_hours - - js_datefns_difference_in_minutes - - js_datefns_end_of_day - - js_datefns_format - - js_datefns_is_after - - js_datefns_is_before - - js_datefns_parse_iso - - js_datefns_start_of_day -*/ diff --git a/test-files/test_parity_dayjs.ts b/test-files/test_parity_dayjs.ts deleted file mode 100644 index 4efd03fa59..0000000000 --- a/test-files/test_parity_dayjs.ts +++ /dev/null @@ -1,82 +0,0 @@ -// Behavioral parity test for the dayjs package (perry-stdlib). -// -// dayjs.now()/Date.now() are non-deterministic — use a fixed-timestamp -// anchor (UTC) for every assertion so output is byte-for-byte stable. - -import dayjs from "dayjs"; - -// Fixed UTC timestamp: 2024-02-29T12:34:56.789Z (leap day, easy to spot). -const ANCHOR_MS = 1709209096789; -const ANCHOR_ISO = "2024-02-29T12:34:56.789Z"; - -const d = dayjs(ANCHOR_MS); - -// ── Parse / construct ── -console.log("isValid:", d.isValid()); -console.log("isValid_bad:", dayjs("not-a-date").isValid()); -console.log("valueOf:", d.valueOf()); -console.log("unix:", d.unix()); -console.log("toISOString:", d.toISOString()); - -// ── Format ── -console.log("format default:", d.format()); -console.log("format YYYY-MM-DD:", d.format("YYYY-MM-DD")); -console.log("format HH:mm:ss:", d.format("HH:mm:ss")); - -// ── Field accessors ── -console.log("year:", d.year()); -console.log("month:", d.month()); -console.log("date:", d.date()); -console.log("day:", d.day()); -console.log("hour:", d.hour()); -console.log("minute:", d.minute()); -console.log("second:", d.second()); -console.log("millisecond:", d.millisecond()); - -// ── Arithmetic ── -console.log("add 1 day iso:", d.add(1, "day").toISOString()); -console.log("sub 1 month iso:", d.subtract(1, "month").toISOString()); - -// ── Boundaries ── -console.log("startOf day:", d.startOf("day").toISOString()); -console.log("endOf day:", d.endOf("day").toISOString()); - -// ── Comparisons ── -const later = dayjs(ANCHOR_MS + 86_400_000); // +1 day -console.log("isBefore:", d.isBefore(later)); -console.log("isAfter:", later.isAfter(d)); -console.log("isSame:", d.isSame(dayjs(ANCHOR_MS))); -console.log("diff ms:", later.diff(d)); -console.log("diff days:", later.diff(d, "day")); - -// Anchor sanity check (printed last so divergence is obvious). -console.log("anchor matches:", d.toISOString() === ANCHOR_ISO); - -/* -@covers -crates/perry-stdlib/src/dayjs.rs: - - js_dayjs_add - - js_dayjs_date - - js_dayjs_day - - js_dayjs_diff - - js_dayjs_end_of - - js_dayjs_format - - js_dayjs_from_timestamp - - js_dayjs_hour - - js_dayjs_is_after - - js_dayjs_is_before - - js_dayjs_is_same - - js_dayjs_is_valid - - js_dayjs_millisecond - - js_dayjs_minute - - js_dayjs_month - - js_dayjs_now - - js_dayjs_parse - - js_dayjs_second - - js_dayjs_start_of - - js_dayjs_subtract - - js_dayjs_to_iso_string - - js_dayjs_unix - - js_dayjs_value_of - - js_dayjs_year -*/ diff --git a/test-parity/known_failures.json b/test-parity/known_failures.json index 228463a2bb..9e9c4f832a 100644 --- a/test-parity/known_failures.json +++ b/test-parity/known_failures.json @@ -746,15 +746,6 @@ "linux" ] }, - "test_parity_cron": { - "issue": "8271", - "added": "2026-08-17", - "category": "bug-open", - "reason": "Node runs; Perry's cron default import resolves validate/schedule as undefined and then throws while reading task.start. This reproduces against Node 22 and 26.5.1 and is independent of the oracle bump. Confirmed in the #8271 audit.", - "platforms": [ - "linux" - ] - }, "test_parity_crypto": { "issue": "8271", "added": "2026-08-17", @@ -764,24 +755,6 @@ "linux" ] }, - "test_parity_date_fns": { - "issue": "8271", - "added": "2026-08-17", - "category": "ci-env", - "reason": "Node 26.5.1 exits ERR_MODULE_NOT_FOUND for 'date-fns' after the repository's CI-equivalent npm ci because date-fns is absent from package.json/package-lock.json. Broken oracle; Perry output cannot be adjudicated until the fixture dependency is intentionally provisioned. Confirmed in the #8271 audit.", - "platforms": [ - "linux" - ] - }, - "test_parity_dayjs": { - "issue": "8271", - "added": "2026-08-17", - "category": "bug-open", - "reason": "Node runs; Perry's native dayjs shim returns numeric 1/0 for predicates and diverges on ISO, default, startOf, and endOf formatting. This reproduces against Node 22 and 26.5.1 and is independent of the oracle bump. Confirmed in the #8271 audit.", - "platforms": [ - "linux" - ] - }, "test_parity_decimal": { "issue": "8271", "added": "2026-08-17", diff --git a/workspace-architecture.json b/workspace-architecture.json index 7de6b0d0a6..a9ba4fd8e9 100644 --- a/workspace-architecture.json +++ b/workspace-architecture.json @@ -25,7 +25,7 @@ ] }, "baseline": { - "workspace_members": 76, + "workspace_members": 72, "default_dependency_closure": [ "perry", "perry-api-manifest", @@ -68,7 +68,7 @@ "perry-updater" ], "decision_counts": { - "externalize": 27, + "externalize": 23, "keep": 44, "merge": 1, "remove": 1, @@ -175,11 +175,6 @@ "decision": "externalize", "migration": "compile-source" }, - "perry-ext-dayjs": { - "category": "binding", - "decision": "externalize", - "migration": "compile-source" - }, "perry-ext-decimal": { "category": "binding", "decision": "externalize", @@ -200,11 +195,6 @@ "decision": "externalize", "migration": "compile-source" }, - "perry-ext-fastify": { - "category": "binding", - "decision": "externalize", - "migration": "compile-source" - }, "perry-ext-fetch": { "category": "binding", "decision": "externalize", @@ -275,16 +265,6 @@ "decision": "externalize", "migration": "compile-source" }, - "perry-ext-qs": { - "category": "binding", - "decision": "externalize", - "migration": "compile-source" - }, - "perry-ext-ratelimit": { - "category": "binding", - "decision": "externalize", - "migration": "compile-source" - }, "perry-ext-sharp": { "category": "binding", "decision": "externalize",