From f9193fe481fbd18c400d7aec1c1981e837c7c6f4 Mon Sep 17 00:00:00 2001 From: ubugeeei Date: Mon, 18 May 2026 14:57:13 +0900 Subject: [PATCH 1/2] chore: finish production readiness issues --- .github/workflows/ci.yml | 30 ++++- .github/workflows/release.yml | 200 ++++++++++++++++++++++++++++ Cargo.lock | 4 +- Cargo.toml | 4 + RELEASE.md | 58 +++++++- crates/fastsse/Cargo.toml | 6 + crates/fastsse_node/Cargo.toml | 8 +- crates/fastsse_wasm/Cargo.toml | 8 +- crates/fastsse_wasm/src/lib.rs | 32 ++++- deny.toml | 23 ++++ flake.nix | 19 ++- npm/fastsse_node/README.md | 31 +++++ npm/fastsse_node/index.mjs | 11 ++ npm/fastsse_node/package.json | 36 ++++- package.json | 8 ++ scripts/audit-npm-licenses.mjs | 31 +++++ scripts/crate-version-published.mjs | 23 ++++ scripts/node-package-pack-check.mjs | 56 ++++++++ scripts/node-package-smoke.mjs | 120 +++++++++++++++++ scripts/package-crates.mjs | 22 +++ scripts/release.mjs | 46 ++++++- scripts/sync-wasm-package.mjs | 44 ++++++ scripts/verify-release-tag.mjs | 37 +++++ scripts/wasm-smoke.mjs | 91 +++++++++++++ 24 files changed, 924 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 deny.toml create mode 100644 npm/fastsse_node/README.md create mode 100644 npm/fastsse_node/index.mjs create mode 100644 scripts/audit-npm-licenses.mjs create mode 100644 scripts/crate-version-published.mjs create mode 100644 scripts/node-package-pack-check.mjs create mode 100644 scripts/node-package-smoke.mjs create mode 100644 scripts/package-crates.mjs create mode 100644 scripts/sync-wasm-package.mjs create mode 100644 scripts/verify-release-tag.mjs create mode 100644 scripts/wasm-smoke.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5aec364..3a4b613 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,11 +31,13 @@ jobs: - name: format node: true cargo: false + nix_shell: .#default command: pnpm run fmt -- --check - name: rust lint node: false cargo: true + nix_shell: .#default command: | cargo clippy --locked -p fastsse -p fastsse-node --all-targets -- -D warnings cargo clippy --locked -p fastsse-wasm --target wasm32-unknown-unknown -- -D warnings @@ -43,25 +45,50 @@ jobs: - name: rust check node: false cargo: true + nix_shell: .#default command: | cargo check --locked -p fastsse -p fastsse-node --all-targets cargo check --locked -p fastsse-wasm --target wasm32-unknown-unknown + - name: rust msrv + node: false + cargo: true + nix_shell: .#msrv + command: | + echo "Checking MSRV from workspace.package.rust-version; update Cargo.toml and flake.nix together if this fails." + cargo check --locked -p fastsse -p fastsse-node --all-targets + cargo check --locked -p fastsse-wasm --target wasm32-unknown-unknown + - name: rust test node: false cargo: true + nix_shell: .#default command: | cargo test --locked -p fastsse cargo test --locked -p fastsse-node + - name: supply-chain audit + node: true + cargo: true + nix_shell: .#default + command: pnpm run audit:supply-chain + + - name: package dry-run + node: true + cargo: true + nix_shell: .#default + command: pnpm run package:dry-run + - name: node package node: true cargo: true + nix_shell: .#default command: pnpm run build:node - name: wasm package node: true cargo: true + nix_shell: .#default command: pnpm run build:wasm steps: - name: Checkout @@ -85,5 +112,6 @@ jobs: - name: Run ${{ matrix.name }} env: CHECK_COMMAND: ${{ matrix.command }} + NIX_SHELL: ${{ matrix.nix_shell }} run: | - nix develop --command bash -e -u -o pipefail -c "$CHECK_COMMAND" + nix develop "$NIX_SHELL" --command bash -e -u -o pipefail -c "$CHECK_COMMAND" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b809c47 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,200 @@ +name: release + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: "1" + +jobs: + verify: + name: release / verify packages + runs-on: ubuntu-24.04 + timeout-minutes: 45 + permissions: + attestations: write + contents: read + id-token: write + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22 + + - name: Install npm dependencies + run: | + nix develop .#default --command pnpm install --frozen-lockfile + + - name: Fetch Rust dependencies + run: | + nix develop .#default --command cargo fetch --locked + + - name: Verify release tag + run: | + nix develop .#default --command node scripts/verify-release-tag.mjs + + - name: Run release gates + run: | + nix develop .#default --command bash -e -u -o pipefail -c ' + pnpm run audit:supply-chain + pnpm run package:dry-run + ' + + - name: Build release artifacts + run: | + nix develop .#default --command bash -e -u -o pipefail -c ' + mkdir -p dist/npm + pnpm run package:crates + pnpm run build:node + pnpm run build:wasm + node scripts/sync-wasm-package.mjs + npm pack ./npm/fastsse_node --pack-destination dist/npm + npm pack ./npm/fastsse_wasm --pack-destination dist/npm + ' + + - name: Upload release artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-packages + if-no-files-found: error + path: | + dist/npm/*.tgz + target/package/*.crate + + - name: Attest release artifacts + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-path: | + dist/npm/*.tgz + target/package/*.crate + + publish-crates: + name: release / publish crates.io + runs-on: ubuntu-24.04 + timeout-minutes: 45 + needs: verify + environment: crates-io + permissions: + contents: read + id-token: write + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22 + + - name: Fetch Rust dependencies + run: | + nix develop .#default --command cargo fetch --locked + + - name: Authenticate to crates.io + id: crates-io + uses: rust-lang/crates-io-auth-action@bbd81622f20ce9e2dd9622e3218b975523e45bbe # v1.0.4 + + - name: Publish crates + env: + CARGO_REGISTRY_TOKEN: ${{ steps.crates-io.outputs.token }} + run: | + nix develop .#default --command bash -e -u -o pipefail <<'BASH' + version=$(node -e "const fs=require(\"node:fs\"); const toml=fs.readFileSync(\"Cargo.toml\", \"utf8\"); console.log(toml.match(/^version = \"([^\"]+)\"$/m)[1]);") + + crate_is_published() { + node scripts/crate-version-published.mjs "$1" "$version" + } + + wait_for_crate() { + crate="$1" + for attempt in $(seq 1 30); do + if crate_is_published "$crate"; then + echo "$crate@$version is visible on crates.io" + return 0 + fi + echo "waiting for $crate@$version to appear on crates.io ($attempt/30)" + sleep 20 + done + echo "$crate@$version did not appear on crates.io in time" >&2 + return 1 + } + + publish_crate() { + crate="$1" + if crate_is_published "$crate"; then + echo "$crate@$version is already published; skipping" + else + cargo publish --locked -p "$crate" + fi + wait_for_crate "$crate" + } + + publish_crate fastsse + publish_crate fastsse-node + publish_crate fastsse-wasm + BASH + + publish-npm: + name: release / publish npm + runs-on: ubuntu-24.04 + timeout-minutes: 30 + needs: verify + environment: npm + permissions: + contents: read + id-token: write + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22 + + - name: Install npm dependencies + run: | + nix develop .#default --command pnpm install --frozen-lockfile + + - name: Build npm packages + run: | + nix develop .#default --command bash -e -u -o pipefail -c ' + pnpm run build:node + pnpm run build:wasm + node scripts/sync-wasm-package.mjs + ' + + - name: Publish npm packages + env: + NPM_CONFIG_ACCESS: public + NPM_CONFIG_PROVENANCE: "true" + run: | + nix develop .#default --command bash -e -u -o pipefail <<'BASH' + publish_package() { + name="$1" + dir="$2" + version=$(node -e "console.log(JSON.parse(require(\"node:fs\").readFileSync(\"$dir/package.json\", \"utf8\")).version)") + + if npm view "$name@$version" version >/dev/null 2>&1; then + echo "$name@$version is already published; skipping" + else + npm publish "$dir" --access public --provenance + fi + } + + publish_package @matesinc/fastsse-node ./npm/fastsse_node + publish_package @matesinc/fastsse-wasm ./npm/fastsse_wasm + BASH diff --git a/Cargo.lock b/Cargo.lock index e178af7..70956d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -940,9 +940,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.1" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da36089a805484bcccfffe0739803392c8298778a2d2f09febf76fac5ad9025b" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-xid" diff --git a/Cargo.toml b/Cargo.toml index 5704985..7afe17b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,10 @@ resolver = "3" [workspace.package] authors = ["Mates Inc."] +categories = ["encoding", "parser-implementations", "web-programming"] edition = "2024" +homepage = "https://github.com/matesedu/fastsse" +keywords = ["eventsource", "parser", "server-sent-events", "sse", "streaming"] license = "GPL-3.0-or-later" repository = "https://github.com/matesedu/fastsse" rust-version = "1.88" @@ -14,6 +17,7 @@ version = "0.1.0" [workspace.dependencies] compact_str = "0.9.0" criterion = "0.8.2" +fastsse = { path = "crates/fastsse", version = "=0.1.0" } insta = "1.46.3" js-sys = "0.3.91" memchr = "2.8.0" diff --git a/RELEASE.md b/RELEASE.md index 2338a26..a039d12 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -33,13 +33,14 @@ vp run check vp run test vp run build:node vp run build:wasm +vp run audit:supply-chain +vp run package:dry-run ``` Inspect package contents before publishing: ```bash -pnpm --dir npm/fastsse_node pack --dry-run -pnpm --dir npm/fastsse_wasm pack --dry-run +pnpm run package:dry-run ``` Create the release bump and annotated tag: @@ -49,3 +50,56 @@ vp run release:patch ``` Push the release commit and tag together after review. + +## Supply-chain Policy + +Release checks run both advisory and license gates: + +```bash +pnpm run audit:supply-chain +``` + +This runs `cargo deny check`, `pnpm audit --audit-level moderate`, and the npm +license allowlist in `scripts/audit-npm-licenses.mjs`. + +Maintain exceptions deliberately: + +- Rust advisory exceptions belong in `deny.toml` under `[advisories].ignore`. + Include the advisory ID, a short comment in the reviewing PR, and a follow-up + issue for removal. +- Rust license exceptions belong in `deny.toml` under `[licenses].exceptions` + only after confirming the package, version range, and redistribution impact. +- npm license exceptions belong in `scripts/audit-npm-licenses.mjs` only after + maintainers confirm the license terms are compatible with publishing public + npm artifacts. + +## MSRV + +The minimum supported Rust version is declared in `Cargo.toml` and pinned in the +`msrv` Nix shell. CI keeps latest-stable checks and also runs: + +```bash +nix develop .#msrv --command cargo check --locked -p fastsse -p fastsse-node --all-targets +nix develop .#msrv --command cargo check --locked -p fastsse-wasm --target wasm32-unknown-unknown +``` + +When bumping MSRV, update `workspace.package.rust-version`, `rust-toolchain.toml`, +and `flake.nix` in the same change. + +## Trusted Publishing + +Tags matching `v*.*.*` run `.github/workflows/release.yml`. + +Before the first trusted release, publish the initial crate/package versions +manually if the registry requires bootstrapping, then configure trusted +publishers for: + +- crates.io: `fastsse`, `fastsse-node`, and `fastsse-wasm`, restricted to the + `release.yml` workflow and the `crates-io` environment. +- npm: `@matesinc/fastsse-node` and `@matesinc/fastsse-wasm`, restricted to the + `release.yml` workflow and the `npm` environment. + +The release workflow uses GitHub OIDC instead of long-lived publish tokens, +performs crate and npm package dry-runs before publishing, uploads package +artifacts, and generates GitHub build provenance attestations for the `.crate` +and `.tgz` files. diff --git a/crates/fastsse/Cargo.toml b/crates/fastsse/Cargo.toml index 27e33ef..052a1f7 100644 --- a/crates/fastsse/Cargo.toml +++ b/crates/fastsse/Cargo.toml @@ -1,8 +1,14 @@ [package] name = "fastsse" description = "Low-allocation Server-Sent Events encoder and incremental decoder." +authors.workspace = true +categories.workspace = true +documentation = "https://docs.rs/fastsse" edition.workspace = true +homepage.workspace = true +keywords.workspace = true license.workspace = true +readme = "../../README.md" repository.workspace = true rust-version.workspace = true version.workspace = true diff --git a/crates/fastsse_node/Cargo.toml b/crates/fastsse_node/Cargo.toml index f7e0779..7f634e9 100644 --- a/crates/fastsse_node/Cargo.toml +++ b/crates/fastsse_node/Cargo.toml @@ -1,9 +1,15 @@ [package] name = "fastsse-node" description = "Node.js bindings for fastsse built with napi-rs." +authors.workspace = true build = "build.rs" +categories.workspace = true +documentation = "https://docs.rs/fastsse-node" edition.workspace = true +homepage.workspace = true +keywords.workspace = true license.workspace = true +readme = "../../README.md" repository.workspace = true rust-version.workspace = true version.workspace = true @@ -12,7 +18,7 @@ version.workspace = true crate-type = ["cdylib"] [dependencies] -fastsse = { path = "../fastsse" } +fastsse.workspace = true napi.workspace = true napi-derive.workspace = true diff --git a/crates/fastsse_wasm/Cargo.toml b/crates/fastsse_wasm/Cargo.toml index 622ee35..c9e8055 100644 --- a/crates/fastsse_wasm/Cargo.toml +++ b/crates/fastsse_wasm/Cargo.toml @@ -1,8 +1,14 @@ [package] name = "fastsse-wasm" description = "Browser bindings for fastsse built with wasm-bindgen." +authors.workspace = true +categories.workspace = true +documentation = "https://docs.rs/fastsse-wasm" edition.workspace = true +homepage.workspace = true +keywords.workspace = true license.workspace = true +readme = "../../README.md" repository.workspace = true rust-version.workspace = true version.workspace = true @@ -11,7 +17,7 @@ version.workspace = true crate-type = ["cdylib", "rlib"] [dependencies] -fastsse = { path = "../fastsse" } +fastsse.workspace = true js-sys.workspace = true wasm-bindgen.workspace = true diff --git a/crates/fastsse_wasm/src/lib.rs b/crates/fastsse_wasm/src/lib.rs index fb53007..1e242b5 100644 --- a/crates/fastsse_wasm/src/lib.rs +++ b/crates/fastsse_wasm/src/lib.rs @@ -8,6 +8,30 @@ use wasm_bindgen::prelude::*; const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0; +#[wasm_bindgen(typescript_custom_section)] +const TYPESCRIPT_DEFINITIONS: &str = r#" +export interface EncodeEventInput { + data: string; + event?: string | null; + id?: string | null; + retry?: number | null; +} + +export interface DecodedEvent { + kind: "event"; + event: string; + data: string; + id: string; +} + +export interface DecodedRetry { + kind: "retry"; + retry: number; +} + +export type DecodedItem = DecodedEvent | DecodedRetry; +"#; + #[wasm_bindgen(js_name = Decoder)] pub struct JsDecoder { inner: Decoder, @@ -30,7 +54,7 @@ impl JsDecoder { } } - #[wasm_bindgen] + #[wasm_bindgen(unchecked_return_type = "DecodedItem[]")] pub fn push(&mut self, chunk: Uint8Array) -> Result { let len = chunk.length() as usize; self.scratch.resize(len, 0); @@ -45,7 +69,7 @@ impl JsDecoder { Ok(items) } - #[wasm_bindgen(js_name = "pushString")] + #[wasm_bindgen(js_name = "pushString", unchecked_return_type = "DecodedItem[]")] pub fn push_string(&mut self, chunk: &str) -> Result { let items = Array::new(); self @@ -79,7 +103,9 @@ impl JsDecoder { } #[wasm_bindgen(js_name = "encodeEvent")] -pub fn encode_event_js(event: JsValue) -> Result { +pub fn encode_event_js( + #[wasm_bindgen(unchecked_param_type = "EncodeEventInput")] event: JsValue, +) -> Result { let data = get_required_string(&event, "data")?; let event_type = get_optional_string(&event, "event")?; let id = get_optional_string(&event, "id")?; diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..3618b16 --- /dev/null +++ b/deny.toml @@ -0,0 +1,23 @@ +[advisories] +version = 2 +yanked = "deny" +ignore = [] + +[bans] +multiple-versions = "warn" +wildcards = "deny" +highlight = "all" +workspace-default-features = "allow" +external-default-features = "allow" + +[licenses] +version = 2 +confidence-threshold = 0.8 +allow = ["Apache-2.0", "GPL-3.0-or-later", "ISC", "MIT", "Unicode-3.0", "Unlicense"] +exceptions = [] + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +allow-git = [] diff --git a/flake.nix b/flake.nix index 2b44b0e..28aae4f 100644 --- a/flake.nix +++ b/flake.nix @@ -26,15 +26,18 @@ { devShells = forAllSystems ({ pkgs }: let - rust = pkgs.rust-bin.stable.latest.default.override { - extensions = ["clippy" "rustfmt"]; - targets = ["wasm32-unknown-unknown"]; + rustExtensions = ["clippy" "rustfmt"]; + rustTargets = ["wasm32-unknown-unknown"]; + mkRust = channel: channel.default.override { + extensions = rustExtensions; + targets = rustTargets; }; - in - { - default = pkgs.mkShell { + rustLatest = mkRust pkgs.rust-bin.stable.latest; + rustMsrv = mkRust pkgs.rust-bin.stable."1.88.0"; + mkShell = rust: pkgs.mkShell { packages = with pkgs; [ binaryen + cargo-deny nodejs_24 pnpm rust @@ -45,6 +48,10 @@ export PATH="$PWD/node_modules/.bin:$PATH" ''; }; + in + { + default = mkShell rustLatest; + msrv = mkShell rustMsrv; }); formatter = forAllSystems ({ pkgs }: pkgs.nixpkgs-fmt); diff --git a/npm/fastsse_node/README.md b/npm/fastsse_node/README.md new file mode 100644 index 0000000..7303438 --- /dev/null +++ b/npm/fastsse_node/README.md @@ -0,0 +1,31 @@ +# @matesinc/fastsse-node + +Node.js bindings for the `fastsse` Server-Sent Events codec. + +This package is intentionally publishable as a public scoped npm package. It +ships a prebuilt N-API addon in `fastsse.node`; it does not compile from source +during consumer installs and does not require a repository checkout. Release +automation must build the addon for the target platform, run the smoke test, and +run the dry-run pack assertions before publishing that tarball. + +## ESM + +```js +import { Decoder, encodeEvent } from "@matesinc/fastsse-node"; + +const decoder = new Decoder(); +const bytes = encodeEvent({ event: "chat", data: "hello", id: "evt-7" }); + +console.log(decoder.push(bytes)); +``` + +## CommonJS + +```js +const { Decoder, encodeEvent } = require("@matesinc/fastsse-node"); + +const decoder = new Decoder(); +const bytes = encodeEvent({ event: "chat", data: "hello", id: "evt-7" }); + +console.log(decoder.push(bytes)); +``` diff --git a/npm/fastsse_node/index.mjs b/npm/fastsse_node/index.mjs new file mode 100644 index 0000000..a37a8d5 --- /dev/null +++ b/npm/fastsse_node/index.mjs @@ -0,0 +1,11 @@ +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const native = require("./fastsse.node"); + +export const Decoder = native.Decoder; +export const encodeComment = native.encodeComment; +export const encodeEvent = native.encodeEvent; +export const encodeRetry = native.encodeRetry; + +export default native; diff --git a/npm/fastsse_node/package.json b/npm/fastsse_node/package.json index 5fe12e3..e2aa7d1 100644 --- a/npm/fastsse_node/package.json +++ b/npm/fastsse_node/package.json @@ -1,31 +1,55 @@ { "name": "@matesinc/fastsse-node", "version": "0.1.0", - "private": true, + "description": "Node.js bindings for the fastsse Server-Sent Events codec.", "keywords": [ "napi-rs", "rust", "server-sent-events", "sse" ], + "homepage": "https://github.com/matesedu/fastsse#readme", + "bugs": { + "url": "https://github.com/matesedu/fastsse/issues" + }, "license": "GPL-3.0-or-later", "author": "Mates Inc.", "repository": { "type": "git", - "url": "https://github.com/matesedu/fastsse" + "url": "git+https://github.com/matesedu/fastsse.git", + "directory": "npm/fastsse_node" }, "files": [ + "README.md", "fastsse.node", "index.d.ts", - "index.js" + "index.js", + "index.mjs" ], "type": "commonjs", - "main": "index.js", - "types": "index.d.ts", + "main": "./index.js", + "types": "./index.d.ts", + "exports": { + ".": { + "types": "./index.d.ts", + "import": "./index.mjs", + "require": "./index.js", + "default": "./index.mjs" + } + }, + "publishConfig": { + "access": "public", + "provenance": true + }, "scripts": { - "build": "napi build --manifest-path ../../crates/fastsse_node/Cargo.toml --package-json-path package.json --output-dir ." + "build": "napi build --manifest-path ../../crates/fastsse_node/Cargo.toml --package-json-path package.json --output-dir .", + "pack:check": "node ../../scripts/node-package-pack-check.mjs", + "smoke": "node ../../scripts/node-package-smoke.mjs" }, "napi": { "binaryName": "fastsse" + }, + "engines": { + "node": ">=18" } } diff --git a/package.json b/package.json index 0321129..60d5782 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,10 @@ "sse", "wasm" ], + "homepage": "https://github.com/matesedu/fastsse#readme", + "bugs": { + "url": "https://github.com/matesedu/fastsse/issues" + }, "license": "GPL-3.0-or-later", "author": "Mates Inc.", "repository": { @@ -18,6 +22,10 @@ "build": "cargo build --workspace", "build:node": "pnpm --dir npm/fastsse_node run build", "build:wasm": "wasm-pack build crates/fastsse_wasm --target web --out-dir ../../npm/fastsse_wasm --out-name fastsse", + "audit:supply-chain": "cargo deny check && pnpm audit --audit-level moderate && node scripts/audit-npm-licenses.mjs", + "package:crates": "node scripts/package-crates.mjs", + "package:npm": "pnpm run build:node && npm pack --dry-run --json ./npm/fastsse_node && pnpm run build:wasm && node scripts/sync-wasm-package.mjs && npm pack --dry-run --json ./npm/fastsse_wasm", + "package:dry-run": "pnpm run package:crates && pnpm run package:npm", "fmt": "oxfmt .", "check": "cargo check -p fastsse -p fastsse-node --all-targets && cargo check -p fastsse-wasm --target wasm32-unknown-unknown", "lint": "cargo clippy -p fastsse -p fastsse-node --all-targets -- -D warnings && cargo clippy -p fastsse-wasm --target wasm32-unknown-unknown -- -D warnings", diff --git a/scripts/audit-npm-licenses.mjs b/scripts/audit-npm-licenses.mjs new file mode 100644 index 0000000..861a3db --- /dev/null +++ b/scripts/audit-npm-licenses.mjs @@ -0,0 +1,31 @@ +import { execFileSync } from "node:child_process"; + +const allowedLicenses = new Set([ + "Apache-2.0", + "BSD-3-Clause", + "ISC", + "MIT", + "MPL-2.0", + "Python-2.0", +]); + +const raw = execFileSync("pnpm", ["licenses", "list", "--json", "--dev"], { + encoding: "utf8", +}); +const licenses = JSON.parse(raw); +const disallowed = Object.keys(licenses) + .filter((license) => !allowedLicenses.has(license)) + .sort(); + +if (disallowed.length > 0) { + for (const license of disallowed) { + console.error(`disallowed npm license: ${license}`); + for (const pkg of licenses[license]) { + console.error(` - ${pkg.name}@${pkg.versions.join(", ")}`); + } + } + console.error("Update scripts/audit-npm-licenses.mjs only after maintainer review."); + process.exit(1); +} + +console.log(`npm license audit passed for ${Object.keys(licenses).length} license groups`); diff --git a/scripts/crate-version-published.mjs b/scripts/crate-version-published.mjs new file mode 100644 index 0000000..c1f89fa --- /dev/null +++ b/scripts/crate-version-published.mjs @@ -0,0 +1,23 @@ +const [crate, version] = process.argv.slice(2); + +if (!crate || !version) { + console.error("usage: node scripts/crate-version-published.mjs "); + process.exit(2); +} + +const response = await fetch(`https://crates.io/api/v1/crates/${crate}/${version}`, { + headers: { + "User-Agent": "matesedu/fastsse release workflow", + }, +}); + +if (response.status === 200) { + process.exit(0); +} + +if (response.status === 404) { + process.exit(1); +} + +console.error(`crates.io returned ${response.status} for ${crate}@${version}`); +process.exit(2); diff --git a/scripts/node-package-pack-check.mjs b/scripts/node-package-pack-check.mjs new file mode 100644 index 0000000..42583a9 --- /dev/null +++ b/scripts/node-package-pack-check.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))); +const packageDir = join(rootDir, "npm", "fastsse_node"); +const packageJsonPath = join(packageDir, "package.json"); +const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); +const nativeAddonPath = join(packageDir, "fastsse.node"); + +assert.ok( + existsSync(nativeAddonPath), + "fastsse.node is missing; run `pnpm --dir npm/fastsse_node build` before pack checking", +); + +assert.notEqual(packageJson.private, true, "package must be publishable"); +assert.equal(packageJson.publishConfig?.access, "public"); +assert.equal(packageJson.exports?.["."]?.import, "./index.mjs"); +assert.equal(packageJson.exports?.["."]?.require, "./index.js"); +assert.equal(packageJson.exports?.["."]?.types, "./index.d.ts"); + +const output = execFileSync("npm", ["pack", "--dry-run", "--json"], { + cwd: packageDir, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], +}); +const [packument] = JSON.parse(output); + +assert.equal(packument.name, "@matesinc/fastsse-node"); +assert.equal(packument.version, packageJson.version); +assert.equal(packument.entryCount, packument.files.length); + +const actualPaths = packument.files.map((file) => file.path).sort(); +const expectedPaths = [ + "LICENSE", + "README.md", + "fastsse.node", + "index.d.ts", + "index.js", + "index.mjs", + "package.json", +].sort(); + +assert.deepEqual(actualPaths, expectedPaths); + +const nativeFiles = actualPaths.filter((path) => path.endsWith(".node")); +assert.deepEqual(nativeFiles, ["fastsse.node"]); + +const nativeEntry = packument.files.find((file) => file.path === "fastsse.node"); +assert.ok(nativeEntry?.size > 0, "fastsse.node must be present and non-empty"); + +console.log( + `npm pack dry-run ok: ${packument.name}@${packument.version} (${actualPaths.length} files)`, +); diff --git a/scripts/node-package-smoke.mjs b/scripts/node-package-smoke.mjs new file mode 100644 index 0000000..a6e5853 --- /dev/null +++ b/scripts/node-package-smoke.mjs @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { pathToFileURL, fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))); +const packageDir = join(rootDir, "npm", "fastsse_node"); +const nativeAddonPath = join(packageDir, "fastsse.node"); + +assert.ok( + existsSync(nativeAddonPath), + "fastsse.node is missing; run `pnpm --dir npm/fastsse_node build` before smoke testing", +); + +function verifyApi(api, label) { + assert.equal(typeof api.Decoder, "function", `${label}: Decoder export`); + assert.equal(typeof api.encodeEvent, "function", `${label}: encodeEvent export`); + assert.equal(typeof api.encodeRetry, "function", `${label}: encodeRetry export`); + + const encoded = api.encodeEvent({ + event: "chat", + data: "hello\nworld", + id: "evt-7", + retry: 1500, + }); + assert.ok(Buffer.isBuffer(encoded), `${label}: encodeEvent returns Buffer`); + + const decoder = new api.Decoder(); + assert.deepEqual(decoder.push(encoded), [ + { kind: "retry", retry: 1500 }, + { kind: "event", event: "chat", data: "hello\nworld", id: "evt-7" }, + ]); + assert.equal(decoder.retry, 1500); + assert.equal(decoder.lastEventId, "evt-7"); + + const stringDecoder = new api.Decoder(); + assert.deepEqual(stringDecoder.pushString("retry: 2500\n\n"), [{ kind: "retry", retry: 2500 }]); + assert.equal(stringDecoder.retry, 2500); + assert.deepEqual(stringDecoder.pushString("id: str-1\ndata: from string\n\n"), [ + { kind: "event", event: "message", data: "from string", id: "str-1" }, + ]); + assert.equal(stringDecoder.lastEventId, "str-1"); + + assert.throws( + () => api.encodeRetry(-1), + /retry must be a finite, non-negative integer/, + `${label}: encodeRetry rejects negative values`, + ); + assert.throws( + () => api.encodeEvent({ data: "bad", retry: 1.5 }), + /retry must be a finite, non-negative integer/, + `${label}: encodeEvent rejects fractional retry values`, + ); +} + +const cjsApi = require(join(packageDir, "index.js")); +verifyApi(cjsApi, "CommonJS entrypoint"); + +const esmApi = await import(pathToFileURL(join(packageDir, "index.mjs")).href); +verifyApi(esmApi, "ESM entrypoint"); + +const tempDir = mkdtempSync(join(tmpdir(), "fastsse-node-smoke-")); + +try { + const packOutput = execFileSync("npm", ["pack", "--pack-destination", tempDir, "--json"], { + cwd: packageDir, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + const [packument] = JSON.parse(packOutput); + const tarball = join(tempDir, packument.filename); + const projectDir = tempDir; + + execFileSync("npm", ["init", "-y"], { + cwd: tempDir, + stdio: "ignore", + }); + execFileSync("npm", ["install", "--silent", tarball], { + cwd: tempDir, + stdio: "inherit", + }); + + writeFileSync( + join(tempDir, "smoke.cjs"), + [ + "const assert = require('node:assert/strict');", + "const { Decoder, encodeEvent } = require('@matesinc/fastsse-node');", + "const decoder = new Decoder();", + "const items = decoder.push(encodeEvent({ data: 'installed cjs' }));", + "assert.deepEqual(items, [{ kind: 'event', event: 'message', data: 'installed cjs', id: '' }]);", + ].join("\n"), + ); + writeFileSync( + join(tempDir, "smoke.mjs"), + [ + "import assert from 'node:assert/strict';", + "import { Decoder, encodeEvent } from '@matesinc/fastsse-node';", + "const decoder = new Decoder();", + "const items = decoder.push(encodeEvent({ data: 'installed esm' }));", + "assert.deepEqual(items, [{ kind: 'event', event: 'message', data: 'installed esm', id: '' }]);", + ].join("\n"), + ); + + execFileSync("node", [join(tempDir, "smoke.cjs")], { + cwd: projectDir, + stdio: "inherit", + }); + execFileSync("node", [join(tempDir, "smoke.mjs")], { + cwd: projectDir, + stdio: "inherit", + }); +} finally { + rmSync(tempDir, { force: true, recursive: true }); +} + +console.log("Node package smoke ok: CJS, ESM, encode/decode, pushString, retry validation"); diff --git a/scripts/package-crates.mjs b/scripts/package-crates.mjs new file mode 100644 index 0000000..58f9949 --- /dev/null +++ b/scripts/package-crates.mjs @@ -0,0 +1,22 @@ +import { execFileSync } from "node:child_process"; + +const allowDirty = process.argv.includes("--allow-dirty"); +const commonArgs = ["package", "--locked"]; +if (allowDirty) { + commonArgs.push("--allow-dirty"); +} + +packageCrate("fastsse"); + +// The binding crates are publishable through crates.io because their path +// dependency also has an exact version. Before the core crate is published, +// CI still needs a local dry-run, so patch crates.io back to this checkout. +const localFastssePatch = 'patch.crates-io.fastsse.path="crates/fastsse"'; +packageCrate("fastsse-node", ["--config", localFastssePatch]); +packageCrate("fastsse-wasm", ["--config", localFastssePatch]); + +function packageCrate(crate, extraArgs = []) { + execFileSync("cargo", [...commonArgs, "-p", crate, ...extraArgs], { + stdio: "inherit", + }); +} diff --git a/scripts/release.mjs b/scripts/release.mjs index ee661fa..908442c 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -39,7 +39,10 @@ if (!noTag) { } const nextVersion = bumpVersion(currentVersion, mode); -const nextCargoToml = cargoToml.replace(/^version = "([^"]+)"$/m, `version = "${nextVersion}"`); +const nextCargoToml = setWorkspaceFastsseDependencyVersion( + setWorkspaceVersion(cargoToml, nextVersion), + nextVersion, +); const nextPackages = packageJsons.map((packageJson) => ({ ...packageJson, contents: { @@ -55,6 +58,7 @@ if (dryRun) { currentVersion, nextVersion, noTag, + workspaceDependency: `fastsse = "=${nextVersion}"`, packages: nextPackages.map(({ name, path }) => ({ name, path })), }, null, @@ -75,7 +79,7 @@ if (!noTag) { ROOT_CARGO_TOML, ROOT_CARGO_LOCK, ...nextPackages.map(({ url }) => url), - ].filter((url) => existsSync(url)); + ].filter((url) => existsSync(url) && shouldStageReleaseFile(url)); execFileSync("git", ["add", ...releaseFiles.map((url) => fileURLToPath(url))], { stdio: "inherit", @@ -109,12 +113,50 @@ function readPackageJsons(entries) { })); } +function setWorkspaceVersion(toml, version) { + const nextToml = toml.replace(/^version = "([^"]+)"$/m, `version = "${version}"`); + if (nextToml === toml) { + throw new Error("workspace.package.version not found in Cargo.toml"); + } + return nextToml; +} + +function setWorkspaceFastsseDependencyVersion(toml, version) { + const nextToml = toml.replace( + /^fastsse = \{ path = "crates\/fastsse", version = "=[^"]+" \}$/m, + `fastsse = { path = "crates/fastsse", version = "=${version}" }`, + ); + if (nextToml === toml) { + throw new Error("workspace.dependencies.fastsse exact version not found in Cargo.toml"); + } + return nextToml; +} + function refreshCargoLock() { execFileSync("cargo", ["metadata", "--format-version=1", "--no-deps"], { stdio: "ignore", }); } +function shouldStageReleaseFile(url) { + const path = fileURLToPath(url); + try { + execFileSync("git", ["ls-files", "--error-unmatch", path], { + stdio: "ignore", + }); + return true; + } catch { + try { + execFileSync("git", ["check-ignore", "-q", path], { + stdio: "ignore", + }); + return false; + } catch { + return true; + } + } +} + function bumpVersion(version, release) { const parsed = parseSemver(version); diff --git a/scripts/sync-wasm-package.mjs b/scripts/sync-wasm-package.mjs new file mode 100644 index 0000000..79bbe90 --- /dev/null +++ b/scripts/sync-wasm-package.mjs @@ -0,0 +1,44 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; + +const ROOT_CARGO_TOML = new URL("../Cargo.toml", import.meta.url); +const WASM_PACKAGE_JSON = new URL("../npm/fastsse_wasm/package.json", import.meta.url); + +if (!existsSync(WASM_PACKAGE_JSON)) { + throw new Error("npm/fastsse_wasm/package.json does not exist; run build:wasm first"); +} + +const version = readWorkspaceVersion(readFileSync(ROOT_CARGO_TOML, "utf8")); +const packageJson = JSON.parse(readFileSync(WASM_PACKAGE_JSON, "utf8")); + +const nextPackageJson = { + ...packageJson, + name: "@matesinc/fastsse-wasm", + version, + description: "Browser bindings for the fastsse Server-Sent Events codec.", + keywords: ["rust", "server-sent-events", "sse", "wasm"], + license: "GPL-3.0-or-later", + author: "Mates Inc.", + repository: { + type: "git", + url: "https://github.com/matesedu/fastsse", + directory: "npm/fastsse_wasm", + }, + bugs: { + url: "https://github.com/matesedu/fastsse/issues", + }, + homepage: "https://github.com/matesedu/fastsse#readme", + publishConfig: { + access: "public", + provenance: true, + }, +}; + +writeFileSync(WASM_PACKAGE_JSON, `${JSON.stringify(nextPackageJson, null, 2)}\n`); + +function readWorkspaceVersion(toml) { + const match = toml.match(/^\[workspace\.package\][\s\S]*?^version = "([^"]+)"$/m); + if (!match) { + throw new Error("workspace.package.version not found in Cargo.toml"); + } + return match[1]; +} diff --git a/scripts/verify-release-tag.mjs b/scripts/verify-release-tag.mjs new file mode 100644 index 0000000..0d94a47 --- /dev/null +++ b/scripts/verify-release-tag.mjs @@ -0,0 +1,37 @@ +import { existsSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const ROOT_CARGO_TOML = new URL("../Cargo.toml", import.meta.url); +const NODE_PACKAGE_JSON = new URL("../npm/fastsse_node/package.json", import.meta.url); +const WASM_PACKAGE_JSON = new URL("../npm/fastsse_wasm/package.json", import.meta.url); +const packageJsonFiles = [NODE_PACKAGE_JSON, WASM_PACKAGE_JSON].filter((url) => existsSync(url)); + +const version = readWorkspaceVersion(readFileSync(ROOT_CARGO_TOML, "utf8")); +const refName = process.env.GITHUB_REF_NAME; + +if (!refName) { + throw new Error("GITHUB_REF_NAME is required"); +} + +if (refName !== `v${version}`) { + throw new Error(`release tag ${refName} does not match workspace version v${version}`); +} + +for (const url of packageJsonFiles) { + const contents = JSON.parse(readFileSync(url, "utf8")); + if (contents.version !== version) { + throw new Error( + `version mismatch: Cargo.toml=${version}, ${fileURLToPath(url)}=${contents.version}`, + ); + } +} + +console.log(`release tag ${refName} matches workspace/npm package version ${version}`); + +function readWorkspaceVersion(toml) { + const match = toml.match(/^\[workspace\.package\][\s\S]*?^version = "([^"]+)"$/m); + if (!match) { + throw new Error("workspace.package.version not found in Cargo.toml"); + } + return match[1]; +} diff --git a/scripts/wasm-smoke.mjs b/scripts/wasm-smoke.mjs new file mode 100644 index 0000000..7c0282f --- /dev/null +++ b/scripts/wasm-smoke.mjs @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +const packageDir = new URL("../npm/fastsse_wasm/", import.meta.url); +const dtsUrl = new URL("fastsse.d.ts", packageDir); +const jsUrl = new URL("fastsse.js", packageDir); +const wasmUrl = new URL("fastsse_bg.wasm", packageDir); + +async function readBuiltFile(url) { + try { + return await readFile(url); + } catch (error) { + if (error?.code === "ENOENT") { + throw new Error( + `WASM package is missing ${url.pathname}. Run \`pnpm run build:wasm\` before this smoke test.`, + ); + } + throw error; + } +} + +function expectDeclaration(dts, expected) { + assert.ok(dts.includes(expected), `fastsse.d.ts is missing expected declaration: ${expected}`); +} + +function rejectDeclaration(dts, unexpected) { + assert.ok( + !dts.includes(unexpected), + `fastsse.d.ts still exposes an untyped public declaration: ${unexpected}`, + ); +} + +const dts = await readBuiltFile(dtsUrl).then((buffer) => buffer.toString("utf8")); + +expectDeclaration(dts, "export interface EncodeEventInput"); +expectDeclaration(dts, "export interface DecodedEvent"); +expectDeclaration(dts, "export interface DecodedRetry"); +expectDeclaration(dts, "export type DecodedItem = DecodedEvent | DecodedRetry;"); +expectDeclaration(dts, "push(chunk: Uint8Array): DecodedItem[];"); +expectDeclaration(dts, "pushString(chunk: string): DecodedItem[];"); +expectDeclaration(dts, "export function encodeEvent(event: EncodeEventInput): Uint8Array;"); +rejectDeclaration(dts, "push(chunk: Uint8Array): Array;"); +rejectDeclaration(dts, "pushString(chunk: string): Array;"); +rejectDeclaration(dts, "export function encodeEvent(event: any): Uint8Array;"); + +const [{ default: init, Decoder, encodeComment, encodeEvent, encodeRetry }, wasmBytes] = + await Promise.all([import(jsUrl.href), readBuiltFile(wasmUrl)]); + +await init({ module_or_path: wasmBytes }); + +const textDecoder = new TextDecoder(); +const textEncoder = new TextEncoder(); + +const encodedEvent = encodeEvent({ + event: "notice", + data: "hello\nworld", + id: "evt-1", + retry: 1500, +}); +assert.equal( + textDecoder.decode(encodedEvent), + "retry:1500\nid:evt-1\nevent:notice\ndata:hello\ndata:world\n\n", +); + +const decoder = new Decoder(); +const firstChunkLength = 11; +assert.deepEqual(decoder.push(encodedEvent.slice(0, firstChunkLength)), [ + { kind: "retry", retry: 1500 }, +]); +assert.deepEqual(decoder.push(encodedEvent.slice(firstChunkLength)), [ + { + kind: "event", + event: "notice", + data: "hello\nworld", + id: "evt-1", + }, +]); +assert.equal(decoder.retry, 1500); +assert.equal(decoder.lastEventId, "evt-1"); + +assert.equal(textDecoder.decode(encodeComment("keep-alive")), ":keep-alive\n\n"); +assert.deepEqual(decoder.pushString(":keep-alive\n\n"), []); + +const encodedRetry = textDecoder.decode(encodeRetry(2500)); +assert.equal(encodedRetry, "retry:2500\n\n"); +assert.deepEqual(decoder.push(textEncoder.encode(encodedRetry)), [{ kind: "retry", retry: 2500 }]); +assert.equal(decoder.retry, 2500); + +decoder.reset(); +assert.equal(decoder.retry, undefined); +assert.equal(decoder.lastEventId, ""); From b2282ce7326c2740b101015b638f648ad9ea0cbb Mon Sep 17 00:00:00 2001 From: ubugeeei Date: Mon, 18 May 2026 15:02:18 +0900 Subject: [PATCH 2/2] test: wire npm package smoke checks --- .github/workflows/ci.yml | 8 +++-- package.json | 4 ++- pnpm-workspace.yaml | 1 + scripts/wasm-package-pack-check.mjs | 54 +++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 scripts/wasm-package-pack-check.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a4b613..76ec22e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,13 +83,13 @@ jobs: node: true cargo: true nix_shell: .#default - command: pnpm run build:node + command: pnpm run package:node - name: wasm package node: true cargo: true nix_shell: .#default - command: pnpm run build:wasm + command: pnpm run package:wasm steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd @@ -106,8 +106,10 @@ jobs: - name: Fetch Rust dependencies if: ${{ matrix.cargo }} + env: + NIX_SHELL: ${{ matrix.nix_shell }} run: | - nix develop --command cargo fetch --locked + nix develop "$NIX_SHELL" --command cargo fetch --locked - name: Run ${{ matrix.name }} env: diff --git a/package.json b/package.json index 60d5782..dc941eb 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,9 @@ "build:wasm": "wasm-pack build crates/fastsse_wasm --target web --out-dir ../../npm/fastsse_wasm --out-name fastsse", "audit:supply-chain": "cargo deny check && pnpm audit --audit-level moderate && node scripts/audit-npm-licenses.mjs", "package:crates": "node scripts/package-crates.mjs", - "package:npm": "pnpm run build:node && npm pack --dry-run --json ./npm/fastsse_node && pnpm run build:wasm && node scripts/sync-wasm-package.mjs && npm pack --dry-run --json ./npm/fastsse_wasm", + "package:node": "pnpm run build:node && pnpm --dir npm/fastsse_node run smoke && pnpm --dir npm/fastsse_node run pack:check", + "package:wasm": "pnpm run build:wasm && node scripts/sync-wasm-package.mjs && node scripts/wasm-smoke.mjs && node scripts/wasm-package-pack-check.mjs", + "package:npm": "pnpm run package:node && pnpm run package:wasm", "package:dry-run": "pnpm run package:crates && pnpm run package:npm", "fmt": "oxfmt .", "check": "cargo check -p fastsse -p fastsse-node --all-targets && cargo check -p fastsse-wasm --target wasm32-unknown-unknown", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 05ebbc4..93309b3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,3 @@ packages: - npm/* + - "!npm/fastsse_wasm" diff --git a/scripts/wasm-package-pack-check.mjs b/scripts/wasm-package-pack-check.mjs new file mode 100644 index 0000000..cb4f959 --- /dev/null +++ b/scripts/wasm-package-pack-check.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))); +const packageDir = join(rootDir, "npm", "fastsse_wasm"); +const packageJsonPath = join(packageDir, "package.json"); +const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); + +for (const file of ["fastsse_bg.wasm", "fastsse.d.ts", "fastsse.js"]) { + assert.ok( + existsSync(join(packageDir, file)), + `${file} is missing; run \`pnpm run build:wasm\` before pack checking`, + ); +} + +assert.equal(packageJson.name, "@matesinc/fastsse-wasm"); +assert.equal(packageJson.type, "module"); +assert.equal(packageJson.main, "fastsse.js"); +assert.equal(packageJson.types, "fastsse.d.ts"); +assert.equal(packageJson.publishConfig?.access, "public"); +assert.equal(packageJson.publishConfig?.provenance, true); + +const output = execFileSync("npm", ["pack", "--dry-run", "--json"], { + cwd: packageDir, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], +}); +const [packument] = JSON.parse(output); + +assert.equal(packument.name, "@matesinc/fastsse-wasm"); +assert.equal(packument.version, packageJson.version); +assert.equal(packument.entryCount, packument.files.length); + +const actualPaths = packument.files.map((file) => file.path).sort(); +const expectedPaths = [ + "LICENSE", + "README.md", + "fastsse_bg.wasm", + "fastsse.d.ts", + "fastsse.js", + "package.json", +].sort(); + +assert.deepEqual(actualPaths, expectedPaths); + +const wasmEntry = packument.files.find((file) => file.path === "fastsse_bg.wasm"); +assert.ok(wasmEntry?.size > 0, "fastsse_bg.wasm must be present and non-empty"); + +console.log( + `WASM npm pack dry-run ok: ${packument.name}@${packument.version} (${actualPaths.length} files)`, +);