From eeef6414b7c0a34db50eec9d299cebf50af57cf3 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Fri, 15 May 2026 09:19:01 -0400 Subject: [PATCH 1/9] fix: Audit/supply chain issues --- .github/workflows/ci.yml | 35 + bun.lock | 296 ++++--- bunfig.toml | 7 + bunfig.update-lockfile.toml | 3 + package.json | 84 +- .../lib/commands/thumbnailMain.mjs | 832 +++++++++--------- packages/create-dicomweb/package.json | 26 +- packages/cs3d/package.json | 12 +- packages/healthlake/package.json | 22 +- packages/s3-deploy/package.json | 18 +- packages/static-wado-creator/package.json | 20 +- packages/static-wado-deploy/package.json | 14 +- packages/static-wado-plugins/package.json | 10 +- packages/static-wado-scp/package.json | 14 +- packages/static-wado-util/package.json | 24 +- .../controllers/server/multipartStream.mjs | 2 +- .../lib/vendor/dicer-compat/Dicer.mjs | 217 +++++ .../lib/vendor/dicer-compat/HeaderParser.mjs | 106 +++ .../lib/vendor/dicer-compat/LICENSE.dicer.md | 7 + .../lib/vendor/dicer-compat/PartStream.mjs | 5 + packages/static-wado-webserver/package.json | 33 +- scripts/check-pinned-versions.sh | 45 + scripts/ci-supply-chain.sh | 56 ++ 23 files changed, 1201 insertions(+), 687 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 bunfig.toml create mode 100644 bunfig.update-lockfile.toml create mode 100644 packages/static-wado-webserver/lib/vendor/dicer-compat/Dicer.mjs create mode 100644 packages/static-wado-webserver/lib/vendor/dicer-compat/HeaderParser.mjs create mode 100644 packages/static-wado-webserver/lib/vendor/dicer-compat/LICENSE.dicer.md create mode 100644 packages/static-wado-webserver/lib/vendor/dicer-compat/PartStream.mjs create mode 100644 scripts/check-pinned-versions.sh create mode 100644 scripts/ci-supply-chain.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..8036ee85 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.13" + + - name: Test (supply chain + unit tests) + run: bun run test:github + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.13" + + - name: Build (supply chain + build) + run: bun run build:github diff --git a/bun.lock b/bun.lock index 045d8495..ad1fdaee 100644 --- a/bun.lock +++ b/bun.lock @@ -7,75 +7,75 @@ "dependencies": { "@cornerstonejs/dicom-codec": "1.0.9", "canvas": "3.1.0", - "dcmjs": "0.50.0", - "typescript": "^5.8.2", + "dcmjs": "0.50.1", + "typescript": "5.9.3", }, "devDependencies": { - "@babel/core": "^7.26.0", - "@babel/eslint-parser": "^7.25.9", - "@babel/plugin-proposal-class-properties": "^7.18.6", - "@babel/plugin-transform-class-properties": "^7.25.9", - "@babel/plugin-transform-class-static-block": "^7.28.3", - "@babel/plugin-transform-modules-commonjs": "^7.16.8", - "@babel/plugin-transform-private-methods": "^7.28.6", - "@babel/plugin-transform-runtime": "^7.17.0", - "@babel/preset-env": "^7.16.11", - "@babel/preset-typescript": "^7.28.5", - "@babel/runtime": "^7.17.0", - "babel-plugin-transform-import-meta": "^2.3.3", - "eslint": "^9.14.0", - "eslint-config-prettier": "^9.1.0", - "eslint-plugin-jest": "^28.8.3", - "eslint-plugin-prettier": "^5.2.1", + "@babel/core": "7.28.6", + "@babel/eslint-parser": "7.28.6", + "@babel/plugin-proposal-class-properties": "7.18.6", + "@babel/plugin-transform-class-properties": "7.28.6", + "@babel/plugin-transform-class-static-block": "7.28.6", + "@babel/plugin-transform-modules-commonjs": "7.28.6", + "@babel/plugin-transform-private-methods": "7.28.6", + "@babel/plugin-transform-runtime": "7.28.5", + "@babel/preset-env": "7.28.6", + "@babel/preset-typescript": "7.28.5", + "@babel/runtime": "7.28.6", + "babel-plugin-transform-import-meta": "2.3.3", + "eslint": "9.39.2", + "eslint-config-prettier": "9.1.2", + "eslint-plugin-jest": "28.14.0", + "eslint-plugin-prettier": "5.5.5", "has-flag": "4.0.0", "jest": "29.7.0", "jest-config": "29.7.0", "jest-watcher": "29.7.0", - "lerna": "^9.0.7", - "p-locate": "^5.0.0", - "prettier": "^3.3.3", - "rimraf": "^6.0.1", - "tsx": "^4.19.3", - "webpack-cli": "^5.1.4", + "lerna": "9.0.7", + "p-locate": "5.0.0", + "prettier": "3.8.1", + "rimraf": "6.1.2", + "tsx": "4.21.0", + "webpack-cli": "5.1.4", }, }, "packages/create-dicomweb": { "name": "@radicalimaging/create-dicomweb", - "version": "1.7.4", + "version": "1.7.6", "bin": { "createdicomweb": "bin/createdicomweb.mjs", }, "dependencies": { - "@radicalimaging/cs3d": ">=1.7.0", - "@radicalimaging/static-wado-creator": ">=1.7.0", - "@radicalimaging/static-wado-util": ">=1.7.0", - "adm-zip": "^0.5.16", - "canvas": ">=3.1.0", - "commander": "^12.0.0", - "config-point": ">=0.5.1", - "dcmjs": "0.50.0", - "dicom-parser": "^1.8.13", - "glob": "^11.0.0", - "node-object-hash": "^3.0.0", - "uuid": "^11.0.2", + "@radicalimaging/cs3d": "workspace:*", + "@radicalimaging/static-wado-creator": "workspace:*", + "@radicalimaging/static-wado-util": "workspace:*", + "adm-zip": "0.5.16", + "canvas": "3.1.0", + "commander": "12.1.0", + "config-point": "0.8.0", + "dcmjs": "0.50.1", + "dicom-parser": "1.8.21", + "glob": "11.1.0", + "node-object-hash": "3.1.1", + "uuid": "11.1.0", }, "devDependencies": { - "must": "^0.13.4", + "must": "0.13.4", }, }, "packages/cs3d": { "name": "@radicalimaging/cs3d", - "version": "1.7.4", + "version": "1.7.6", "dependencies": { - "@cornerstonejs/core": "^4.22.3", + "@cornerstonejs/core": "4.22.3", "canvas": "3.1.0", - "dcmjs": "0.50.0", - "jsdom": "^26.0.0", - "jsdom-context-require": "^5.2.4", + "dcmjs": "0.50.1", + "jsdom": "26.1.0", + "jsdom-context-require": "5.2.7", }, "devDependencies": { - "@babel/plugin-proposal-class-properties": "^7.18.6", - "babel-plugin-transform-import-meta": "^2.3.3", + "@babel/plugin-proposal-class-properties": "7.18.6", + "babel-plugin-transform-import-meta": "2.3.3", }, }, "packages/healthlake": { @@ -85,169 +85,175 @@ "curiestore": "bin/healthlakestore.mjs", }, "dependencies": { - "@radicalimaging/s3-deploy": ">=1.7.0", - "@radicalimaging/static-wado-deploy": ">=1.7.0", - "@radicalimaging/static-wado-plugins": ">=1.7.0", - "@radicalimaging/static-wado-util": ">=1.7.0", - "config-point": ">=0.5.1", - "cross-env": "^7.0.3", - "dcmjs": "0.50.0", - "dicom-parser": "^1.8.13", - "node-object-hash": "^3.0.0", + "@radicalimaging/s3-deploy": "workspace:*", + "@radicalimaging/static-wado-deploy": "workspace:*", + "@radicalimaging/static-wado-plugins": "workspace:*", + "@radicalimaging/static-wado-util": "workspace:*", + "config-point": "0.8.0", + "cross-env": "7.0.3", + "dcmjs": "0.50.1", + "dicom-parser": "1.8.21", + "node-object-hash": "3.1.1", }, "devDependencies": { - "must": "^0.13.4", - "ts-jest": "^28.0.2", + "must": "0.13.4", + "ts-jest": "28.0.8", }, }, "packages/s3-deploy": { "name": "@radicalimaging/s3-deploy", - "version": "1.7.4", + "version": "1.7.6", "dependencies": { - "@aws-sdk/client-s3": "^3.374.0", - "@aws-sdk/lib-storage": "^3.862.0", - "@radicalimaging/static-wado-plugins": ">=1.7.0", - "@radicalimaging/static-wado-util": ">=1.7.0", - "aws-cdk-lib": "^2.251.0", - "config-point": ">=0.5.1", - "constructs": "^10.0.94", - "mime-types": "^2.1.35", + "@aws-sdk/client-s3": "3.975.0", + "@aws-sdk/lib-storage": "3.975.0", + "@radicalimaging/static-wado-plugins": "workspace:*", + "@radicalimaging/static-wado-util": "workspace:*", + "aws-cdk-lib": "2.251.0", + "config-point": "0.8.0", + "constructs": "10.4.5", + "mime-types": "2.1.35", }, "devDependencies": { - "aws-cdk": "^2.144.0", + "aws-cdk": "2.1103.0", }, }, "packages/static-wado-creator": { "name": "@radicalimaging/static-wado-creator", - "version": "1.7.4", + "version": "1.7.6", "bin": { "mkdicomweb": "bin/mkdicomweb.mjs", }, "dependencies": { - "@radicalimaging/cs3d": ">=1.7.0", - "@radicalimaging/static-wado-util": ">=1.7.0", - "canvas": ">=3.1.0", - "config-point": ">=0.5.1", - "dcmjs": "0.50.0", - "dicom-parser": "^1.8.13", - "glob": "^11.0.0", - "node-object-hash": "^3.0.0", - "uuid": "^11.0.2", + "@radicalimaging/cs3d": "workspace:*", + "@radicalimaging/static-wado-util": "workspace:*", + "canvas": "3.1.0", + "config-point": "0.8.0", + "dcmjs": "0.50.1", + "dicom-parser": "1.8.21", + "glob": "11.1.0", + "node-object-hash": "3.1.1", + "uuid": "11.1.0", }, "devDependencies": { - "must": "^0.13.4", + "must": "0.13.4", }, }, "packages/static-wado-deploy": { "name": "@radicalimaging/static-wado-deploy", - "version": "1.7.4", + "version": "1.7.6", "bin": { "deploydicomweb": "bin/deploydicomweb.mjs", }, "dependencies": { - "@radicalimaging/s3-deploy": ">=1.7.0", - "@radicalimaging/static-wado-plugins": ">=1.7.0", - "@radicalimaging/static-wado-util": ">=1.7.0", - "config-point": ">=0.5.1", - "node-object-hash": "^3.0.0", + "@radicalimaging/s3-deploy": "workspace:*", + "@radicalimaging/static-wado-plugins": "workspace:*", + "@radicalimaging/static-wado-util": "workspace:*", + "config-point": "0.8.0", + "node-object-hash": "3.1.1", }, "devDependencies": { - "must": "^0.13.4", - "ts-jest": "^28.0.2", + "must": "0.13.4", + "ts-jest": "28.0.8", }, }, "packages/static-wado-plugins": { "name": "@radicalimaging/static-wado-plugins", - "version": "1.7.4", + "version": "1.7.6", "dependencies": { - "@radicalimaging/static-wado-util": ">=1.7.0", - "config-point": ">=0.5.1", - "dcmjs-dimse": "^0.1.30", - "http-proxy-middleware": "^2.0.6", + "@radicalimaging/static-wado-util": "workspace:*", + "config-point": "0.8.0", + "dcmjs-dimse": "0.1.31", + "http-proxy-middleware": "2.0.9", }, "devDependencies": { - "must": "^0.13.4", + "must": "0.13.4", }, }, "packages/static-wado-scp": { "name": "@radicalimaging/static-wado-scp", - "version": "1.7.4", + "version": "1.7.6", "bin": { "dicomwebscp": "bin/dicomwebscp.mjs", }, "dependencies": { - "@radicalimaging/static-wado-creator": ">=1.7.0", - "@radicalimaging/static-wado-plugins": ">=1.7.0", - "@radicalimaging/static-wado-util": ">=1.7.0", - "config-point": ">=0.5.1", - "dcmjs": "0.50.0", - "dcmjs-dimse": "^0.1.30", + "@radicalimaging/static-wado-creator": "workspace:*", + "@radicalimaging/static-wado-plugins": "workspace:*", + "@radicalimaging/static-wado-util": "workspace:*", + "config-point": "0.8.0", + "dcmjs": "0.50.1", + "dcmjs-dimse": "0.1.31", }, "devDependencies": { - "must": "^0.13.4", + "must": "0.13.4", }, }, "packages/static-wado-util": { "name": "@radicalimaging/static-wado-util", - "version": "1.7.4", + "version": "1.7.6", "dependencies": { - "@cornerstonejs/codec-openjpeg": ">=1.2.3", - "@cornerstonejs/codec-openjph": ">=2.4.5", - "@cornerstonejs/dicom-codec": ">=1.0.5", - "commander": "^10.0.0", - "config-point": ">=0.5.1", - "dcmjs": "0.50.0", - "imurmurhash": "^0.1.4", - "jpeg-lossless-decoder-js": ">=2.0.4", - "json5": "^2.2.1", + "@cornerstonejs/codec-openjpeg": "1.3.0", + "@cornerstonejs/codec-openjph": "2.4.7", + "@cornerstonejs/dicom-codec": "1.0.9", + "commander": "10.0.1", + "config-point": "0.8.0", + "dcmjs": "0.50.1", + "imurmurhash": "0.1.4", + "jpeg-lossless-decoder-js": "2.1.2", + "json5": "2.2.3", "loglevel": "1.9.2", - "must": "^0.13.4", - "node-object-hash": "^3.0.0", + "must": "0.13.4", + "node-object-hash": "3.1.1", }, "devDependencies": { - "must": "^0.13.4", + "must": "0.13.4", }, }, "packages/static-wado-webserver": { "name": "@radicalimaging/static-wado-webserver", - "version": "1.7.4", + "version": "1.7.6", "bin": { "dicomwebserver": "bin/dicomwebserver.mjs", "monitordicomwebserver": "bin/monitordicomwebserver.mjs", }, "dependencies": { "@cornerstonejs/dicom-codec": "1.0.9", - "@radicalimaging/create-dicomweb": ">=1.7.0", - "@radicalimaging/static-wado-plugins": ">=1.7.0", - "@radicalimaging/static-wado-util": ">=1.7.0", - "@radicalimaging/static-wado-webserver": ">=1.7.0", + "@radicalimaging/create-dicomweb": "workspace:*", + "@radicalimaging/static-wado-plugins": "workspace:*", + "@radicalimaging/static-wado-util": "workspace:*", "@saga-bus/core": "0.2.2", - "config-point": ">=0.5.1", + "config-point": "0.8.0", "content-type": "1.0.5", - "cors": "^2.8.5", - "dcmjs": "0.50.0", - "dcmjs-dimse": "^0.1.30", - "dicer": "0.3.1", - "express": "^5.2.1", - "formidable": "^3.5.4", - "morgan": "^1.10.0", - "regenerator-runtime": "^0.14.1", - "rxjs": "^7.5.4", - "ws": "^8.18.0", + "cors": "2.8.6", + "dcmjs": "0.50.1", + "dcmjs-dimse": "0.1.31", + "express": "5.2.1", + "formidable": "3.5.4", + "morgan": "1.10.1", + "regenerator-runtime": "0.14.1", + "rxjs": "7.8.2", + "streamsearch": "1.1.0", + "ws": "8.19.0", }, "devDependencies": { - "@babel/plugin-transform-runtime": "^7.17.0", + "@babel/plugin-transform-runtime": "7.28.5", "jest": "29.7.0", - "must": "^0.13.4", + "must": "0.13.4", }, }, }, + "trustedDependencies": [ + "canvas", + ], "overrides": { - "@cornerstonejs/codec-openjph": ">=2.4.5", + "@babel/plugin-transform-modules-systemjs": "7.29.4", + "@cornerstonejs/codec-openjph": "2.4.7", "@cornerstonejs/dicom-codec": "1.0.9", "@isaacs/brace-expansion": "5.0.1", + "axios": "1.16.1", "canvas": "3.1.0", "dcmjs": "0.50.1", + "fast-uri": "3.1.2", + "fast-xml-builder": "1.2.0", "fast-xml-parser": "5.7.2", "flatted": "3.4.2", "handlebars": "4.7.9", @@ -506,7 +512,7 @@ "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.28.6", "", { "dependencies": { "@babel/helper-module-transforms": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA=="], - "@babel/plugin-transform-modules-systemjs": ["@babel/plugin-transform-modules-systemjs@7.28.5", "", { "dependencies": { "@babel/helper-module-transforms": "^7.28.3", "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew=="], + "@babel/plugin-transform-modules-systemjs": ["@babel/plugin-transform-modules-systemjs@7.29.4", "", { "dependencies": { "@babel/helper-module-transforms": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.29.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-N7QmZ0xRZfjHOfZeQLJjwgX2zS9pdGHSVl/cjSGlo4dXMqvurfxXDMKY4RqEKzPozV78VMcd0lxyG13mlbKc4w=="], "@babel/plugin-transform-modules-umd": ["@babel/plugin-transform-modules-umd@7.27.1", "", { "dependencies": { "@babel/helper-module-transforms": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w=="], @@ -1210,7 +1216,7 @@ "aws-cdk-lib": ["aws-cdk-lib@2.251.0", "", { "dependencies": { "@aws-cdk/asset-awscli-v1": "2.2.273", "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.1", "@aws-cdk/cloud-assembly-api": "^2.2.2", "@aws-cdk/cloud-assembly-schema": "^53.18.0", "@balena/dockerignore": "^1.0.2", "case": "1.6.3", "fs-extra": "^11.3.3", "ignore": "^5.3.2", "jsonschema": "^1.5.0", "mime-types": "^2.1.35", "minimatch": "^10.2.3", "punycode": "^2.3.1", "semver": "^7.7.4", "table": "^6.9.0", "yaml": "1.10.3" }, "peerDependencies": { "constructs": "^10.5.0" } }, "sha512-H1Jfz2Oyejn+yG24i+By9fZpYfg+E3h1XnFCF2wnt/MyGOTIePRph7MRGkX73ap10ERSpmd0Ly58OLVykFoSQA=="], - "axios": ["axios@1.15.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q=="], + "axios": ["axios@1.16.1", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A=="], "babel-jest": ["babel-jest@29.7.0", "", { "dependencies": { "@jest/transform": "^29.7.0", "@types/babel__core": "^7.1.14", "babel-plugin-istanbul": "^6.1.1", "babel-preset-jest": "^29.6.3", "chalk": "^4.0.0", "graceful-fs": "^4.2.9", "slash": "^3.0.0" }, "peerDependencies": { "@babel/core": "^7.8.0" } }, "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg=="], @@ -1468,8 +1474,6 @@ "dezalgo": ["dezalgo@1.0.4", "", { "dependencies": { "asap": "^2.0.0", "wrappy": "1" } }, "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig=="], - "dicer": ["dicer@0.3.1", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-ObioMtXnmjYs3aRtpIJt9rgQSPCIhKVkFPip+E9GUDyWl8N435znUxK/JfNwGZJ2wnn5JKQ7Ly3vOK5Q5dylGA=="], - "dicom-parser": ["dicom-parser@1.8.21", "", {}, "sha512-lYCweHQDsC8UFpXErPlg86Px2A8bay0HiUY+wzoG3xv5GzgqVHU3lziwSc/Gzn7VV7y2KeP072SzCviuOoU02w=="], "diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="], @@ -1590,9 +1594,9 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], - "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], - "fast-xml-builder": ["fast-xml-builder@1.1.5", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA=="], + "fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="], "fast-xml-parser": ["fast-xml-parser@5.7.2", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.5", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w=="], @@ -2608,6 +2612,8 @@ "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + "xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="], + "xmlbuilder2": ["xmlbuilder2@3.0.2", "", { "dependencies": { "@oozcitak/dom": "1.15.10", "@oozcitak/infra": "1.0.8", "@oozcitak/util": "8.3.8", "@types/node": "*", "js-yaml": "3.14.0" } }, "sha512-h4MUawGY21CTdhV4xm3DG9dgsqyhDkZvVJBx88beqX8wJs3VgyGQgAn5VreHuae6unTQxh115aMK5InCVmOIKw=="], "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], @@ -2650,6 +2656,8 @@ "@aws-sdk/util-endpoints/@aws-sdk/types": ["@aws-sdk/types@3.972.0", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-U7xBIbLSetONxb2bNzHyDgND3oKGoIfmknrEVnoEU4GUSs+0augUOIn9DIWGUO2ETcRFdsRUnmx9KhPT9Ojbug=="], + "@babel/plugin-transform-modules-systemjs/@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@inquirer/core/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], @@ -2766,6 +2774,10 @@ "aws-cdk-lib/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "axios/follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], + + "axios/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + "babel-plugin-istanbul/istanbul-lib-instrument": ["istanbul-lib-instrument@5.2.1", "", { "dependencies": { "@babel/core": "^7.12.3", "@babel/parser": "^7.14.7", "@istanbuljs/schema": "^0.1.2", "istanbul-lib-coverage": "^3.2.0", "semver": "^6.3.0" } }, "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg=="], "basic-auth/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], @@ -3022,6 +3034,14 @@ "@aws-sdk/signature-v4-multi-region/@aws-sdk/middleware-sdk-s3/@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.972.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-RM5Mmo/KJ593iMSrALlHEOcc9YOIyOsDmS5x2NLOMdEmzv1o00fcpAkCQ02IGu1eFneBFT7uX0Mpag0HI+Cz2g=="], + "@babel/plugin-transform-modules-systemjs/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/plugin-transform-modules-systemjs/@babel/traverse/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/plugin-transform-modules-systemjs/@babel/traverse/@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="], + + "@babel/plugin-transform-modules-systemjs/@babel/traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], @@ -3078,6 +3098,8 @@ "aws-cdk-lib/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + "axios/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "bin-links/write-file-atomic/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "cacache/@npmcli/fs/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 00000000..8ec2ecea --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,7 @@ +# https://bun.sh/docs/runtime/bunfig +[install] +frozenLockfile = true +exact = true +saveTextLockfile = true +# Optional supply-chain knob (pnpm-style “minimum release age”), in seconds: +# minimumReleaseAge = 86400 diff --git a/bunfig.update-lockfile.toml b/bunfig.update-lockfile.toml new file mode 100644 index 00000000..844bbdc4 --- /dev/null +++ b/bunfig.update-lockfile.toml @@ -0,0 +1,3 @@ +# Used only by `bun run install:update-lockfile` — default `bunfig.toml` keeps frozen lockfile. +[install] +frozenLockfile = false diff --git a/package.json b/package.json index 5bbd94a5..80be6a5f 100644 --- a/package.json +++ b/package.json @@ -1,61 +1,69 @@ { "name": "static-dicomweb", "private": true, + "packageManager": "bun@1.3.13", "engines": { "node": ">=14.18.1", "npm": ">=6.14.15" }, "devDependencies": { - "@babel/core": "^7.26.0", - "@babel/eslint-parser": "^7.25.9", - "@babel/plugin-proposal-class-properties": "^7.18.6", - "@babel/plugin-transform-class-properties": "^7.25.9", - "@babel/plugin-transform-class-static-block": "^7.28.3", - "@babel/plugin-transform-modules-commonjs": "^7.16.8", - "@babel/plugin-transform-private-methods": "^7.28.6", - "@babel/plugin-transform-runtime": "^7.17.0", - "@babel/preset-env": "^7.16.11", - "@babel/preset-typescript": "^7.28.5", - "@babel/runtime": "^7.17.0", - "babel-plugin-transform-import-meta": "^2.3.3", - "eslint": "^9.14.0", - "eslint-config-prettier": "^9.1.0", - "eslint-plugin-jest": "^28.8.3", - "eslint-plugin-prettier": "^5.2.1", + "@babel/core": "7.28.6", + "@babel/eslint-parser": "7.28.6", + "@babel/plugin-proposal-class-properties": "7.18.6", + "@babel/plugin-transform-class-properties": "7.28.6", + "@babel/plugin-transform-class-static-block": "7.28.6", + "@babel/plugin-transform-modules-commonjs": "7.28.6", + "@babel/plugin-transform-private-methods": "7.28.6", + "@babel/plugin-transform-runtime": "7.28.5", + "@babel/preset-env": "7.28.6", + "@babel/preset-typescript": "7.28.5", + "@babel/runtime": "7.28.6", + "babel-plugin-transform-import-meta": "2.3.3", + "eslint": "9.39.2", + "eslint-config-prettier": "9.1.2", + "eslint-plugin-jest": "28.14.0", + "eslint-plugin-prettier": "5.5.5", "has-flag": "4.0.0", "jest": "29.7.0", "jest-config": "29.7.0", "jest-watcher": "29.7.0", - "lerna": "^9.0.7", - "p-locate": "^5.0.0", - "prettier": "^3.3.3", - "rimraf": "^6.0.1", - "tsx": "^4.19.3", - "webpack-cli": "^5.1.4" + "lerna": "9.0.7", + "p-locate": "5.0.0", + "prettier": "3.8.1", + "rimraf": "6.1.2", + "tsx": "4.21.0", + "webpack-cli": "5.1.4" }, "dependencies": { "@cornerstonejs/dicom-codec": "1.0.9", "canvas": "3.1.0", - "dcmjs": "0.50.0", - "typescript": "^5.8.2" + "dcmjs": "0.50.1", + "typescript": "5.9.3" }, - "resolutions": { + "overrides": { + "@babel/plugin-transform-modules-systemjs": "7.29.4", + "@cornerstonejs/codec-openjph": "2.4.7", "@cornerstonejs/dicom-codec": "1.0.9", - "@cornerstonejs/codec-openjph": ">=2.4.5", - "dcmjs": "0.50.1", + "@isaacs/brace-expansion": "5.0.1", + "axios": "1.16.1", "canvas": "3.1.0", - "has-flag": "4.0.0", - "node-object-hash": "3.1.1", + "dcmjs": "0.50.1", + "fast-uri": "3.1.2", + "fast-xml-builder": "1.2.0", "fast-xml-parser": "5.7.2", + "flatted": "3.4.2", "handlebars": "4.7.9", + "has-flag": "4.0.0", "lodash": "4.18.1", - "@isaacs/brace-expansion": "5.0.1", - "picomatch": "2.3.2", - "flatted": "3.4.2", + "node-object-hash": "3.1.1", "path-to-regexp": "8.4.2", + "picomatch": "2.3.2", "serialize-javascript": "7.0.5", "yallist": "4.0.0" }, + "trustedDependencies": [ + "canvas" + ], "workspaces": [ "packages/*" ], @@ -66,12 +74,15 @@ "docker:run": "docker run -it --mount type=bind,source=/c/dicomweb,target=/dicomweb --mount type=bind,source=/a/dicom,target=/dicom -p 25080:5000 --rm --entrypoint /bin/bash braveheartsoftware/static-dicomweb:latest", "docker:dicomwebserver": "docker run -it --mount type=bind,source=/c/dicomweb,target=/dicomweb -p 25080:5000 --rm braveheartsoftware/static-dicomweb:latest", "test": "lerna run test --parallel --stream --", - "test:ci": "yarn run test", + "supply-chain:ci": "bash ./scripts/ci-supply-chain.sh", + "test:github": "bun run supply-chain:ci && bun run test", + "test:ci": "bun run test:github", "build": "lerna run build --stream --concurrency 1", "build:parallel": "lerna run build --stream", "build:serial": "lerna run build --stream --concurrency 1", "build:esm": "lerna run build:esm --stream", - "build:ci": "yarn run build", + "build:github": "bun run supply-chain:ci && bun run build", + "build:ci": "bun run build:github", "clean": "lerna run clean --stream", "link:exec": "lerna run link:exec --stream", "lint": "lerna run lint --parallel --stream", @@ -82,6 +93,7 @@ "publish": "echo Use yarn publishJS --new-version VERSION --otp XXXXXX", "publishJS": "lerna run publishJS --", "pack:js": "lerna run pack:js --", - "reset:hooks": "git config core.hooksPath '.git' && echo 'git hooks was reset'" + "reset:hooks": "git config core.hooksPath '.git' && echo 'git hooks was reset'", + "install:update-lockfile": "bun install --config=bunfig.update-lockfile.toml" } -} \ No newline at end of file +} diff --git a/packages/create-dicomweb/lib/commands/thumbnailMain.mjs b/packages/create-dicomweb/lib/commands/thumbnailMain.mjs index f3f13d48..d85a7abe 100644 --- a/packages/create-dicomweb/lib/commands/thumbnailMain.mjs +++ b/packages/create-dicomweb/lib/commands/thumbnailMain.mjs @@ -1,416 +1,416 @@ -import fs from 'fs'; -import path from 'path'; -import { FileDicomWebReader } from '../instance/FileDicomWebReader.mjs'; -import { FileDicomWebWriter } from '../instance/FileDicomWebWriter.mjs'; -import { Tags } from '@radicalimaging/static-wado-util'; -import { readBulkData } from '@radicalimaging/static-wado-util'; -import StaticWado from '@radicalimaging/static-wado-creator'; - -const { getValue } = Tags; - -/** - * Generates thumbnails for series (middle SOP instance, middle frame for multiframe) - * @param {string} studyUID - Study Instance UID - * @param {Object} options - Options object - * @param {string} options.dicomdir - Base directory path where DICOMweb structure is located - * @param {string} [options.seriesUid] - Specific Series Instance UID to process (if not provided, processes all series) - * @param {Object} options.reader - FileDicomWebReader instance - */ -async function generateSeriesThumbnails(studyUID, options = {}) { - const { dicomdir, seriesUid, reader } = options; - - // Step 1: Get list of series to process - const seriesIndex = await reader.readJsonFile( - reader.getStudyPath(studyUID, { path: 'series' }), - 'index.json' - ); - - if (!seriesIndex || !Array.isArray(seriesIndex) || seriesIndex.length === 0) { - throw new Error(`No series found for study ${studyUID}`); - } - - // Filter to specific series if provided, otherwise process all - let seriesToProcess = seriesIndex; - if (seriesUid) { - seriesToProcess = seriesIndex.filter( - series => getValue(series, Tags.SeriesInstanceUID) === seriesUid - ); - if (seriesToProcess.length === 0) { - throw new Error(`Series ${seriesUid} not found in study ${studyUID}`); - } - } - - console.log(`Generating series thumbnails for ${seriesToProcess.length} series...`); - - // Step 2: Process each series - const seriesPromises = seriesToProcess.map(async series => { - const targetSeriesUID = getValue(series, Tags.SeriesInstanceUID); - if (!targetSeriesUID) { - console.warn('Could not extract SeriesInstanceUID from series query, skipping'); - return; - } - - console.log(`Processing series ${targetSeriesUID}...`); - - // Step 3: Read series metadata to get all instances - const seriesMetadata = await reader.readJsonFile( - reader.getSeriesPath(studyUID, targetSeriesUID), - 'metadata' - ); - - if (!seriesMetadata || !Array.isArray(seriesMetadata) || seriesMetadata.length === 0) { - console.warn(`No series metadata found for series ${targetSeriesUID}, skipping`); - return; - } - - // Step 4: Choose middle SOP instance - const middleInstanceIndex = Math.floor(seriesMetadata.length / 2); - const targetInstanceMetadata = seriesMetadata[middleInstanceIndex]; - const targetInstanceUID = getValue(targetInstanceMetadata, Tags.SOPInstanceUID); - - if (!targetInstanceUID) { - console.warn( - `Could not extract SOPInstanceUID from instance metadata for series ${targetSeriesUID}, skipping` - ); - return; - } - - console.log( - `Using middle instance ${targetInstanceUID} (${middleInstanceIndex + 1} of ${seriesMetadata.length}) for series ${targetSeriesUID}` - ); - - // Step 5: Determine middle frame for multiframe - const numberOfFrames = getValue(targetInstanceMetadata, Tags.NumberOfFrames) || 1; - const middleFrame = Math.ceil(numberOfFrames / 2); - - console.log( - `Using middle frame ${middleFrame} of ${numberOfFrames} for instance ${targetInstanceUID}` - ); - - // Step 6: Read pixel data first to get definitive transfer syntax - try { - const pixelData = await readPixelData( - dicomdir, - studyUID, - targetSeriesUID, - targetInstanceMetadata, - middleFrame - ); - - const frameTransferSyntaxUid = pixelData.transferSyntaxUid; - if (!frameTransferSyntaxUid) { - console.warn( - `Could not determine transfer syntax UID for instance ${targetInstanceUID} from pixel data, skipping` - ); - return; - } - - // Step 7: Create writer only after we have definitive transfer syntax - const writer = new FileDicomWebWriter( - { - studyInstanceUid: studyUID, - seriesInstanceUid: targetSeriesUID, - sopInstanceUid: targetInstanceUID, - transferSyntaxUid: frameTransferSyntaxUid, - }, - { baseDir: dicomdir } - ); - - // Convert ArrayBuffer to Uint8Array if needed - let imageFrame = pixelData.binaryData; - if (imageFrame instanceof ArrayBuffer) { - imageFrame = new Uint8Array(imageFrame); - } - - // Step 9: Generate thumbnail and write at instance level - const writeThumbnailCallback = async (buffer, canvasDest) => { - if (!buffer) { - console.warn( - `No thumbnail buffer generated for series ${targetSeriesUID}, instance ${targetInstanceUID}` - ); - return; - } - - console.log(`Writing series thumbnail for instance ${targetInstanceUID}...`); - - // Write thumbnail at instance level: .../instances//thumbnail - const thumbnailStreamInfo = await writer.openSeriesStream('thumbnail', { gzip: false }); - thumbnailStreamInfo.stream.write(Buffer.from(buffer)); - await writer.closeStream(thumbnailStreamInfo.streamKey); - - console.log(`Series thumbnail written successfully for instance ${targetInstanceUID}`); - }; - - // Generate thumbnail using StaticWado's internal method - await StaticWado.internalGenerateImage( - imageFrame, - null, // dataset - not needed when using metadata - targetInstanceMetadata, - frameTransferSyntaxUid, - writeThumbnailCallback - ); - - console.log(`Series thumbnail generation completed for series ${targetSeriesUID}`); - } catch (error) { - console.error( - `Error generating series thumbnail for series ${targetSeriesUID}: ${error.message}` - ); - throw error; - } - }); - - // Wait for all series thumbnails to be generated - try { - await Promise.all(seriesPromises); - console.log(`Series thumbnail generation completed for study ${studyUID}`); - } catch (error) { - console.error(`Error generating series thumbnails: ${error.message}`); - throw error; - } -} - -/** - * Reads pixel data from instance metadata - * @param {string} baseDir - Base directory for DICOMweb structure - * @param {string} studyUID - Study Instance UID - * @param {string} seriesUID - Series Instance UID - * @param {Object} instanceMetadata - Instance metadata object - * @param {number} frameNumber - Frame number (1-based, default: 1) - * @returns {Promise} - Object with binaryData, transferSyntaxUid, and contentType - */ -async function readPixelData(baseDir, studyUID, seriesUID, instanceMetadata, frameNumber = 1) { - const pixelDataTag = Tags.PixelData; - const pixelData = instanceMetadata[pixelDataTag]; - - if (!pixelData) { - throw new Error('No PixelData found in instance metadata'); - } - - const bulkDataURI = pixelData.BulkDataURI; - if (!bulkDataURI) { - throw new Error('No BulkDataURI found in PixelData'); - } - - const studyDir = path.join(baseDir, `studies/${studyUID}`); - const seriesDir = path.join(studyDir, `series/${seriesUID}`); - - // Resolve bulk data path. SeriesSummary writes series-relative paths: - // - Frames: "instances//frames" (resolve from seriesDir) - // - Bulkdata: "../../bulkdata/..." (resolve from seriesDir) - // Legacy instance-relative "./frames" is resolved from instance dir. - let bulkData; - if (bulkDataURI.indexOf('frames') !== -1) { - const isSeriesRelative = bulkDataURI.startsWith('./instances/'); - if (!isSeriesRelative && !getValue(instanceMetadata, Tags.SOPInstanceUID)) { - throw new Error( - 'No SOPInstanceUID in instance metadata; cannot resolve instance-relative frames path' - ); - } - const frameBaseDir = isSeriesRelative - ? seriesDir - : path.join(seriesDir, 'instances', getValue(instanceMetadata, Tags.SOPInstanceUID)); - const frameBaseName = isSeriesRelative ? bulkDataURI : './frames'; - bulkData = await readBulkData(frameBaseDir, frameBaseName, frameNumber); - } else { - bulkData = await readBulkData(seriesDir, bulkDataURI); - } - - if (!bulkData) { - throw new Error(`Failed to read bulk data for frame ${frameNumber}`); - } - - return { - binaryData: bulkData.binaryData, - transferSyntaxUid: - bulkData.transferSyntaxUid || - pixelData.transferSyntaxUid || - getValue(instanceMetadata, Tags.TransferSyntaxUID), - contentType: bulkData.contentType, - }; -} - -/** - * Main function for creating thumbnails - * @param {string} studyUID - Study Instance UID - * @param {Object} options - Options object - * @param {string} [options.dicomdir] - Base directory path where DICOMweb structure is located - * @param {string} [options.seriesUid] - Specific Series Instance UID to process (if not provided, uses first series from study query) - * @param {string} [options.instanceUid] - Specific SOP Instance UID to process (if not provided, uses first instance from series) - * @param {number|number[]} [options.frameNumber] - Frame number to use for thumbnail (default: 1) - deprecated, use frameNumbers instead - * @param {number[]} [options.frameNumbers] - Array of frame numbers to generate thumbnails for (default: [1]) - * @param {boolean} [options.seriesThumbnail] - Generate thumbnails for series (middle SOP instance, middle frame for multiframe) - */ -export async function thumbnailMain(studyUID, options = {}) { - const { dicomdir, seriesUid, instanceUid, frameNumber, frameNumbers, seriesThumbnail } = options; - - // Support both frameNumber (single) and frameNumbers (array) for backward compatibility - const framesToProcess = frameNumbers || (frameNumber ? [frameNumber] : [1]); - - if (!dicomdir) { - throw new Error('dicomdir option is required'); - } - - if (!studyUID) { - throw new Error('studyUID is required'); - } - - const reader = new FileDicomWebReader(dicomdir); - - // If seriesThumbnail is enabled, process series thumbnails - if (seriesThumbnail) { - return await generateSeriesThumbnails(studyUID, { dicomdir, seriesUid, reader }); - } - - let targetSeriesUID = seriesUid; - - // Step 1: If series UID not provided, read study query to find series - if (!targetSeriesUID) { - console.log(`Reading study query to find series for study ${studyUID}...`); - const studyQuery = await reader.readJsonFile(reader.getStudyPath(studyUID), 'index.json'); - - if (!studyQuery || !Array.isArray(studyQuery) || studyQuery.length === 0) { - throw new Error(`No study query found for study ${studyUID}`); - } - - // Read series index to get available series - const seriesIndex = await reader.readJsonFile( - reader.getStudyPath(studyUID, { path: 'series' }), - 'index.json' - ); - - if (!seriesIndex || !Array.isArray(seriesIndex) || seriesIndex.length === 0) { - throw new Error(`No series found for study ${studyUID}`); - } - - // Use the first series - const firstSeries = seriesIndex[0]; - targetSeriesUID = getValue(firstSeries, Tags.SeriesInstanceUID); - - if (!targetSeriesUID) { - throw new Error('Could not extract SeriesInstanceUID from series query'); - } - - console.log(`Using first series: ${targetSeriesUID}`); - } - - // Step 2: Read series metadata - console.log(`Reading series metadata for series ${targetSeriesUID}...`); - const seriesMetadata = await reader.readJsonFile( - reader.getSeriesPath(studyUID, targetSeriesUID), - 'metadata' - ); - - if (!seriesMetadata || !Array.isArray(seriesMetadata) || seriesMetadata.length === 0) { - throw new Error(`No series metadata found for series ${targetSeriesUID}`); - } - - // Step 3: Find instance to use - let targetInstanceMetadata = null; - let targetInstanceUID = instanceUid; - - if (targetInstanceUID) { - // Find specific instance - targetInstanceMetadata = seriesMetadata.find( - instance => getValue(instance, Tags.SOPInstanceUID) === targetInstanceUID - ); - - if (!targetInstanceMetadata) { - throw new Error(`Instance ${targetInstanceUID} not found in series metadata`); - } - } else { - // Use first instance - targetInstanceMetadata = seriesMetadata[0]; - targetInstanceUID = getValue(targetInstanceMetadata, Tags.SOPInstanceUID); - - if (!targetInstanceUID) { - throw new Error('Could not extract SOPInstanceUID from instance metadata'); - } - - console.log(`Using first instance: ${targetInstanceUID}`); - } - - // Step 4: Generate thumbnails for each frame; create writer only after first definitive transfer syntax, then new writer when it changes - console.log( - `Generating thumbnails for ${framesToProcess.length} frame(s): ${framesToProcess.join(', ')}...` - ); - - let writer = null; - let lastTransferSyntaxUid = null; - - for (const frameNum of framesToProcess) { - try { - console.log(`Processing frame ${frameNum}...`); - - // Read pixel data first to get definitive transfer syntax - const pixelData = await readPixelData( - dicomdir, - studyUID, - targetSeriesUID, - targetInstanceMetadata, - frameNum - ); - - const frameTransferSyntaxUid = pixelData.transferSyntaxUid; - if (!frameTransferSyntaxUid) { - throw new Error('Could not determine transfer syntax UID from pixel data'); - } - - // Create writer on first frame or when transfer syntax changes - if (!writer || lastTransferSyntaxUid !== frameTransferSyntaxUid) { - writer = new FileDicomWebWriter( - { - studyInstanceUid: studyUID, - seriesInstanceUid: targetSeriesUID, - sopInstanceUid: targetInstanceUID, - transferSyntaxUid: frameTransferSyntaxUid, - }, - { baseDir: dicomdir } - ); - lastTransferSyntaxUid = frameTransferSyntaxUid; - } - - // Convert ArrayBuffer to Uint8Array if needed - let imageFrame = pixelData.binaryData; - if (imageFrame instanceof ArrayBuffer) { - imageFrame = new Uint8Array(imageFrame); - } - - const thumbnailFilename = framesToProcess.length > 1 ? `thumbnail-${frameNum}` : 'thumbnail'; - - // Callback to write thumbnail (receives buffer and canvasDest) - const writeThumbnailCallback = async (buffer, canvasDest) => { - if (!buffer) { - console.warn(`No thumbnail buffer generated for frame ${frameNum}`); - return; - } - - console.log(`Writing thumbnail for instance ${targetInstanceUID}, frame ${frameNum}...`); - - const thumbnailStreamInfo = await writer.openInstanceStream(thumbnailFilename, { - gzip: false, - }); - thumbnailStreamInfo.stream.write(Buffer.from(buffer)); - await writer.closeStream(thumbnailStreamInfo.streamKey); - - console.log(`Thumbnail written successfully for frame ${frameNum} as ${thumbnailFilename}`); - }; - - await StaticWado.internalGenerateImage( - imageFrame, - null, - targetInstanceMetadata, - frameTransferSyntaxUid, - writeThumbnailCallback - ); - - console.log(`Thumbnail generation completed for frame ${frameNum}`); - } catch (error) { - console.error(`Error generating thumbnail for frame ${frameNum}: ${error.message}`); - throw error; - } - } - - console.log( - `Thumbnail generation completed for study ${studyUID}, series ${targetSeriesUID}, instance ${targetInstanceUID}` - ); -} +import fs from 'fs'; +import path from 'path'; +import { FileDicomWebReader } from '../instance/FileDicomWebReader.mjs'; +import { FileDicomWebWriter } from '../instance/FileDicomWebWriter.mjs'; +import { Tags } from '@radicalimaging/static-wado-util'; +import { readBulkData } from '@radicalimaging/static-wado-util'; +import StaticWado from '@radicalimaging/static-wado-creator'; + +const { getValue } = Tags; + +/** + * Generates thumbnails for series (middle SOP instance, middle frame for multiframe) + * @param {string} studyUID - Study Instance UID + * @param {Object} options - Options object + * @param {string} options.dicomdir - Base directory path where DICOMweb structure is located + * @param {string} [options.seriesUid] - Specific Series Instance UID to process (if not provided, processes all series) + * @param {Object} options.reader - FileDicomWebReader instance + */ +async function generateSeriesThumbnails(studyUID, options = {}) { + const { dicomdir, seriesUid, reader } = options; + + // Step 1: Get list of series to process + const seriesIndex = await reader.readJsonFile( + reader.getStudyPath(studyUID, { path: 'series' }), + 'index.json' + ); + + if (!seriesIndex || !Array.isArray(seriesIndex) || seriesIndex.length === 0) { + throw new Error(`No series found for study ${studyUID}`); + } + + // Filter to specific series if provided, otherwise process all + let seriesToProcess = seriesIndex; + if (seriesUid) { + seriesToProcess = seriesIndex.filter( + series => getValue(series, Tags.SeriesInstanceUID) === seriesUid + ); + if (seriesToProcess.length === 0) { + throw new Error(`Series ${seriesUid} not found in study ${studyUID}`); + } + } + + console.log(`Generating series thumbnails for ${seriesToProcess.length} series...`); + + // Step 2: Process each series + const seriesPromises = seriesToProcess.map(async series => { + const targetSeriesUID = getValue(series, Tags.SeriesInstanceUID); + if (!targetSeriesUID) { + console.warn('Could not extract SeriesInstanceUID from series query, skipping'); + return; + } + + console.log(`Processing series ${targetSeriesUID}...`); + + // Step 3: Read series metadata to get all instances + const seriesMetadata = await reader.readJsonFile( + reader.getSeriesPath(studyUID, targetSeriesUID), + 'metadata' + ); + + if (!seriesMetadata || !Array.isArray(seriesMetadata) || seriesMetadata.length === 0) { + console.warn(`No series metadata found for series ${targetSeriesUID}, skipping`); + return; + } + + // Step 4: Choose middle SOP instance + const middleInstanceIndex = Math.floor(seriesMetadata.length / 2); + const targetInstanceMetadata = seriesMetadata[middleInstanceIndex]; + const targetInstanceUID = getValue(targetInstanceMetadata, Tags.SOPInstanceUID); + + if (!targetInstanceUID) { + console.warn( + `Could not extract SOPInstanceUID from instance metadata for series ${targetSeriesUID}, skipping` + ); + return; + } + + console.log( + `Using middle instance ${targetInstanceUID} (${middleInstanceIndex + 1} of ${seriesMetadata.length}) for series ${targetSeriesUID}` + ); + + // Step 5: Determine middle frame for multiframe + const numberOfFrames = getValue(targetInstanceMetadata, Tags.NumberOfFrames) || 1; + const middleFrame = Math.ceil(numberOfFrames / 2); + + console.log( + `Using middle frame ${middleFrame} of ${numberOfFrames} for instance ${targetInstanceUID}` + ); + + // Step 6: Read pixel data first to get definitive transfer syntax + try { + const pixelData = await readPixelData( + dicomdir, + studyUID, + targetSeriesUID, + targetInstanceMetadata, + middleFrame + ); + + const frameTransferSyntaxUid = pixelData.transferSyntaxUid; + if (!frameTransferSyntaxUid) { + console.warn( + `Could not determine transfer syntax UID for instance ${targetInstanceUID} from pixel data, skipping` + ); + return; + } + + // Step 7: Create writer only after we have definitive transfer syntax + const writer = new FileDicomWebWriter( + { + studyInstanceUid: studyUID, + seriesInstanceUid: targetSeriesUID, + sopInstanceUid: targetInstanceUID, + transferSyntaxUid: frameTransferSyntaxUid, + }, + { baseDir: dicomdir } + ); + + // Convert ArrayBuffer to Uint8Array if needed + let imageFrame = pixelData.binaryData; + if (imageFrame instanceof ArrayBuffer) { + imageFrame = new Uint8Array(imageFrame); + } + + // Step 9: Generate thumbnail and write at instance level + const writeThumbnailCallback = async (buffer, canvasDest) => { + if (!buffer) { + console.warn( + `No thumbnail buffer generated for series ${targetSeriesUID}, instance ${targetInstanceUID}` + ); + return; + } + + console.log(`Writing series thumbnail for instance ${targetInstanceUID}...`); + + // Write thumbnail at instance level: .../instances//thumbnail + const thumbnailStreamInfo = await writer.openSeriesStream('thumbnail', { gzip: false }); + thumbnailStreamInfo.stream.write(Buffer.from(buffer)); + await writer.closeStream(thumbnailStreamInfo.streamKey); + + console.log(`Series thumbnail written successfully for instance ${targetInstanceUID}`); + }; + + // Generate thumbnail using StaticWado's internal method + await StaticWado.internalGenerateImage( + imageFrame, + null, // dataset - not needed when using metadata + targetInstanceMetadata, + frameTransferSyntaxUid, + writeThumbnailCallback + ); + + console.log(`Series thumbnail generation completed for series ${targetSeriesUID}`); + } catch (error) { + console.error( + `Error generating series thumbnail for series ${targetSeriesUID}: ${error.message}` + ); + throw error; + } + }); + + // Wait for all series thumbnails to be generated + try { + await Promise.all(seriesPromises); + console.log(`Series thumbnail generation completed for study ${studyUID}`); + } catch (error) { + console.error(`Error generating series thumbnails: ${error.message}`); + throw error; + } +} + +/** + * Reads pixel data from instance metadata + * @param {string} baseDir - Base directory for DICOMweb structure + * @param {string} studyUID - Study Instance UID + * @param {string} seriesUID - Series Instance UID + * @param {Object} instanceMetadata - Instance metadata object + * @param {number} frameNumber - Frame number (1-based, default: 1) + * @returns {Promise} - Object with binaryData, transferSyntaxUid, and contentType + */ +async function readPixelData(baseDir, studyUID, seriesUID, instanceMetadata, frameNumber = 1) { + const pixelDataTag = Tags.PixelData; + const pixelData = instanceMetadata[pixelDataTag]; + + if (!pixelData) { + throw new Error('No PixelData found in instance metadata'); + } + + const bulkDataURI = pixelData.BulkDataURI; + if (!bulkDataURI) { + throw new Error('No BulkDataURI found in PixelData'); + } + + const studyDir = path.join(baseDir, `studies/${studyUID}`); + const seriesDir = path.join(studyDir, `series/${seriesUID}`); + + // Resolve bulk data path. SeriesSummary writes series-relative paths: + // - Frames: "instances//frames" (resolve from seriesDir) + // - Bulkdata: "../../bulkdata/..." (resolve from seriesDir) + // Legacy instance-relative "./frames" is resolved from instance dir. + let bulkData; + if (bulkDataURI.indexOf('frames') !== -1) { + const isSeriesRelative = bulkDataURI.startsWith('./instances/'); + if (!isSeriesRelative && !getValue(instanceMetadata, Tags.SOPInstanceUID)) { + throw new Error( + 'No SOPInstanceUID in instance metadata; cannot resolve instance-relative frames path' + ); + } + const frameBaseDir = isSeriesRelative + ? seriesDir + : path.join(seriesDir, 'instances', getValue(instanceMetadata, Tags.SOPInstanceUID)); + const frameBaseName = isSeriesRelative ? bulkDataURI : './frames'; + bulkData = await readBulkData(frameBaseDir, frameBaseName, frameNumber); + } else { + bulkData = await readBulkData(seriesDir, bulkDataURI); + } + + if (!bulkData) { + throw new Error(`Failed to read bulk data for frame ${frameNumber}`); + } + + return { + binaryData: bulkData.binaryData, + transferSyntaxUid: + bulkData.transferSyntaxUid || + pixelData.transferSyntaxUid || + getValue(instanceMetadata, Tags.TransferSyntaxUID), + contentType: bulkData.contentType, + }; +} + +/** + * Main function for creating thumbnails + * @param {string} studyUID - Study Instance UID + * @param {Object} options - Options object + * @param {string} [options.dicomdir] - Base directory path where DICOMweb structure is located + * @param {string} [options.seriesUid] - Specific Series Instance UID to process (if not provided, uses first series from study query) + * @param {string} [options.instanceUid] - Specific SOP Instance UID to process (if not provided, uses first instance from series) + * @param {number|number[]} [options.frameNumber] - Frame number to use for thumbnail (default: 1) - deprecated, use frameNumbers instead + * @param {number[]} [options.frameNumbers] - Array of frame numbers to generate thumbnails for (default: [1]) + * @param {boolean} [options.seriesThumbnail] - Generate thumbnails for series (middle SOP instance, middle frame for multiframe) + */ +export async function thumbnailMain(studyUID, options = {}) { + const { dicomdir, seriesUid, instanceUid, frameNumber, frameNumbers, seriesThumbnail } = options; + + // Support both frameNumber (single) and frameNumbers (array) for backward compatibility + const framesToProcess = frameNumbers || (frameNumber ? [frameNumber] : [1]); + + if (!dicomdir) { + throw new Error('dicomdir option is required'); + } + + if (!studyUID) { + throw new Error('studyUID is required'); + } + + const reader = new FileDicomWebReader(dicomdir); + + // If seriesThumbnail is enabled, process series thumbnails + if (seriesThumbnail) { + return await generateSeriesThumbnails(studyUID, { dicomdir, seriesUid, reader }); + } + + let targetSeriesUID = seriesUid; + + // Step 1: If series UID not provided, read study query to find series + if (!targetSeriesUID) { + console.log(`Reading study query to find series for study ${studyUID}...`); + const studyQuery = await reader.readJsonFile(reader.getStudyPath(studyUID), 'index.json'); + + if (!studyQuery || !Array.isArray(studyQuery) || studyQuery.length === 0) { + throw new Error(`No study query found for study ${studyUID}`); + } + + // Read series index to get available series + const seriesIndex = await reader.readJsonFile( + reader.getStudyPath(studyUID, { path: 'series' }), + 'index.json' + ); + + if (!seriesIndex || !Array.isArray(seriesIndex) || seriesIndex.length === 0) { + throw new Error(`No series found for study ${studyUID}`); + } + + // Use the first series + const firstSeries = seriesIndex[0]; + targetSeriesUID = getValue(firstSeries, Tags.SeriesInstanceUID); + + if (!targetSeriesUID) { + throw new Error('Could not extract SeriesInstanceUID from series query'); + } + + console.log(`Using first series: ${targetSeriesUID}`); + } + + // Step 2: Read series metadata + console.log(`Reading series metadata for series ${targetSeriesUID}...`); + const seriesMetadata = await reader.readJsonFile( + reader.getSeriesPath(studyUID, targetSeriesUID), + 'metadata' + ); + + if (!seriesMetadata || !Array.isArray(seriesMetadata) || seriesMetadata.length === 0) { + throw new Error(`No series metadata found for series ${targetSeriesUID}`); + } + + // Step 3: Find instance to use + let targetInstanceMetadata = null; + let targetInstanceUID = instanceUid; + + if (targetInstanceUID) { + // Find specific instance + targetInstanceMetadata = seriesMetadata.find( + instance => getValue(instance, Tags.SOPInstanceUID) === targetInstanceUID + ); + + if (!targetInstanceMetadata) { + throw new Error(`Instance ${targetInstanceUID} not found in series metadata`); + } + } else { + // Use first instance + targetInstanceMetadata = seriesMetadata[0]; + targetInstanceUID = getValue(targetInstanceMetadata, Tags.SOPInstanceUID); + + if (!targetInstanceUID) { + throw new Error('Could not extract SOPInstanceUID from instance metadata'); + } + + console.log(`Using first instance: ${targetInstanceUID}`); + } + + // Step 4: Generate thumbnails for each frame; create writer only after first definitive transfer syntax, then new writer when it changes + console.log( + `Generating thumbnails for ${framesToProcess.length} frame(s): ${framesToProcess.join(', ')}...` + ); + + let writer = null; + let lastTransferSyntaxUid = null; + + for (const frameNum of framesToProcess) { + try { + console.log(`Processing frame ${frameNum}...`); + + // Read pixel data first to get definitive transfer syntax + const pixelData = await readPixelData( + dicomdir, + studyUID, + targetSeriesUID, + targetInstanceMetadata, + frameNum + ); + + const frameTransferSyntaxUid = pixelData.transferSyntaxUid; + if (!frameTransferSyntaxUid) { + throw new Error('Could not determine transfer syntax UID from pixel data'); + } + + // Create writer on first frame or when transfer syntax changes + if (!writer || lastTransferSyntaxUid !== frameTransferSyntaxUid) { + writer = new FileDicomWebWriter( + { + studyInstanceUid: studyUID, + seriesInstanceUid: targetSeriesUID, + sopInstanceUid: targetInstanceUID, + transferSyntaxUid: frameTransferSyntaxUid, + }, + { baseDir: dicomdir } + ); + lastTransferSyntaxUid = frameTransferSyntaxUid; + } + + // Convert ArrayBuffer to Uint8Array if needed + let imageFrame = pixelData.binaryData; + if (imageFrame instanceof ArrayBuffer) { + imageFrame = new Uint8Array(imageFrame); + } + + const thumbnailFilename = framesToProcess.length > 1 ? `thumbnail-${frameNum}` : 'thumbnail'; + + // Callback to write thumbnail (receives buffer and canvasDest) + const writeThumbnailCallback = async (buffer, canvasDest) => { + if (!buffer) { + console.warn(`No thumbnail buffer generated for frame ${frameNum}`); + return; + } + + console.log(`Writing thumbnail for instance ${targetInstanceUID}, frame ${frameNum}...`); + + const thumbnailStreamInfo = await writer.openInstanceStream(thumbnailFilename, { + gzip: false, + }); + thumbnailStreamInfo.stream.write(Buffer.from(buffer)); + await writer.closeStream(thumbnailStreamInfo.streamKey); + + console.log(`Thumbnail written successfully for frame ${frameNum} as ${thumbnailFilename}`); + }; + + await StaticWado.internalGenerateImage( + imageFrame, + null, + targetInstanceMetadata, + frameTransferSyntaxUid, + writeThumbnailCallback + ); + + console.log(`Thumbnail generation completed for frame ${frameNum}`); + } catch (error) { + console.error(`Error generating thumbnail for frame ${frameNum}: ${error.message}`); + throw error; + } + } + + console.log( + `Thumbnail generation completed for study ${studyUID}, series ${targetSeriesUID}, instance ${targetInstanceUID}` + ); +} diff --git a/packages/create-dicomweb/package.json b/packages/create-dicomweb/package.json index 12d8005c..f6ef7dcf 100644 --- a/packages/create-dicomweb/package.json +++ b/packages/create-dicomweb/package.json @@ -50,21 +50,21 @@ "verbose": true }, "dependencies": { - "@radicalimaging/cs3d": ">=1.7.0", - "@radicalimaging/static-wado-creator": ">=1.7.0", - "@radicalimaging/static-wado-util": ">=1.7.0", - "adm-zip": "^0.5.16", - "canvas": ">=3.1.0", - "commander": "^12.0.0", - "config-point": ">=0.5.1", - "dcmjs": "0.50.0", - "dicom-parser": "^1.8.13", - "glob": "^11.0.0", - "node-object-hash": "^3.0.0", - "uuid": "^11.0.2" + "@radicalimaging/cs3d": "workspace:*", + "@radicalimaging/static-wado-creator": "workspace:*", + "@radicalimaging/static-wado-util": "workspace:*", + "adm-zip": "0.5.16", + "canvas": "3.1.0", + "commander": "12.1.0", + "config-point": "0.8.0", + "dcmjs": "0.50.1", + "dicom-parser": "1.8.21", + "glob": "11.1.0", + "node-object-hash": "3.1.1", + "uuid": "11.1.0" }, "devDependencies": { - "must": "^0.13.4" + "must": "0.13.4" }, "scripts": { "test": "jest --config ./jest.config.cjs", diff --git a/packages/cs3d/package.json b/packages/cs3d/package.json index aec9e858..b6b720da 100644 --- a/packages/cs3d/package.json +++ b/packages/cs3d/package.json @@ -74,14 +74,14 @@ "publishJS": "yarn publish --no-git-tag-version" }, "dependencies": { - "@cornerstonejs/core": "^4.22.3", + "@cornerstonejs/core": "4.22.3", "canvas": "3.1.0", - "dcmjs": "0.50.0", - "jsdom": "^26.0.0", - "jsdom-context-require": "^5.2.4" + "dcmjs": "0.50.1", + "jsdom": "26.1.0", + "jsdom-context-require": "5.2.7" }, "devDependencies": { - "@babel/plugin-proposal-class-properties": "^7.18.6", - "babel-plugin-transform-import-meta": "^2.3.3" + "@babel/plugin-proposal-class-properties": "7.18.6", + "babel-plugin-transform-import-meta": "2.3.3" } } diff --git a/packages/healthlake/package.json b/packages/healthlake/package.json index 1d647951..b2617e8f 100644 --- a/packages/healthlake/package.json +++ b/packages/healthlake/package.json @@ -55,18 +55,18 @@ "verbose": true }, "dependencies": { - "@radicalimaging/s3-deploy": ">=1.7.0", - "@radicalimaging/static-wado-deploy": ">=1.7.0", - "@radicalimaging/static-wado-plugins": ">=1.7.0", - "@radicalimaging/static-wado-util": ">=1.7.0", - "config-point": ">=0.5.1", - "cross-env": "^7.0.3", - "dcmjs": "0.50.0", - "dicom-parser": "^1.8.13", - "node-object-hash": "^3.0.0" + "@radicalimaging/s3-deploy": "workspace:*", + "@radicalimaging/static-wado-deploy": "workspace:*", + "@radicalimaging/static-wado-plugins": "workspace:*", + "@radicalimaging/static-wado-util": "workspace:*", + "config-point": "0.8.0", + "cross-env": "7.0.3", + "dcmjs": "0.50.1", + "dicom-parser": "1.8.21", + "node-object-hash": "3.1.1" }, "devDependencies": { - "must": "^0.13.4", - "ts-jest": "^28.0.2" + "must": "0.13.4", + "ts-jest": "28.0.8" } } \ No newline at end of file diff --git a/packages/s3-deploy/package.json b/packages/s3-deploy/package.json index 068f7716..8a1b1a3c 100644 --- a/packages/s3-deploy/package.json +++ b/packages/s3-deploy/package.json @@ -82,16 +82,16 @@ "verbose": true }, "dependencies": { - "@aws-sdk/client-s3": "^3.374.0", - "@aws-sdk/lib-storage": "^3.862.0", - "@radicalimaging/static-wado-plugins": ">=1.7.0", - "@radicalimaging/static-wado-util": ">=1.7.0", - "aws-cdk-lib": "^2.251.0", - "config-point": ">=0.5.1", - "constructs": "^10.0.94", - "mime-types": "^2.1.35" + "@aws-sdk/client-s3": "3.975.0", + "@aws-sdk/lib-storage": "3.975.0", + "@radicalimaging/static-wado-plugins": "workspace:*", + "@radicalimaging/static-wado-util": "workspace:*", + "aws-cdk-lib": "2.251.0", + "config-point": "0.8.0", + "constructs": "10.4.5", + "mime-types": "2.1.35" }, "devDependencies": { - "aws-cdk": "^2.144.0" + "aws-cdk": "2.1103.0" } } diff --git a/packages/static-wado-creator/package.json b/packages/static-wado-creator/package.json index 62e45869..dd64ba8a 100644 --- a/packages/static-wado-creator/package.json +++ b/packages/static-wado-creator/package.json @@ -66,17 +66,17 @@ "verbose": true }, "dependencies": { - "@radicalimaging/cs3d": ">=1.7.0", - "@radicalimaging/static-wado-util": ">=1.7.0", - "canvas": ">=3.1.0", - "config-point": ">=0.5.1", - "dcmjs": "0.50.0", - "dicom-parser": "^1.8.13", - "glob": "^11.0.0", - "node-object-hash": "^3.0.0", - "uuid": "^11.0.2" + "@radicalimaging/cs3d": "workspace:*", + "@radicalimaging/static-wado-util": "workspace:*", + "canvas": "3.1.0", + "config-point": "0.8.0", + "dcmjs": "0.50.1", + "dicom-parser": "1.8.21", + "glob": "11.1.0", + "node-object-hash": "3.1.1", + "uuid": "11.1.0" }, "devDependencies": { - "must": "^0.13.4" + "must": "0.13.4" } } diff --git a/packages/static-wado-deploy/package.json b/packages/static-wado-deploy/package.json index 8eba3696..9d99d1ba 100644 --- a/packages/static-wado-deploy/package.json +++ b/packages/static-wado-deploy/package.json @@ -53,15 +53,15 @@ "verbose": true }, "dependencies": { - "@radicalimaging/s3-deploy": ">=1.7.0", - "@radicalimaging/static-wado-plugins": ">=1.7.0", - "@radicalimaging/static-wado-util": ">=1.7.0", - "config-point": ">=0.5.1", - "node-object-hash": "^3.0.0" + "@radicalimaging/s3-deploy": "workspace:*", + "@radicalimaging/static-wado-plugins": "workspace:*", + "@radicalimaging/static-wado-util": "workspace:*", + "config-point": "0.8.0", + "node-object-hash": "3.1.1" }, "devDependencies": { - "must": "^0.13.4", - "ts-jest": "^28.0.2" + "must": "0.13.4", + "ts-jest": "28.0.8" }, "scripts": { "test": "jest --config ./jest.config.js", diff --git a/packages/static-wado-plugins/package.json b/packages/static-wado-plugins/package.json index 74ca1129..02e6fb22 100644 --- a/packages/static-wado-plugins/package.json +++ b/packages/static-wado-plugins/package.json @@ -69,12 +69,12 @@ "verbose": true }, "dependencies": { - "@radicalimaging/static-wado-util": ">=1.7.0", - "config-point": ">=0.5.1", - "dcmjs-dimse": "^0.1.30", - "http-proxy-middleware": "^2.0.6" + "@radicalimaging/static-wado-util": "workspace:*", + "config-point": "0.8.0", + "dcmjs-dimse": "0.1.31", + "http-proxy-middleware": "2.0.9" }, "devDependencies": { - "must": "^0.13.4" + "must": "0.13.4" } } diff --git a/packages/static-wado-scp/package.json b/packages/static-wado-scp/package.json index 0efe29a3..8631dfe2 100644 --- a/packages/static-wado-scp/package.json +++ b/packages/static-wado-scp/package.json @@ -63,14 +63,14 @@ "publishJS": "yarn publish --no-git-tag-version" }, "dependencies": { - "@radicalimaging/static-wado-creator": ">=1.7.0", - "@radicalimaging/static-wado-plugins": ">=1.7.0", - "@radicalimaging/static-wado-util": ">=1.7.0", - "config-point": ">=0.5.1", - "dcmjs": "0.50.0", - "dcmjs-dimse": "^0.1.30" + "@radicalimaging/static-wado-creator": "workspace:*", + "@radicalimaging/static-wado-plugins": "workspace:*", + "@radicalimaging/static-wado-util": "workspace:*", + "config-point": "0.8.0", + "dcmjs": "0.50.1", + "dcmjs-dimse": "0.1.31" }, "devDependencies": { - "must": "^0.13.4" + "must": "0.13.4" } } diff --git a/packages/static-wado-util/package.json b/packages/static-wado-util/package.json index 105f2b58..13fb2e33 100644 --- a/packages/static-wado-util/package.json +++ b/packages/static-wado-util/package.json @@ -56,18 +56,18 @@ "bin" ], "dependencies": { - "@cornerstonejs/codec-openjpeg": ">=1.2.3", - "@cornerstonejs/codec-openjph": ">=2.4.5", - "@cornerstonejs/dicom-codec": ">=1.0.5", - "commander": "^10.0.0", - "config-point": ">=0.5.1", - "dcmjs": "0.50.0", - "imurmurhash": "^0.1.4", - "jpeg-lossless-decoder-js": ">=2.0.4", - "json5": "^2.2.1", + "@cornerstonejs/codec-openjpeg": "1.3.0", + "@cornerstonejs/codec-openjph": "2.4.7", + "@cornerstonejs/dicom-codec": "1.0.9", + "commander": "10.0.1", + "config-point": "0.8.0", + "dcmjs": "0.50.1", + "imurmurhash": "0.1.4", + "jpeg-lossless-decoder-js": "2.1.2", + "json5": "2.2.3", "loglevel": "1.9.2", - "must": "^0.13.4", - "node-object-hash": "^3.0.0" + "must": "0.13.4", + "node-object-hash": "3.1.1" }, "scripts": { "test": "jest --config ./jest.config.cjs", @@ -81,6 +81,6 @@ "publishJS": "yarn publish --no-git-tag-version" }, "devDependencies": { - "must": "^0.13.4" + "must": "0.13.4" } } diff --git a/packages/static-wado-webserver/lib/controllers/server/multipartStream.mjs b/packages/static-wado-webserver/lib/controllers/server/multipartStream.mjs index 10706b8a..5f36fbcb 100644 --- a/packages/static-wado-webserver/lib/controllers/server/multipartStream.mjs +++ b/packages/static-wado-webserver/lib/controllers/server/multipartStream.mjs @@ -1,4 +1,4 @@ -import Dicer from 'dicer'; +import Dicer from '../../vendor/dicer-compat/Dicer.mjs'; import { randomUUID } from 'node:crypto'; import { data } from 'dcmjs'; import { parse as parseContentType } from 'content-type'; diff --git a/packages/static-wado-webserver/lib/vendor/dicer-compat/Dicer.mjs b/packages/static-wado-webserver/lib/vendor/dicer-compat/Dicer.mjs new file mode 100644 index 00000000..baf082f9 --- /dev/null +++ b/packages/static-wado-webserver/lib/vendor/dicer-compat/Dicer.mjs @@ -0,0 +1,217 @@ +import { Writable } from 'node:stream'; +import StreamSearch from 'streamsearch'; +import PartStream from './PartStream.mjs'; +import HeaderParser from './HeaderParser.mjs'; + +const DASH = 45; +const B_ONEDASH = Buffer.from('-'); +const B_CRLF = Buffer.from('\r\n'); +const EMPTY_FN = () => {}; + +export default class Dicer extends Writable { + constructor(cfg) { + super(cfg); + + if (!cfg || (!cfg.headerFirst && typeof cfg.boundary !== 'string')) + throw new TypeError('Boundary required'); + + if (typeof cfg.boundary === 'string') this.setBoundary(cfg.boundary); + else this._bparser = undefined; + + this._headerFirst = cfg.headerFirst; + + this._dashes = 0; + this._parts = 0; + this._finished = false; + this._realFinish = false; + this._isPreamble = true; + this._justMatched = false; + this._firstWrite = true; + this._inHeader = true; + this._part = undefined; + this._cb = undefined; + this._ignoreData = false; + this._partOpts = typeof cfg.partHwm === 'number' ? { highWaterMark: cfg.partHwm } : {}; + this._pause = false; + + this._hparser = new HeaderParser(cfg); + this._hparser.on('header', header => { + this._inHeader = false; + this._part.emit('header', header); + }); + this._hparser.on('error', err => { + if (this._part && !this._ignoreData) { + this._part.emit('error', err); + this._part.push(null); + } + }); + } + + emit(ev) { + if (ev !== 'finish' || this._realFinish) { + Writable.prototype.emit.apply(this, arguments); + return; + } + + if (this._finished) return; + + process.nextTick(() => { + this.emit('error', new Error('Unexpected end of multipart data')); + + if (this._part && !this._ignoreData) { + const type = this._isPreamble ? 'Preamble' : 'Part'; + this._part.emit( + 'error', + new Error( + `${type} terminated early due to unexpected end of multipart data` + ) + ); + this._part.push(null); + process.nextTick(() => { + this._realFinish = true; + this.emit('finish'); + this._realFinish = false; + }); + return; + } + + this._realFinish = true; + this.emit('finish'); + this._realFinish = false; + }); + } + + _write(data, encoding, cb) { + if (!this._hparser && !this._bparser) return cb(); + + if (this._headerFirst && this._isPreamble) { + if (!this._part) { + this._part = new PartStream(this._partOpts); + if (this._events.preamble) this.emit('preamble', this._part); + else ignore(this); + } + const r = this._hparser.push(data); + if (!this._inHeader && r !== undefined && r < data.length) data = data.slice(r); + else return cb(); + } + + if (this._firstWrite) { + this._bparser.push(B_CRLF); + this._firstWrite = false; + } + + this._bparser.push(data); + + if (this._pause) this._cb = cb; + else cb(); + } + + reset() { + this._part = undefined; + this._bparser = undefined; + this._hparser = undefined; + } + + setBoundary(boundary) { + this._bparser = new StreamSearch(`\r\n--${boundary}`, onInfo.bind(this)); + } +} + +function onInfo(isMatch, data, start, end) { + let buf; + let i = 0; + let r; + let ev; + let shouldWriteMore = true; + + if (!this._part && this._justMatched && data) { + while (this._dashes < 2 && start + i < end) { + if (data[start + i] === DASH) { + ++i; + ++this._dashes; + } else { + if (this._dashes) buf = B_ONEDASH; + this._dashes = 0; + break; + } + } + if (this._dashes === 2) { + if (start + i < end && this._events.trailer) + this.emit('trailer', data.slice(start + i, end)); + this.reset(); + this._finished = true; + if (this._parts === 0) { + this._realFinish = true; + this.emit('finish'); + this._realFinish = false; + } + } + if (this._dashes) return; + } + if (this._justMatched) this._justMatched = false; + if (!this._part) { + this._part = new PartStream(this._partOpts); + this._part._read = () => { + unpause(this); + }; + ev = this._isPreamble ? 'preamble' : 'part'; + if (this._events[ev]) this.emit(ev, this._part); + else ignore(this); + if (!this._isPreamble) this._inHeader = true; + } + if (data && start < end && !this._ignoreData) { + if (this._isPreamble || !this._inHeader) { + if (buf) shouldWriteMore = this._part.push(buf); + shouldWriteMore = this._part.push(data.slice(start, end)); + if (!shouldWriteMore) this._pause = true; + } else if (!this._isPreamble && this._inHeader) { + if (buf) this._hparser.push(buf); + r = this._hparser.push(data.slice(start, end)); + if (!this._inHeader && r !== undefined && r < end) + onInfo.call(this, false, data, start + r, end); + } + } + if (isMatch) { + this._hparser.reset(); + if (this._isPreamble) { + this._isPreamble = false; + } else { + ++this._parts; + this._part.on('end', () => { + if (--this._parts === 0) { + if (this._finished) { + this._realFinish = true; + this.emit('finish'); + this._realFinish = false; + } else { + unpause(this); + } + } + }); + } + this._part.push(null); + this._part = undefined; + this._ignoreData = false; + this._justMatched = true; + this._dashes = 0; + } +} + +function ignore(self) { + if (self._part && !self._ignoreData) { + self._ignoreData = true; + self._part.on('error', EMPTY_FN); + self._part.resume(); + } +} + +function unpause(self) { + if (!self._pause) return; + + self._pause = false; + if (self._cb) { + const cb = self._cb; + self._cb = undefined; + cb(); + } +} diff --git a/packages/static-wado-webserver/lib/vendor/dicer-compat/HeaderParser.mjs b/packages/static-wado-webserver/lib/vendor/dicer-compat/HeaderParser.mjs new file mode 100644 index 00000000..275ab524 --- /dev/null +++ b/packages/static-wado-webserver/lib/vendor/dicer-compat/HeaderParser.mjs @@ -0,0 +1,106 @@ +import { EventEmitter } from 'node:events'; +import StreamSearch from 'streamsearch'; + +const B_DCRLF = Buffer.from('\r\n\r\n'); +const RE_CRLF = /\r\n/g; +// eslint-disable-next-line no-control-regex +const RE_HDR = /^([^:]+):[ \t]?([\x00-\xFF]+)?$/; +const MAX_HEADER_PAIRS = 2000; // From node's http.js +// Tighter than legacy dicer (80 KiB); matches busboy's multipart header cap +const MAX_HEADER_SIZE = 16 * 1024; + +export default class HeaderParser extends EventEmitter { + constructor(cfg) { + super(); + + this.nread = 0; + this.maxed = false; + this.npairs = 0; + this.maxHeaderPairs = + cfg && typeof cfg.maxHeaderPairs === 'number' ? cfg.maxHeaderPairs : MAX_HEADER_PAIRS; + this.buffer = ''; + this.header = {}; + this.finished = false; + this.ss = new StreamSearch(B_DCRLF, (isMatch, data, start, end) => { + if (data && !this.maxed) { + if (this.nread + (end - start) > MAX_HEADER_SIZE) { + end = MAX_HEADER_SIZE - this.nread; + this.nread = MAX_HEADER_SIZE; + } else { + this.nread += end - start; + } + + if (this.nread === MAX_HEADER_SIZE) this.maxed = true; + + this.buffer += data.toString('latin1', start, end); + } + if (isMatch) this._finish(); + }); + } + + push(data) { + const r = this.ss.push(data); + if (this.finished) return r; + } + + reset() { + this.finished = false; + this.buffer = ''; + this.header = {}; + this.ss.reset(); + } + + _finish() { + let hadError = false; + if (this.buffer) hadError = !parseHeader(this); + this.ss.matches = this.ss.maxMatches; + const header = this.header; + this.header = {}; + this.buffer = ''; + this.finished = true; + this.nread = this.npairs = 0; + this.maxed = false; + if (!hadError) this.emit('header', header); + } +} + +function parseHeader(self) { + if (self.npairs === self.maxHeaderPairs) return true; + + const lines = self.buffer.split(RE_CRLF); + const len = lines.length; + let m; + let h; + let modded = false; + + for (let i = 0; i < len; ++i) { + if (lines[i].length === 0) continue; + + if (lines[i][0] === '\t' || lines[i][0] === ' ') { + if (!h) { + self.emit('error', new Error('Unexpected folded header value')); + return false; + } + self.header[h][self.header[h].length - 1] += lines[i]; + } else { + m = RE_HDR.exec(lines[i]); + if (m) { + h = m[1].toLowerCase(); + if (m[2]) { + if (self.header[h] === undefined) self.header[h] = [m[2]]; + else self.header[h].push(m[2]); + } else { + self.header[h] = ['']; + } + if (++self.npairs === self.maxHeaderPairs) break; + } else { + self.buffer = lines[i]; + modded = true; + break; + } + } + } + if (!modded) self.buffer = ''; + + return true; +} diff --git a/packages/static-wado-webserver/lib/vendor/dicer-compat/LICENSE.dicer.md b/packages/static-wado-webserver/lib/vendor/dicer-compat/LICENSE.dicer.md new file mode 100644 index 00000000..a0960c35 --- /dev/null +++ b/packages/static-wado-webserver/lib/vendor/dicer-compat/LICENSE.dicer.md @@ -0,0 +1,7 @@ +Vendored from dicer (MIT), https://github.com/mscdex/dicer + +Copyright Brian White. See upstream LICENSE in the dicer repository. + +This copy is inlined to avoid the unmaintained `dicer` npm package (GHSA-wm7h-9275-46v2) +while preserving streaming multipart parsing behavior required for STOW-RS +(`multipart/related`), which differs from `busboy`'s `multipart/form-data` parser. diff --git a/packages/static-wado-webserver/lib/vendor/dicer-compat/PartStream.mjs b/packages/static-wado-webserver/lib/vendor/dicer-compat/PartStream.mjs new file mode 100644 index 00000000..7786fa7e --- /dev/null +++ b/packages/static-wado-webserver/lib/vendor/dicer-compat/PartStream.mjs @@ -0,0 +1,5 @@ +import { Readable } from 'node:stream'; + +export default class PartStream extends Readable { + _read(n) {} +} diff --git a/packages/static-wado-webserver/package.json b/packages/static-wado-webserver/package.json index 8e3deae6..522a6fde 100644 --- a/packages/static-wado-webserver/package.json +++ b/packages/static-wado-webserver/package.json @@ -65,27 +65,26 @@ }, "dependencies": { "@cornerstonejs/dicom-codec": "1.0.9", - "@radicalimaging/create-dicomweb": ">=1.7.0", - "@radicalimaging/static-wado-plugins": ">=1.7.0", - "@radicalimaging/static-wado-util": ">=1.7.0", - "@radicalimaging/static-wado-webserver": ">=1.7.0", + "@radicalimaging/create-dicomweb": "workspace:*", + "@radicalimaging/static-wado-plugins": "workspace:*", + "@radicalimaging/static-wado-util": "workspace:*", "@saga-bus/core": "0.2.2", - "config-point": ">=0.5.1", + "config-point": "0.8.0", "content-type": "1.0.5", - "cors": "^2.8.5", - "dcmjs": "0.50.0", - "dcmjs-dimse": "^0.1.30", - "dicer": "0.3.1", - "express": "^5.2.1", - "formidable": "^3.5.4", - "morgan": "^1.10.0", - "regenerator-runtime": "^0.14.1", - "rxjs": "^7.5.4", - "ws": "^8.18.0" + "cors": "2.8.6", + "dcmjs": "0.50.1", + "dcmjs-dimse": "0.1.31", + "express": "5.2.1", + "formidable": "3.5.4", + "morgan": "1.10.1", + "regenerator-runtime": "0.14.1", + "rxjs": "7.8.2", + "streamsearch": "1.1.0", + "ws": "8.19.0" }, "devDependencies": { - "@babel/plugin-transform-runtime": "^7.17.0", + "@babel/plugin-transform-runtime": "7.28.5", "jest": "29.7.0", - "must": "^0.13.4" + "must": "0.13.4" } } diff --git a/scripts/check-pinned-versions.sh b/scripts/check-pinned-versions.sh new file mode 100644 index 00000000..5e26fc70 --- /dev/null +++ b/scripts/check-pinned-versions.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Fails if any package.json dependency version is not pinned (no ^ ~ * >= ranges). +# Allows workspace:, file:, npm:, and http(s): specifiers. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +is_pinned() { + local version="$1" + [[ -z "$version" ]] && return 1 + [[ "$version" =~ ^(workspace:|file:|npm:) ]] && return 0 + [[ "$version" =~ ^https?:// ]] && return 0 + [[ "$version" =~ [\^~*] ]] && return 1 + [[ "$version" == *">="* ]] && return 1 + [[ "$version" == *"<="* ]] && return 1 + return 0 +} + +check_dep_block() { + local pkg="$1" + local block="$2" + local entry name version + + while IFS= read -r entry; do + [[ -z "$entry" ]] && continue + name="${entry%%$'\t'*}" + version="${entry#*$'\t'}" + if ! is_pinned "$version"; then + echo "Unpinned dependency in ${pkg} ${block}: ${name} -> ${version}" >&2 + return 1 + fi + done < <(jq -r --arg b "$block" '.[$b] // {} | to_entries[] | "\(.key)\t\(.value)"' "$pkg" 2>/dev/null || true) +} + +failed=0 +while IFS= read -r -d '' pkg; do + for block in dependencies devDependencies optionalDependencies peerDependencies overrides; do + if ! check_dep_block "$pkg" "$block"; then + failed=1 + fi + done +done < <(find "$REPO_ROOT" -name package.json -not -path '*/node_modules/*' -print0) + +exit "$failed" diff --git a/scripts/ci-supply-chain.sh b/scripts/ci-supply-chain.sh new file mode 100644 index 00000000..2ab83aa8 --- /dev/null +++ b/scripts/ci-supply-chain.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# CI supply-chain checks: pinned versions, frozen lockfile sync, audit only when bun.lock changed. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$REPO_ROOT" + +lockfile_changed_in_range() { + local range="$1" + [[ -z "$range" ]] && return 1 + git diff --name-only "$range" -- bun.lock | grep -q . +} + +should_run_bun_audit() { + if [[ "${FORCE_BUN_AUDIT:-}" == "1" ]]; then + return 0 + fi + + if [[ "${GITHUB_EVENT_NAME:-}" == "pull_request" && -n "${GITHUB_BASE_REF:-}" ]]; then + git fetch --no-tags --depth=1 origin "$GITHUB_BASE_REF" 2>/dev/null || true + lockfile_changed_in_range "origin/${GITHUB_BASE_REF}...HEAD" + return $? + fi + + if [[ "${GITHUB_EVENT_NAME:-}" == "push" ]]; then + if git rev-parse HEAD~1 >/dev/null 2>&1; then + lockfile_changed_in_range 'HEAD~1..HEAD' + return $? + fi + return 0 + fi + + lockfile_changed_in_range HEAD && return 0 + lockfile_changed_in_range --cached && return 0 + return 1 +} + +echo 'Checking pinned dependency versions...' +bash "$SCRIPT_DIR/check-pinned-versions.sh" + +echo 'Installing with frozen lockfile...' +bun install --frozen-lockfile + +echo 'Verifying bun.lock matches package.json (no drift after install)...' +if ! git diff --exit-code bun.lock; then + echo 'bun.lock is out of sync. Run: bun run install:update-lockfile' >&2 + exit 1 +fi + +if should_run_bun_audit; then + echo 'bun.lock changed in this change set; running bun audit (high/critical)...' + bun audit --audit-level=high +else + echo 'bun.lock unchanged; skipping bun audit.' +fi From d159cf594276b36e96ca7f906351546203295ee7 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Fri, 15 May 2026 09:27:53 -0400 Subject: [PATCH 2/9] Normalize line endings to LF --- .config/jest/has-flag-stub.cjs | 30 +- .dockerignore | 14 +- .github/workflows/ci.yml | 70 +- .gitignore | 2 +- .prettierrc | 22 +- bunfig.toml | 14 +- bunfig.update-lockfile.toml | 6 +- docker/docker-compose.yml | 28 +- docs/livelock-debugging.md | 134 +-- jest.config.cjs | 18 +- .../lib/commands/createMain.mjs | 116 +-- .../create-dicomweb/lib/commands/index.mjs | 14 +- .../lib/commands/indexMain.mjs | 62 +- .../lib/commands/instanceMain.mjs | 54 +- .../lib/commands/parseDicomJsonErrors.mjs | 276 +++--- .../lib/commands/seriesMain.mjs | 140 ++-- .../create-dicomweb/lib/commands/stowMain.mjs | 792 +++++++++--------- .../lib/commands/studyMain.mjs | 56 +- packages/create-dicomweb/lib/index.mjs | 18 +- .../lib/instance/DicomWebStream.mjs | 158 ++-- .../lib/instance/FileDicomWebWriter.mjs | 716 ++++++++-------- .../lib/instance/IndexSummary.mjs | 274 +++--- .../MultipartResponseDicomWebWriter.mjs | 328 ++++---- .../lib/instance/MultipartStreamWriter.mjs | 190 ++--- .../lib/instance/SeriesSummary.mjs | 410 ++++----- .../lib/instance/StreamInfo.mjs | 624 +++++++------- .../lib/instance/dicomDirLocation.mjs | 116 +-- .../lib/instance/inlineBinaryFilter.mjs | 96 +-- .../lib/instance/writeBulkdataFilter.mjs | 574 ++++++------- .../test/writeBulkdataFilter.jest.mjs | 254 +++--- packages/cs3d/src/createIImage.mjs | 12 +- packages/cs3d/src/cs3d.ts | 118 +-- packages/cs3d/src/dcmjs.d.ts | 58 +- packages/cs3d/test.ts | 2 +- packages/cs3d/test/jestSetup.js | 38 +- packages/cs3d/tsconfig.json | 16 +- packages/healthlake/.babelrc | 14 +- packages/static-wado-creator/lib/index.mjs | 36 +- .../lib/util/resultsReport.ts | 174 ++-- .../static-wado-util/lib/StatusMonitor.mjs | 272 +++--- packages/static-wado-util/lib/TagLists.mjs | 320 +++---- packages/static-wado-util/lib/compareTo.mjs | 116 +-- .../lib/createPromiseTracker.mjs | 450 +++++----- .../lib/createStudyDirectories.ts | 22 +- packages/static-wado-util/lib/dicomToXml.ts | 82 +- .../static-wado-util/lib/dictionary/Tags.mjs | 268 +++--- .../static-wado-util/lib/parseSizeToBytes.ts | 54 +- .../static-wado-util/lib/parseTimeoutToMs.ts | 42 +- packages/static-wado-util/lib/sortStudies.mjs | 134 +-- .../test/unit/asyncIterable.jest.mjs | 84 +- packages/static-wado-util/tsconfig.json | 28 +- .../controllers/server/SagaBusMessaging.mjs | 506 +++++------ .../server/TrackableReadBufferStream.mjs | 526 ++++++------ .../server/deleteStaleSummaries.mjs | 122 +-- .../server/seriesMetadataQueryController.mjs | 80 +- .../server/seriesQueryController.mjs | 94 +-- .../controllers/server/statusController.mjs | 112 +-- .../lib/services/util/serverSpawn.mjs | 286 +++---- .../lib/util/asyncStackDump.mjs | 378 ++++----- .../lib/util/livelockRegistry.mjs | 174 ++-- .../lib/util/stowResponseBuilder.mjs | 432 +++++----- .../lib/vendor/dicer-compat/Dicer.mjs | 434 +++++----- .../lib/vendor/dicer-compat/HeaderParser.mjs | 212 ++--- .../lib/vendor/dicer-compat/LICENSE.dicer.md | 14 +- .../lib/vendor/dicer-compat/PartStream.mjs | 10 +- scripts/check-pinned-versions.sh | 90 +- scripts/ci-supply-chain.sh | 112 +-- 67 files changed, 5764 insertions(+), 5764 deletions(-) diff --git a/.config/jest/has-flag-stub.cjs b/.config/jest/has-flag-stub.cjs index 6a862d5b..af9cb02a 100644 --- a/.config/jest/has-flag-stub.cjs +++ b/.config/jest/has-flag-stub.cjs @@ -1,15 +1,15 @@ -'use strict'; - -/** - * CommonJS stub for "has-flag" so that supports-color (and other CJS deps) - * work when the hoisted has-flag is ESM-only v5. Use via Jest moduleNameMapper. - */ -function hasFlag(flag, argv) { - argv = argv || process.argv; - const prefix = flag.startsWith('-') ? '' : (flag.length === 1 ? '-' : '--'); - const position = argv.indexOf(prefix + flag); - const terminatorPosition = argv.indexOf('--'); - return position !== -1 && (terminatorPosition === -1 || position < terminatorPosition); -} - -module.exports = hasFlag; +'use strict'; + +/** + * CommonJS stub for "has-flag" so that supports-color (and other CJS deps) + * work when the hoisted has-flag is ESM-only v5. Use via Jest moduleNameMapper. + */ +function hasFlag(flag, argv) { + argv = argv || process.argv; + const prefix = flag.startsWith('-') ? '' : (flag.length === 1 ? '-' : '--'); + const position = argv.indexOf(prefix + flag); + const terminatorPosition = argv.indexOf('--'); + return position !== -1 && (terminatorPosition === -1 || position < terminatorPosition); +} + +module.exports = hasFlag; diff --git a/.dockerignore b/.dockerignore index cbf2fdac..95a8ae34 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,7 @@ -**/node_modules/ -**/dist -.git -npm-debug.log -.coverage -.coverage.* -.env +**/node_modules/ +**/dist +.git +npm-debug.log +.coverage +.coverage.* +.env diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8036ee85..0e446e4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,35 +1,35 @@ -name: CI - -on: - push: - branches: [main, master] - pull_request: - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - uses: oven-sh/setup-bun@v2 - with: - bun-version: "1.3.13" - - - name: Test (supply chain + unit tests) - run: bun run test:github - - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - uses: oven-sh/setup-bun@v2 - with: - bun-version: "1.3.13" - - - name: Build (supply chain + build) - run: bun run build:github +name: CI + +on: + push: + branches: [main, master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.13" + + - name: Test (supply chain + unit tests) + run: bun run test:github + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.13" + + - name: Build (supply chain + build) + run: bun run build:github diff --git a/.gitignore b/.gitignore index 50c7fb12..c4391066 100644 --- a/.gitignore +++ b/.gitignore @@ -58,4 +58,4 @@ node_modules/ *.swp .cursor/ .test -.claude +.claude diff --git a/.prettierrc b/.prettierrc index d3cd4add..28448abd 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,11 +1,11 @@ -{ - "trailingComma": "es5", - "printWidth": 100, - "proseWrap": "always", - "tabWidth": 2, - "semi": true, - "singleQuote": true, - "arrowParens": "avoid", - "singleAttributePerLine": true, - "endOfLine": "auto" -} +{ + "trailingComma": "es5", + "printWidth": 100, + "proseWrap": "always", + "tabWidth": 2, + "semi": true, + "singleQuote": true, + "arrowParens": "avoid", + "singleAttributePerLine": true, + "endOfLine": "auto" +} diff --git a/bunfig.toml b/bunfig.toml index 8ec2ecea..623a5c6c 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,7 +1,7 @@ -# https://bun.sh/docs/runtime/bunfig -[install] -frozenLockfile = true -exact = true -saveTextLockfile = true -# Optional supply-chain knob (pnpm-style “minimum release age”), in seconds: -# minimumReleaseAge = 86400 +# https://bun.sh/docs/runtime/bunfig +[install] +frozenLockfile = true +exact = true +saveTextLockfile = true +# Optional supply-chain knob (pnpm-style “minimum release age”), in seconds: +# minimumReleaseAge = 86400 diff --git a/bunfig.update-lockfile.toml b/bunfig.update-lockfile.toml index 844bbdc4..aa482b6a 100644 --- a/bunfig.update-lockfile.toml +++ b/bunfig.update-lockfile.toml @@ -1,3 +1,3 @@ -# Used only by `bun run install:update-lockfile` — default `bunfig.toml` keeps frozen lockfile. -[install] -frozenLockfile = false +# Used only by `bun run install:update-lockfile` — default `bunfig.toml` keeps frozen lockfile. +[install] +frozenLockfile = false diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 907ee136..3f978b45 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,14 +1,14 @@ -name: static-dicomweb - -services: - # In a production environment, Keycloak should be set up separately from KHEOPS. - # KHEOPS interacts with Keycloak using the Authorization Code flow please - # refer to the Keycloak documentation for instruction on how to properly - # secure Keycloak. - static-dicomweb: - platform: linux/amd64 - image: braveheartsoftware/static-dicomweb:v1 - container_name: static-dicomweb - ports: - - "5000:5000" - - "11115:11115" +name: static-dicomweb + +services: + # In a production environment, Keycloak should be set up separately from KHEOPS. + # KHEOPS interacts with Keycloak using the Authorization Code flow please + # refer to the Keycloak documentation for instruction on how to properly + # secure Keycloak. + static-dicomweb: + platform: linux/amd64 + image: braveheartsoftware/static-dicomweb:v1 + container_name: static-dicomweb + ports: + - "5000:5000" + - "11115:11115" diff --git a/docs/livelock-debugging.md b/docs/livelock-debugging.md index fdfc4eb9..90df9a4a 100644 --- a/docs/livelock-debugging.md +++ b/docs/livelock-debugging.md @@ -1,67 +1,67 @@ -# Livelock debugging (STOW uploads with AsyncDicomReader) - -When the DICOMweb server appears to livelock during STOW uploads (streamPostController + dcmjs AsyncDicomReader and listeners), use the following to capture stack traces and narrow down the cause. - -## 1. Stack dump on signal (Unix / Bun with SIGUSR2) - -Enable stack dumps when the process is running, then send a signal to get a snapshot (or a short sampler) of what is running. - -**Enable at startup:** - -```bash -# Single dump on SIGUSR2 -STACK_DUMP_ENABLED=1 bun run bin/dicomwebserver.mjs - -# Or: on SIGUSR2 run a 2s sampler (dumps every 200ms) to see the repeating call path -STACK_DUMP_SAMPLE_MS=200 STACK_DUMP_SAMPLE_DURATION_MS=2000 bun run bin/dicomwebserver.mjs -``` - -**When the server appears stuck:** - -- **Unix:** `kill -USR2 ` - - One dump: you see the current stack. - - With sampler: you get multiple dumps over ~2s; the repeated frames are the livelock loop. -- **Windows:** SIGUSR2 may not be available in Bun. Use either: - - Run with `STACK_DUMP_SAMPLE_MS=200` and add a **debug HTTP route** that calls `dumpCurrentStack()` (see `lib/util/asyncStackDump.mjs`), or - - Call `dumpCurrentStack()` from a timer or from your own “interrupt now” trigger. - -Implementation: `packages/static-wado-webserver/lib/util/asyncStackDump.mjs` (exported helpers: `dumpCurrentStack`, `installStackDumpOnSignal`, `runStackSampler`, `installFromEnv`). - -## 2. Livelock detector (ensureAvailable stuck) - -If the reader is stuck waiting on `ensureAvailable()` (stream not progressing), enable the optional detector so that after a given time a warning is logged with the call stack. - -**Enable via environment:** - -```bash -# Log a warning (with stack) if ensureAvailable() is still pending after 15 seconds -TRACKABLE_STREAM_LIVELOCK_DETECT_MS=15000 bun run bin/dicomwebserver.mjs -``` - -**Enable in code (when creating the stream):** - -If you create `TrackableReadBufferStream` yourself, pass the option: - -```js -const stream = new TrackableReadBufferStream(null, true, { - noCopy: true, - livelockDetectMs: 15000, -}); -``` - -When a wait exceeds the threshold, you’ll see a log like: - -- `[TrackableReadBufferStream] Possible livelock: ensureAvailable(N) still pending after 15000ms. Stream: offset=... endOffset=... isComplete=...` -- Followed by the stack trace captured at the time of the `ensureAvailable()` call. - -That stack shows where in the reader/listener pipeline the code is blocked waiting for more data. - -## Summary - -| Goal | Method | -|-----------------------------|-------------------------------------------------------------------------| -| “Interrupt now” stack | `STACK_DUMP_ENABLED=1` + `kill -USR2 ` (or HTTP/timer calling `dumpCurrentStack`) | -| Stacks over ~2s (livelock) | `STACK_DUMP_SAMPLE_MS=200` + `kill -USR2 ` | -| Detect stuck ensureAvailable| `TRACKABLE_STREAM_LIVELOCK_DETECT_MS=15000` (or `livelockDetectMs` option) | - -Combining (1) and (2) gives both a one-shot or sampled view of the event loop and an automatic warning when the stream reader is stuck waiting on data. +# Livelock debugging (STOW uploads with AsyncDicomReader) + +When the DICOMweb server appears to livelock during STOW uploads (streamPostController + dcmjs AsyncDicomReader and listeners), use the following to capture stack traces and narrow down the cause. + +## 1. Stack dump on signal (Unix / Bun with SIGUSR2) + +Enable stack dumps when the process is running, then send a signal to get a snapshot (or a short sampler) of what is running. + +**Enable at startup:** + +```bash +# Single dump on SIGUSR2 +STACK_DUMP_ENABLED=1 bun run bin/dicomwebserver.mjs + +# Or: on SIGUSR2 run a 2s sampler (dumps every 200ms) to see the repeating call path +STACK_DUMP_SAMPLE_MS=200 STACK_DUMP_SAMPLE_DURATION_MS=2000 bun run bin/dicomwebserver.mjs +``` + +**When the server appears stuck:** + +- **Unix:** `kill -USR2 ` + - One dump: you see the current stack. + - With sampler: you get multiple dumps over ~2s; the repeated frames are the livelock loop. +- **Windows:** SIGUSR2 may not be available in Bun. Use either: + - Run with `STACK_DUMP_SAMPLE_MS=200` and add a **debug HTTP route** that calls `dumpCurrentStack()` (see `lib/util/asyncStackDump.mjs`), or + - Call `dumpCurrentStack()` from a timer or from your own “interrupt now” trigger. + +Implementation: `packages/static-wado-webserver/lib/util/asyncStackDump.mjs` (exported helpers: `dumpCurrentStack`, `installStackDumpOnSignal`, `runStackSampler`, `installFromEnv`). + +## 2. Livelock detector (ensureAvailable stuck) + +If the reader is stuck waiting on `ensureAvailable()` (stream not progressing), enable the optional detector so that after a given time a warning is logged with the call stack. + +**Enable via environment:** + +```bash +# Log a warning (with stack) if ensureAvailable() is still pending after 15 seconds +TRACKABLE_STREAM_LIVELOCK_DETECT_MS=15000 bun run bin/dicomwebserver.mjs +``` + +**Enable in code (when creating the stream):** + +If you create `TrackableReadBufferStream` yourself, pass the option: + +```js +const stream = new TrackableReadBufferStream(null, true, { + noCopy: true, + livelockDetectMs: 15000, +}); +``` + +When a wait exceeds the threshold, you’ll see a log like: + +- `[TrackableReadBufferStream] Possible livelock: ensureAvailable(N) still pending after 15000ms. Stream: offset=... endOffset=... isComplete=...` +- Followed by the stack trace captured at the time of the `ensureAvailable()` call. + +That stack shows where in the reader/listener pipeline the code is blocked waiting for more data. + +## Summary + +| Goal | Method | +|-----------------------------|-------------------------------------------------------------------------| +| “Interrupt now” stack | `STACK_DUMP_ENABLED=1` + `kill -USR2 ` (or HTTP/timer calling `dumpCurrentStack`) | +| Stacks over ~2s (livelock) | `STACK_DUMP_SAMPLE_MS=200` + `kill -USR2 ` | +| Detect stuck ensureAvailable| `TRACKABLE_STREAM_LIVELOCK_DETECT_MS=15000` (or `livelockDetectMs` option) | + +Combining (1) and (2) gives both a one-shot or sampled view of the event loop and an automatic warning when the stream reader is stuck waiting on data. diff --git a/jest.config.cjs b/jest.config.cjs index 2f13a3b5..204d950f 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -1,9 +1,9 @@ -/** - * Root Jest config so the Test Explorer and Jest extension discover tests in - * packages/create-dicomweb (e.g. test/**/*.jest.mjs). Run from repo root: - * bun run test --packages=create-dicomweb (or use lerna) - * Or run Jest with this config: jest --config jest.config.cjs - */ -module.exports = { - projects: ['./packages/create-dicomweb'], -}; +/** + * Root Jest config so the Test Explorer and Jest extension discover tests in + * packages/create-dicomweb (e.g. test/**/*.jest.mjs). Run from repo root: + * bun run test --packages=create-dicomweb (or use lerna) + * Or run Jest with this config: jest --config jest.config.cjs + */ +module.exports = { + projects: ['./packages/create-dicomweb'], +}; diff --git a/packages/create-dicomweb/lib/commands/createMain.mjs b/packages/create-dicomweb/lib/commands/createMain.mjs index da528006..468c908d 100644 --- a/packages/create-dicomweb/lib/commands/createMain.mjs +++ b/packages/create-dicomweb/lib/commands/createMain.mjs @@ -1,58 +1,58 @@ -import { instanceMain } from './instanceMain.mjs'; -import { seriesMain } from './seriesMain.mjs'; -import { studyMain } from './studyMain.mjs'; -import { indexSummary } from '../instance/IndexSummary.mjs'; - -/** - * Main function for creating complete DICOMweb structure - * Processes instances, then generates series and study metadata for each study - * @param {string|string[]} fileNames - File(s) or directory(ies) to process - * @param {Object} options - Options object - * @param {string} [options.dicomdir] - Base directory path where DICOMweb structure is located - * @param {boolean} [options.studyIndex=true] - Whether to create/update studies/index.json.gz file - */ -export async function createMain(fileNames, options = {}) { - const { dicomdir, studyIndex = true } = options; - - if (!dicomdir) { - throw new Error('dicomdir option is required'); - } - - // Step 1: Process instances and collect study UIDs - console.log('Processing instances...'); - const studyUIDs = await instanceMain(fileNames, options); - - if (studyUIDs.size === 0) { - console.warn('No study UIDs found in processed instances'); - return; - } - - console.log(`Found ${studyUIDs.size} unique study(ies)`); - - // Step 2: For each study UID, process series and then study metadata - for (const studyUID of studyUIDs) { - try { - // Process series for this study - console.noQuiet(`Processing series for study ${studyUID}...`); - await seriesMain(studyUID, options); - - // Process study metadata right after series - console.noQuiet(`Processing study metadata for study ${studyUID}...`); - await studyMain(studyUID, options); - - console.noQuiet(`Completed processing for study ${studyUID}`); - } catch (error) { - console.error(`Error processing study ${studyUID}: ${error.message}`); - throw error; - } - } - - // Step 3: Create/update studies/index.json.gz file unless disabled - if (studyIndex) { - console.noQuiet('Creating/updating studies index...'); - const studyUIDsArray = Array.from(studyUIDs); - await indexSummary(dicomdir, studyUIDsArray); - } - - console.log('Completed all processing'); -} +import { instanceMain } from './instanceMain.mjs'; +import { seriesMain } from './seriesMain.mjs'; +import { studyMain } from './studyMain.mjs'; +import { indexSummary } from '../instance/IndexSummary.mjs'; + +/** + * Main function for creating complete DICOMweb structure + * Processes instances, then generates series and study metadata for each study + * @param {string|string[]} fileNames - File(s) or directory(ies) to process + * @param {Object} options - Options object + * @param {string} [options.dicomdir] - Base directory path where DICOMweb structure is located + * @param {boolean} [options.studyIndex=true] - Whether to create/update studies/index.json.gz file + */ +export async function createMain(fileNames, options = {}) { + const { dicomdir, studyIndex = true } = options; + + if (!dicomdir) { + throw new Error('dicomdir option is required'); + } + + // Step 1: Process instances and collect study UIDs + console.log('Processing instances...'); + const studyUIDs = await instanceMain(fileNames, options); + + if (studyUIDs.size === 0) { + console.warn('No study UIDs found in processed instances'); + return; + } + + console.log(`Found ${studyUIDs.size} unique study(ies)`); + + // Step 2: For each study UID, process series and then study metadata + for (const studyUID of studyUIDs) { + try { + // Process series for this study + console.noQuiet(`Processing series for study ${studyUID}...`); + await seriesMain(studyUID, options); + + // Process study metadata right after series + console.noQuiet(`Processing study metadata for study ${studyUID}...`); + await studyMain(studyUID, options); + + console.noQuiet(`Completed processing for study ${studyUID}`); + } catch (error) { + console.error(`Error processing study ${studyUID}: ${error.message}`); + throw error; + } + } + + // Step 3: Create/update studies/index.json.gz file unless disabled + if (studyIndex) { + console.noQuiet('Creating/updating studies index...'); + const studyUIDsArray = Array.from(studyUIDs); + await indexSummary(dicomdir, studyUIDsArray); + } + + console.log('Completed all processing'); +} diff --git a/packages/create-dicomweb/lib/commands/index.mjs b/packages/create-dicomweb/lib/commands/index.mjs index 643ea47d..60d42c29 100644 --- a/packages/create-dicomweb/lib/commands/index.mjs +++ b/packages/create-dicomweb/lib/commands/index.mjs @@ -1,8 +1,8 @@ -export * from './instanceMain.mjs'; -export * from './seriesMain.mjs'; -export * from './studyMain.mjs'; -export * from './createMain.mjs'; -export * from './stowMain.mjs'; -export * from './thumbnailMain.mjs'; -export * from './indexMain.mjs'; +export * from './instanceMain.mjs'; +export * from './seriesMain.mjs'; +export * from './studyMain.mjs'; +export * from './createMain.mjs'; +export * from './stowMain.mjs'; +export * from './thumbnailMain.mjs'; +export * from './indexMain.mjs'; export * from './part10Main.mjs'; \ No newline at end of file diff --git a/packages/create-dicomweb/lib/commands/indexMain.mjs b/packages/create-dicomweb/lib/commands/indexMain.mjs index 9dec0f3b..655b1aec 100644 --- a/packages/create-dicomweb/lib/commands/indexMain.mjs +++ b/packages/create-dicomweb/lib/commands/indexMain.mjs @@ -1,31 +1,31 @@ -import { indexSummary } from '../instance/IndexSummary.mjs'; - -/** - * Main function for processing studies index - * @param {string[]} studyUIDs - Optional array of Study Instance UIDs to process (if empty, scans all studies) - * @param {Object} options - Options object - * @param {string} [options.dicomdir] - Base directory path where DICOMweb structure is located - */ -export async function indexMain(studyUIDs = [], options = {}) { - const { dicomdir } = options; - - if (!dicomdir) { - throw new Error('dicomdir option is required'); - } - - // studyUIDs can be an array or undefined/empty - const studyUIDsArray = Array.isArray(studyUIDs) ? studyUIDs : (studyUIDs ? [studyUIDs] : []); - - try { - if (studyUIDsArray.length === 0) { - console.log('Processing all studies in index...'); - } else { - console.log(`Processing ${studyUIDsArray.length} study(ies) in index...`); - } - await indexSummary(dicomdir, studyUIDsArray); - console.log('Completed studies index update'); - } catch (error) { - console.error(`Error processing studies index: ${error.message}`); - throw error; - } -} +import { indexSummary } from '../instance/IndexSummary.mjs'; + +/** + * Main function for processing studies index + * @param {string[]} studyUIDs - Optional array of Study Instance UIDs to process (if empty, scans all studies) + * @param {Object} options - Options object + * @param {string} [options.dicomdir] - Base directory path where DICOMweb structure is located + */ +export async function indexMain(studyUIDs = [], options = {}) { + const { dicomdir } = options; + + if (!dicomdir) { + throw new Error('dicomdir option is required'); + } + + // studyUIDs can be an array or undefined/empty + const studyUIDsArray = Array.isArray(studyUIDs) ? studyUIDs : (studyUIDs ? [studyUIDs] : []); + + try { + if (studyUIDsArray.length === 0) { + console.log('Processing all studies in index...'); + } else { + console.log(`Processing ${studyUIDsArray.length} study(ies) in index...`); + } + await indexSummary(dicomdir, studyUIDsArray); + console.log('Completed studies index update'); + } catch (error) { + console.error(`Error processing studies index: ${error.message}`); + throw error; + } +} diff --git a/packages/create-dicomweb/lib/commands/instanceMain.mjs b/packages/create-dicomweb/lib/commands/instanceMain.mjs index 6e2caed2..22e8b1eb 100644 --- a/packages/create-dicomweb/lib/commands/instanceMain.mjs +++ b/packages/create-dicomweb/lib/commands/instanceMain.mjs @@ -1,27 +1,27 @@ -import fs from "fs"; -import { dirScanner } from '@radicalimaging/static-wado-util'; -import { instanceFromStream } from '../instance/instanceFromStream.mjs'; - -export async function instanceMain(fileNames, options) { - const studyUIDs = new Set(); - await dirScanner(fileNames, { - ...options, - recursive: true, - callback: async (filename) => { - const result = await instanceFromFile(filename, options); - if (result?.information) { - // Check both PascalCase and camelCase for compatibility - const studyUID = result.information.StudyInstanceUid || result.information.studyInstanceUid; - if (studyUID) { - studyUIDs.add(studyUID); - } - } - } - }); - return studyUIDs; -} - -export function instanceFromFile(fileName, options = {}) { - const stream = fs.createReadStream(fileName); - return instanceFromStream(stream, options); -} +import fs from "fs"; +import { dirScanner } from '@radicalimaging/static-wado-util'; +import { instanceFromStream } from '../instance/instanceFromStream.mjs'; + +export async function instanceMain(fileNames, options) { + const studyUIDs = new Set(); + await dirScanner(fileNames, { + ...options, + recursive: true, + callback: async (filename) => { + const result = await instanceFromFile(filename, options); + if (result?.information) { + // Check both PascalCase and camelCase for compatibility + const studyUID = result.information.StudyInstanceUid || result.information.studyInstanceUid; + if (studyUID) { + studyUIDs.add(studyUID); + } + } + } + }); + return studyUIDs; +} + +export function instanceFromFile(fileName, options = {}) { + const stream = fs.createReadStream(fileName); + return instanceFromStream(stream, options); +} diff --git a/packages/create-dicomweb/lib/commands/parseDicomJsonErrors.mjs b/packages/create-dicomweb/lib/commands/parseDicomJsonErrors.mjs index 98632781..26011b26 100644 --- a/packages/create-dicomweb/lib/commands/parseDicomJsonErrors.mjs +++ b/packages/create-dicomweb/lib/commands/parseDicomJsonErrors.mjs @@ -1,138 +1,138 @@ -import fs from "fs"; -import path from 'path'; -import { async, utilities } from 'dcmjs'; - -const { AsyncDicomReader } = async; -const { DicomMetadataListener } = utilities; - -/** Private tag (0009,1001) Content-Location - filename from multipart request, for matching response to uploaded files */ -const CONTENT_LOCATION_TAG = '00091001'; - -/** - * Parses a JSON DICOM response and logs warnings for any items that are not COMPLETED, matched to uploaded file names - * @param {Response} response - The fetch response object - * @param {string} responseText - The response text content - * @param {Array<{filePath: string, fileSize: number}>} files - Array of uploaded files - */ -export async function parseAndLogDicomJsonErrors(response, responseText, files) { - // Only process if we have response text - if (!responseText) { - return; - } - - // Check content type to determine if it's parseable JSON DICOM response - const contentType = response.headers.get('content-type') || ''; - const isJsonDicom = contentType.includes('application/dicom+json') || contentType.includes('application/json'); - const isXmlDicom = contentType.includes('application/dicom+xml') || contentType.includes('application/xml') || contentType.includes('text/xml'); - - // Skip XML responses - if (isXmlDicom) { - return; - } - - // Only process JSON DICOM responses - if (!isJsonDicom) { - return; - } - - try { - // Parse JSON response - const jsonResponse = JSON.parse(responseText); - - // Check if it's a valid DICOM JSON response (should be an object with sequences) - if (typeof jsonResponse !== 'object' || Array.isArray(jsonResponse)) { - return; - } - - // Extract FailedSOPSequence (00081198) if present - const failedSequence = jsonResponse['00081198']; - const failedItems = failedSequence?.Value || []; - - // If no failed items, nothing to log - if (failedItems.length === 0) { - return; - } - - // Extract SOP Instance UIDs from uploaded files using AsyncDicomReader - // Only do this if we have failed items to process - const fileSopInstanceUids = await Promise.all( - files.map(async ({ filePath }) => { - try { - const reader = new AsyncDicomReader(); - const fileStream = fs.createReadStream(filePath); - await reader.stream.fromAsyncStream(fileStream); - - // Use DicomMetadataListener to extract SOP Instance UID - const information = {}; - const listener = new DicomMetadataListener({ information }); - - await reader.readFile({ listener }); - - return { filePath, sopInstanceUid: information.sopInstanceUid || null }; - } catch (error) { - // If we can't read the file, return null for SOP Instance UID - // We'll fall back to position-based matching - return { filePath, sopInstanceUid: null }; - } - }) - ); - - // Match response items to files and log warnings - for (let i = 0; i < failedItems.length; i++) { - const failedItem = failedItems[i]; - - // Extract Failure Reason (00081197) if available - const failureReasonTag = failedItem['00081197']; - const failureReason = failureReasonTag?.Value?.[0]; - - // Build the warning message - let warningMessage = 'Failed'; - if (failureReason !== undefined) { - // Failure reason is typically a US (unsigned short) error code - // Convert to hex for readability if it's a number - if (typeof failureReason === 'number') { - warningMessage = `Failure Reason: 0x${failureReason.toString(16).toUpperCase()}`; - } else { - warningMessage = `Failure Reason: ${failureReason}`; - } - } - - // Match by Content-Location first (private tag 00091001) - most reliable for failed invalid DICOM - // Server includes the multipart Content-Location (filename) so we can match to uploaded files - const responseContentLocation = failedItem[CONTENT_LOCATION_TAG]?.Value?.[0]; - let matchedFile = null; - - if (responseContentLocation) { - matchedFile = files.find( - ({ filePath }) => path.basename(filePath) === responseContentLocation - ); - if (matchedFile) matchedFile = { filePath: matchedFile.filePath }; - } - - // Try SOP Instance UID (ReferencedSOPInstanceUID 00081155) if Content-Location didn't match - if (!matchedFile) { - const responseSopInstanceUid = failedItem['00081155']?.Value?.[0]; - if (responseSopInstanceUid) { - matchedFile = fileSopInstanceUids.find( - ({ sopInstanceUid }) => sopInstanceUid === responseSopInstanceUid - ); - } - } - - // Fall back to position-based matching - unreliable when multiple files with mixed success/failure - if (!matchedFile && i < files.length) { - matchedFile = { filePath: files[i].filePath }; - } - - // Log warning at noQuiet level - if (matchedFile) { - console.noQuiet(`Warning for file ${matchedFile.filePath}: ${warningMessage}`); - } else { - console.noQuiet(`Warning for uploaded file (index ${i}): ${warningMessage}`); - } - } - } catch (error) { - // If parsing fails, silently ignore (response might not be JSON DICOM format) - // The response body is already logged above - } -} +import fs from "fs"; +import path from 'path'; +import { async, utilities } from 'dcmjs'; + +const { AsyncDicomReader } = async; +const { DicomMetadataListener } = utilities; + +/** Private tag (0009,1001) Content-Location - filename from multipart request, for matching response to uploaded files */ +const CONTENT_LOCATION_TAG = '00091001'; + +/** + * Parses a JSON DICOM response and logs warnings for any items that are not COMPLETED, matched to uploaded file names + * @param {Response} response - The fetch response object + * @param {string} responseText - The response text content + * @param {Array<{filePath: string, fileSize: number}>} files - Array of uploaded files + */ +export async function parseAndLogDicomJsonErrors(response, responseText, files) { + // Only process if we have response text + if (!responseText) { + return; + } + + // Check content type to determine if it's parseable JSON DICOM response + const contentType = response.headers.get('content-type') || ''; + const isJsonDicom = contentType.includes('application/dicom+json') || contentType.includes('application/json'); + const isXmlDicom = contentType.includes('application/dicom+xml') || contentType.includes('application/xml') || contentType.includes('text/xml'); + + // Skip XML responses + if (isXmlDicom) { + return; + } + + // Only process JSON DICOM responses + if (!isJsonDicom) { + return; + } + + try { + // Parse JSON response + const jsonResponse = JSON.parse(responseText); + + // Check if it's a valid DICOM JSON response (should be an object with sequences) + if (typeof jsonResponse !== 'object' || Array.isArray(jsonResponse)) { + return; + } + + // Extract FailedSOPSequence (00081198) if present + const failedSequence = jsonResponse['00081198']; + const failedItems = failedSequence?.Value || []; + + // If no failed items, nothing to log + if (failedItems.length === 0) { + return; + } + + // Extract SOP Instance UIDs from uploaded files using AsyncDicomReader + // Only do this if we have failed items to process + const fileSopInstanceUids = await Promise.all( + files.map(async ({ filePath }) => { + try { + const reader = new AsyncDicomReader(); + const fileStream = fs.createReadStream(filePath); + await reader.stream.fromAsyncStream(fileStream); + + // Use DicomMetadataListener to extract SOP Instance UID + const information = {}; + const listener = new DicomMetadataListener({ information }); + + await reader.readFile({ listener }); + + return { filePath, sopInstanceUid: information.sopInstanceUid || null }; + } catch (error) { + // If we can't read the file, return null for SOP Instance UID + // We'll fall back to position-based matching + return { filePath, sopInstanceUid: null }; + } + }) + ); + + // Match response items to files and log warnings + for (let i = 0; i < failedItems.length; i++) { + const failedItem = failedItems[i]; + + // Extract Failure Reason (00081197) if available + const failureReasonTag = failedItem['00081197']; + const failureReason = failureReasonTag?.Value?.[0]; + + // Build the warning message + let warningMessage = 'Failed'; + if (failureReason !== undefined) { + // Failure reason is typically a US (unsigned short) error code + // Convert to hex for readability if it's a number + if (typeof failureReason === 'number') { + warningMessage = `Failure Reason: 0x${failureReason.toString(16).toUpperCase()}`; + } else { + warningMessage = `Failure Reason: ${failureReason}`; + } + } + + // Match by Content-Location first (private tag 00091001) - most reliable for failed invalid DICOM + // Server includes the multipart Content-Location (filename) so we can match to uploaded files + const responseContentLocation = failedItem[CONTENT_LOCATION_TAG]?.Value?.[0]; + let matchedFile = null; + + if (responseContentLocation) { + matchedFile = files.find( + ({ filePath }) => path.basename(filePath) === responseContentLocation + ); + if (matchedFile) matchedFile = { filePath: matchedFile.filePath }; + } + + // Try SOP Instance UID (ReferencedSOPInstanceUID 00081155) if Content-Location didn't match + if (!matchedFile) { + const responseSopInstanceUid = failedItem['00081155']?.Value?.[0]; + if (responseSopInstanceUid) { + matchedFile = fileSopInstanceUids.find( + ({ sopInstanceUid }) => sopInstanceUid === responseSopInstanceUid + ); + } + } + + // Fall back to position-based matching - unreliable when multiple files with mixed success/failure + if (!matchedFile && i < files.length) { + matchedFile = { filePath: files[i].filePath }; + } + + // Log warning at noQuiet level + if (matchedFile) { + console.noQuiet(`Warning for file ${matchedFile.filePath}: ${warningMessage}`); + } else { + console.noQuiet(`Warning for uploaded file (index ${i}): ${warningMessage}`); + } + } + } catch (error) { + // If parsing fails, silently ignore (response might not be JSON DICOM format) + // The response body is already logged above + } +} diff --git a/packages/create-dicomweb/lib/commands/seriesMain.mjs b/packages/create-dicomweb/lib/commands/seriesMain.mjs index ec660e79..167925c0 100644 --- a/packages/create-dicomweb/lib/commands/seriesMain.mjs +++ b/packages/create-dicomweb/lib/commands/seriesMain.mjs @@ -1,70 +1,70 @@ -import fs from 'fs'; -import path from 'path'; -import { seriesSummary } from '../instance/SeriesSummary.mjs'; -import { FileDicomWebReader } from '../instance/FileDicomWebReader.mjs'; - -/** - * Main function for processing series metadata - * @param {string} studyUID - Study Instance UID - * @param {Object} options - Options object - * @param {string} [options.dicomdir] - Base directory path where DICOMweb structure is located - * @param {string} [options.seriesUid] - Specific Series Instance UID to process (if not provided, processes all series in the study) - */ -export async function seriesMain(studyUID, options = {}) { - const { dicomdir, seriesUid } = options; - - if (!dicomdir) { - throw new Error('dicomdir option is required'); - } - - const reader = new FileDicomWebReader(dicomdir); - - let seriesUIDs = []; - - if (seriesUid) { - // Process specific series - seriesUIDs = [seriesUid]; - } else { - // Scan for all series in the study - const seriesPath = reader.getStudyPath(studyUID, { path: 'series' }); - const seriesDirectories = await reader.scanDirectory(seriesPath, { withFileTypes: true }); - - for (const entry of seriesDirectories) { - // If withFileTypes is used, entry is a Dirent object - if (entry && typeof entry === 'object' && entry.isDirectory && entry.isDirectory()) { - seriesUIDs.push(entry.name); - } else if (typeof entry === 'string') { - // Fallback: if entry is a string, check if it's a directory - const seriesDirPath = `${seriesPath}/${entry}`; - const fullSeriesPath = path.join(reader.baseDir, seriesDirPath); - - try { - const stats = fs.lstatSync(fullSeriesPath); - if (stats.isDirectory()) { - seriesUIDs.push(entry); - } - } catch (error) { - // Skip if we can't stat the path - console.warn(`Could not stat ${seriesDirPath}: ${error.message}`); - } - } - } - } - - if (seriesUIDs.length === 0) { - console.warn(`No series found for study ${studyUID}`); - return; - } - - // Process each series - for (const seriesUID of seriesUIDs) { - try { - console.verbose(`Processing series ${seriesUID}...`); - await seriesSummary(dicomdir, studyUID, seriesUID); - console.noQuiet(`Completed series ${seriesUID}`); - } catch (error) { - console.error(`Error processing series ${seriesUID}: ${error.message}`); - throw error; - } - } -} +import fs from 'fs'; +import path from 'path'; +import { seriesSummary } from '../instance/SeriesSummary.mjs'; +import { FileDicomWebReader } from '../instance/FileDicomWebReader.mjs'; + +/** + * Main function for processing series metadata + * @param {string} studyUID - Study Instance UID + * @param {Object} options - Options object + * @param {string} [options.dicomdir] - Base directory path where DICOMweb structure is located + * @param {string} [options.seriesUid] - Specific Series Instance UID to process (if not provided, processes all series in the study) + */ +export async function seriesMain(studyUID, options = {}) { + const { dicomdir, seriesUid } = options; + + if (!dicomdir) { + throw new Error('dicomdir option is required'); + } + + const reader = new FileDicomWebReader(dicomdir); + + let seriesUIDs = []; + + if (seriesUid) { + // Process specific series + seriesUIDs = [seriesUid]; + } else { + // Scan for all series in the study + const seriesPath = reader.getStudyPath(studyUID, { path: 'series' }); + const seriesDirectories = await reader.scanDirectory(seriesPath, { withFileTypes: true }); + + for (const entry of seriesDirectories) { + // If withFileTypes is used, entry is a Dirent object + if (entry && typeof entry === 'object' && entry.isDirectory && entry.isDirectory()) { + seriesUIDs.push(entry.name); + } else if (typeof entry === 'string') { + // Fallback: if entry is a string, check if it's a directory + const seriesDirPath = `${seriesPath}/${entry}`; + const fullSeriesPath = path.join(reader.baseDir, seriesDirPath); + + try { + const stats = fs.lstatSync(fullSeriesPath); + if (stats.isDirectory()) { + seriesUIDs.push(entry); + } + } catch (error) { + // Skip if we can't stat the path + console.warn(`Could not stat ${seriesDirPath}: ${error.message}`); + } + } + } + } + + if (seriesUIDs.length === 0) { + console.warn(`No series found for study ${studyUID}`); + return; + } + + // Process each series + for (const seriesUID of seriesUIDs) { + try { + console.verbose(`Processing series ${seriesUID}...`); + await seriesSummary(dicomdir, studyUID, seriesUID); + console.noQuiet(`Completed series ${seriesUID}`); + } catch (error) { + console.error(`Error processing series ${seriesUID}: ${error.message}`); + throw error; + } + } +} diff --git a/packages/create-dicomweb/lib/commands/stowMain.mjs b/packages/create-dicomweb/lib/commands/stowMain.mjs index 2e2af1e7..dc982289 100644 --- a/packages/create-dicomweb/lib/commands/stowMain.mjs +++ b/packages/create-dicomweb/lib/commands/stowMain.mjs @@ -1,396 +1,396 @@ -import fs from "fs"; -import path from "path"; -import http from "node:http"; -import https from "node:https"; -import { randomUUID } from "node:crypto"; -import { Readable } from "node:stream"; -import { - dirScanner, - createPromiseTracker, - createProgressReporter, -} from '@radicalimaging/static-wado-util'; -import { parseAndLogDicomJsonErrors } from './parseDicomJsonErrors.mjs'; - -/** Timeout for createPromiseTracker limitUnsettled when waiting for the next storage slot (at least 30 minutes) */ -const LIMIT_UNSETTLED_TIMEOUT_MS = 30 * 60 * 1000; -/** Timeout for "wait for all" when using parallel (24 hours) */ -const WAIT_ALL_TIMEOUT_MS = 24 * 60 * 60 * 1000; -/** File extensions skipped when filenameCheck is on (lowercase, no dot) */ -const SKIP_EXTENSIONS = new Set([ - 'gz', - 'json', - 'md', - 'txt', - 'xml', - 'pdf', - 'py', - 'jpg', - 'jpeg', - 'png', - 'gif', - 'bmp', - 'webp', - 'svg', - 'ico', - 'tiff', - 'tif', - 'html', - 'htm', - 'css', - 'js', - 'mjs', - 'cjs', - 'zip', - 'zipx', - 'tar', - 'rar', - '7z', - 'properties', - 'db', - 'csv', -]); - -/** - * Stores DICOM files to a STOW-RS endpoint - * @param {string|string[]} fileNames - File(s) or directory(ies) to process - * @param {Object} options - Options object - * @param {string} options.url - URL endpoint for STOW-RS storage - * @param {Object} [options.headers] - Additional HTTP headers to include - * @param {number} [options.maxGroupSize] - Maximum size in bytes for grouping files (default: 10MB) - * @param {boolean} [options.sendAsSingleFiles] - If true, send each file individually instead of grouping (default: false) - * @param {boolean} [options.xmlResponse] - If true, request XML response format instead of JSON (default: false) - * @param {number} [options.timeoutMs] - Request timeout in milliseconds; no timeout if omitted - * @param {number} [options.parallel=1] - Number of parallel STOW-RS requests (1 = sequential). When > 1, limitUnsettled uses at least 30 minutes. - * @param {boolean} [options.filenameCheck=true] - If true, skip common non-DICOM extensions (e.g. gz, json, md, txt, xml, pdf, jpg, png, html, zip). Use false to upload all files. - * @param {boolean} [options.progress] - If true, show progress with instance count as files are stored - * @param {boolean} [options.quiet] - If true, suppress progress and non-error output - * @param {boolean} [options.verbose] - If true, show per-group storage messages - */ -export async function stowMain(fileNames, options = {}) { - const { - url, - headers = {}, - maxGroupSize = 10 * 1024 * 1024, - sendAsSingleFiles = false, - xmlResponse = false, - timeoutMs, - parallel = 1, - filenameCheck = true, - progress = false, - quiet = false, - verbose = false, - } = options; // Default 10MB - - const showProgress = progress && !quiet; - - if (!url) { - throw new Error('url option is required'); - } - - /** Count files that will be processed (respects filenameCheck) */ - const countFiles = async () => { - let count = 0; - await dirScanner(fileNames, { - recursive: true, - callback: async filename => { - if (filenameCheck) { - const ext = path.extname(filename).slice(1).toLowerCase(); - if (SKIP_EXTENSIONS.has(ext)) return; - if (filename.endsWith('DICOMDIR')) return; - } - count++; - }, - }); - return count; - }; - - const totalFiles = showProgress ? await countFiles() : 0; - const progressReporter = createProgressReporter({ total: totalFiles, enabled: showProgress }); - - if (showProgress && totalFiles > 0) { - console.log(`\nStoring ${totalFiles} file(s)...\n`); - } - - const results = { - success: 0, - failed: 0, - errors: [], - }; - - // When sendAsSingleFiles is true, set maxGroupSize to 0 to force groups of size 1 - const effectiveMaxGroupSize = sendAsSingleFiles ? 0 : maxGroupSize; - - // Group files by size - const fileGroup = []; - let currentGroupSize = 0; - - const tracker = createPromiseTracker('stow'); - - const runOneStow = async (group, requestTimeoutMs) => { - try { - await stowFiles(group, url, headers, xmlResponse, requestTimeoutMs); - results.success += group.length; - console.verbose(`Stored group of ${group.length} file(s)`); - progressReporter.addProcessed(group.length); - } catch (error) { - const isConnectionError = - error.message.includes('Unable to connect') || - error.message.includes('fetch failed') || - error.message.includes('ECONNREFUSED') || - error.message.includes('ENOTFOUND') || - error.message.includes('ETIMEDOUT') || - error.message.includes('ECONNRESET') || - error.cause?.code === 'ECONNREFUSED' || - error.cause?.code === 'ENOTFOUND' || - error.cause?.code === 'ETIMEDOUT' || - error.cause?.code === 'ECONNRESET'; - - if (isConnectionError) { - console.error(`Failed to connect to endpoint ${url}: ${error.message}`); - console.error('Exiting due to connection failure'); - process.exit(1); - } - - results.failed += group.length; - progressReporter.addProcessed(group.length); - group.forEach(({ filePath }) => { - results.errors.push({ file: filePath, error: error.message }); - console.error(`Failed to store ${filePath}: ${error.message}`); - }); - } - }; - - const flushGroup = async () => { - if (fileGroup.length === 0) return; - - const snapshot = fileGroup.slice(); - fileGroup.length = 0; - currentGroupSize = 0; - - await tracker.limitUnsettled(parallel, LIMIT_UNSETTLED_TIMEOUT_MS); - tracker.add(runOneStow(snapshot, timeoutMs)); - }; - - await dirScanner(fileNames, { - ...options, - recursive: true, - callback: async filename => { - try { - if (filenameCheck) { - const ext = path.extname(filename).slice(1).toLowerCase(); - if (SKIP_EXTENSIONS.has(ext)) return; - if (filename.endsWith('DICOMDIR')) return; - } - - const stats = fs.statSync(filename); - const fileSize = stats.size; - - // If adding this file would exceed the group size, flush the current group - // (skip this check when sendAsSingleFiles is true, as we'll flush after each file anyway) - if ( - !sendAsSingleFiles && - fileGroup.length > 0 && - currentGroupSize + fileSize > effectiveMaxGroupSize - ) { - await flushGroup(); - } - - // Add file to current group - fileGroup.push({ filePath: filename, fileSize }); - currentGroupSize += fileSize; - - // If sendAsSingleFiles is true, flush after each file (group of size 1) - if (sendAsSingleFiles) { - await flushGroup(); - } - } catch (error) { - results.failed++; - results.errors.push({ file: filename, error: error.message }); - console.error(`Failed to process ${filename}: ${error.message}`); - } - }, - }); - - // Flush any remaining files in the group - await flushGroup(); - - if (!showProgress) { - console.log('Finished starting all stow operations; awaiting remaining store promises...'); - } - // limitUnsettled(1): resolve when unsettled count drops below 1 (i.e. 0 remaining) - await tracker.limitUnsettled(1, WAIT_ALL_TIMEOUT_MS); - - if (showProgress) { - progressReporter.finish(); - } - console.log(`\nStorage complete: ${results.success} succeeded, ${results.failed} failed`); - if (results.errors.length > 0) { - console.log('\nErrors:'); - results.errors.forEach(({ file, error }) => { - console.log(` ${file}: ${error}`); - }); - } - - return results; -} - -/** - * Stores multiple DICOM files to a STOW-RS endpoint in a single multipart request. - * Uses node:http/node:https so timeout is under our control (Bun's fetch has a hardcoded 5-minute limit). - * @param {Array<{filePath: string, fileSize: number}>} files - Array of file objects with path and size - * @param {string} endpointUrl - URL endpoint for STOW-RS storage - * @param {Object} additionalHeaders - Additional HTTP headers to include - * @param {boolean} [xmlResponse=false] - If true, request XML response format instead of JSON - * @param {number} [timeoutMs] - Request timeout in milliseconds; no timeout if omitted - */ -export async function stowFiles( - files, - endpointUrl, - additionalHeaders = {}, - xmlResponse = false, - timeoutMs -) { - if (files.length === 0) { - return; - } - - const boundary = `StaticWadoBoundary${randomUUID()}`; - const contentType = `multipart/related; type="application/dicom"; boundary=${boundary}`; - - // Create streaming multipart body with multiple files - const { bodyStream, contentLength } = createMultipartBodyStreamMultiple(files, boundary); - - // Prepare headers - const requestHeaders = { - 'Content-Type': contentType, - 'Content-Length': contentLength.toString(), - Accept: xmlResponse ? 'application/dicom+xml' : 'application/dicom+json', - ...additionalHeaders, - }; - - // Timeout: when timeoutMs is omitted, use 24h so long uploads don't fail. node:http allows this. - const effectiveTimeoutMs = - timeoutMs != null && timeoutMs > 0 ? timeoutMs : 24 * 60 * 60 * 1000; - if (timeoutMs != null && timeoutMs > 0) { - console.noQuiet('Setting timeout for', timeoutMs, 'ms'); - } - - const url = new URL(endpointUrl); - const isHttps = url.protocol === 'https:'; - const requestOptions = { - method: 'POST', - hostname: url.hostname, - port: url.port || (isHttps ? 443 : 80), - path: url.pathname + url.search, - headers: requestHeaders, - }; - - const client = isHttps ? https : http; - - const response = await new Promise((resolve, reject) => { - const req = client.request(requestOptions, (res) => { - const chunks = []; - res.on('data', (chunk) => chunks.push(chunk)); - res.on('end', () => { - const responseText = Buffer.concat(chunks).toString('utf-8'); - const responseHeaders = { ...res.headers }; - const responseLike = { - ok: res.statusCode >= 200 && res.statusCode < 300, - status: res.statusCode, - statusText: res.statusMessage || '', - headers: { - get(name) { - const key = Object.keys(responseHeaders).find( - (k) => k.toLowerCase() === name.toLowerCase() - ); - return key ? responseHeaders[key] : null; - }, - }, - }; - resolve({ responseLike, responseText, res }); - }); - res.on('error', reject); - }); - - req.on('error', reject); - - if (effectiveTimeoutMs > 0) { - req.setTimeout(effectiveTimeoutMs, () => { - req.destroy(new Error(`Request timed out after ${effectiveTimeoutMs} ms`)); - }); - } - - bodyStream.pipe(req); - }); - - const { responseLike, responseText, res } = response; - console.verbose('Server response status:', res.statusCode, res.statusMessage); - console.verbose('Server response headers:', res.headers); - if (responseText) { - console.verbose('Server response body:', responseText); - } - - if (!responseLike.ok) { - throw new Error( - `HTTP ${res.statusCode} ${res.statusMessage || ''}: ${responseText}` - ); - } - - // Parse JSON DICOM response if applicable and log errors - await parseAndLogDicomJsonErrors(responseLike, responseText, files); - - return responseLike; -} - -/** - * Creates a multipart/related body for STOW-RS request with multiple files - * @param {Array<{filePath: string, fileSize: number}>} files - Array of file objects - * @param {string} boundary - Multipart boundary - * @returns {Promise} Complete multipart body as buffer - */ -function createMultipartBodyStreamMultiple(files, boundary) { - const footerStr = `\r\n--${boundary}--\r\n`; - const footerLen = Buffer.byteLength(footerStr, 'utf-8'); - - let contentLength = footerLen; - - for (let i = 0; i < files.length; i++) { - const { filePath, fileSize } = files[i]; - const fileName = path.basename(filePath); - const headerStr = multipartPartHeader(boundary, fileName, i === 0); - const headerLen = Buffer.byteLength(headerStr, 'utf-8'); - contentLength += headerLen + fileSize; - } - - async function* gen() { - for (let i = 0; i < files.length; i++) { - const { filePath } = files[i]; - const fileName = path.basename(filePath); - - console.verbose(`Reading file: ${filePath}`); - - yield Buffer.from(multipartPartHeader(boundary, fileName, i === 0), 'utf-8'); - - const fileStream = fs.createReadStream(filePath); - for await (const chunk of fileStream) { - yield chunk; - } - } - - yield Buffer.from(footerStr, 'utf-8'); - } - - return { bodyStream: Readable.from(gen()), contentLength }; -} - -function multipartPartHeader(boundary, fileName, isFirstPart) { - // First part starts with --boundary, subsequent parts need \r\n before --boundary - const boundaryPrefix = isFirstPart ? '' : '\r\n'; - return [ - `${boundaryPrefix}--${boundary}\r\n`, - `Content-Type: application/dicom\r\n`, - `Content-Location: ${fileName}\r\n`, - `\r\n`, - ].join(''); -} - +import fs from "fs"; +import path from "path"; +import http from "node:http"; +import https from "node:https"; +import { randomUUID } from "node:crypto"; +import { Readable } from "node:stream"; +import { + dirScanner, + createPromiseTracker, + createProgressReporter, +} from '@radicalimaging/static-wado-util'; +import { parseAndLogDicomJsonErrors } from './parseDicomJsonErrors.mjs'; + +/** Timeout for createPromiseTracker limitUnsettled when waiting for the next storage slot (at least 30 minutes) */ +const LIMIT_UNSETTLED_TIMEOUT_MS = 30 * 60 * 1000; +/** Timeout for "wait for all" when using parallel (24 hours) */ +const WAIT_ALL_TIMEOUT_MS = 24 * 60 * 60 * 1000; +/** File extensions skipped when filenameCheck is on (lowercase, no dot) */ +const SKIP_EXTENSIONS = new Set([ + 'gz', + 'json', + 'md', + 'txt', + 'xml', + 'pdf', + 'py', + 'jpg', + 'jpeg', + 'png', + 'gif', + 'bmp', + 'webp', + 'svg', + 'ico', + 'tiff', + 'tif', + 'html', + 'htm', + 'css', + 'js', + 'mjs', + 'cjs', + 'zip', + 'zipx', + 'tar', + 'rar', + '7z', + 'properties', + 'db', + 'csv', +]); + +/** + * Stores DICOM files to a STOW-RS endpoint + * @param {string|string[]} fileNames - File(s) or directory(ies) to process + * @param {Object} options - Options object + * @param {string} options.url - URL endpoint for STOW-RS storage + * @param {Object} [options.headers] - Additional HTTP headers to include + * @param {number} [options.maxGroupSize] - Maximum size in bytes for grouping files (default: 10MB) + * @param {boolean} [options.sendAsSingleFiles] - If true, send each file individually instead of grouping (default: false) + * @param {boolean} [options.xmlResponse] - If true, request XML response format instead of JSON (default: false) + * @param {number} [options.timeoutMs] - Request timeout in milliseconds; no timeout if omitted + * @param {number} [options.parallel=1] - Number of parallel STOW-RS requests (1 = sequential). When > 1, limitUnsettled uses at least 30 minutes. + * @param {boolean} [options.filenameCheck=true] - If true, skip common non-DICOM extensions (e.g. gz, json, md, txt, xml, pdf, jpg, png, html, zip). Use false to upload all files. + * @param {boolean} [options.progress] - If true, show progress with instance count as files are stored + * @param {boolean} [options.quiet] - If true, suppress progress and non-error output + * @param {boolean} [options.verbose] - If true, show per-group storage messages + */ +export async function stowMain(fileNames, options = {}) { + const { + url, + headers = {}, + maxGroupSize = 10 * 1024 * 1024, + sendAsSingleFiles = false, + xmlResponse = false, + timeoutMs, + parallel = 1, + filenameCheck = true, + progress = false, + quiet = false, + verbose = false, + } = options; // Default 10MB + + const showProgress = progress && !quiet; + + if (!url) { + throw new Error('url option is required'); + } + + /** Count files that will be processed (respects filenameCheck) */ + const countFiles = async () => { + let count = 0; + await dirScanner(fileNames, { + recursive: true, + callback: async filename => { + if (filenameCheck) { + const ext = path.extname(filename).slice(1).toLowerCase(); + if (SKIP_EXTENSIONS.has(ext)) return; + if (filename.endsWith('DICOMDIR')) return; + } + count++; + }, + }); + return count; + }; + + const totalFiles = showProgress ? await countFiles() : 0; + const progressReporter = createProgressReporter({ total: totalFiles, enabled: showProgress }); + + if (showProgress && totalFiles > 0) { + console.log(`\nStoring ${totalFiles} file(s)...\n`); + } + + const results = { + success: 0, + failed: 0, + errors: [], + }; + + // When sendAsSingleFiles is true, set maxGroupSize to 0 to force groups of size 1 + const effectiveMaxGroupSize = sendAsSingleFiles ? 0 : maxGroupSize; + + // Group files by size + const fileGroup = []; + let currentGroupSize = 0; + + const tracker = createPromiseTracker('stow'); + + const runOneStow = async (group, requestTimeoutMs) => { + try { + await stowFiles(group, url, headers, xmlResponse, requestTimeoutMs); + results.success += group.length; + console.verbose(`Stored group of ${group.length} file(s)`); + progressReporter.addProcessed(group.length); + } catch (error) { + const isConnectionError = + error.message.includes('Unable to connect') || + error.message.includes('fetch failed') || + error.message.includes('ECONNREFUSED') || + error.message.includes('ENOTFOUND') || + error.message.includes('ETIMEDOUT') || + error.message.includes('ECONNRESET') || + error.cause?.code === 'ECONNREFUSED' || + error.cause?.code === 'ENOTFOUND' || + error.cause?.code === 'ETIMEDOUT' || + error.cause?.code === 'ECONNRESET'; + + if (isConnectionError) { + console.error(`Failed to connect to endpoint ${url}: ${error.message}`); + console.error('Exiting due to connection failure'); + process.exit(1); + } + + results.failed += group.length; + progressReporter.addProcessed(group.length); + group.forEach(({ filePath }) => { + results.errors.push({ file: filePath, error: error.message }); + console.error(`Failed to store ${filePath}: ${error.message}`); + }); + } + }; + + const flushGroup = async () => { + if (fileGroup.length === 0) return; + + const snapshot = fileGroup.slice(); + fileGroup.length = 0; + currentGroupSize = 0; + + await tracker.limitUnsettled(parallel, LIMIT_UNSETTLED_TIMEOUT_MS); + tracker.add(runOneStow(snapshot, timeoutMs)); + }; + + await dirScanner(fileNames, { + ...options, + recursive: true, + callback: async filename => { + try { + if (filenameCheck) { + const ext = path.extname(filename).slice(1).toLowerCase(); + if (SKIP_EXTENSIONS.has(ext)) return; + if (filename.endsWith('DICOMDIR')) return; + } + + const stats = fs.statSync(filename); + const fileSize = stats.size; + + // If adding this file would exceed the group size, flush the current group + // (skip this check when sendAsSingleFiles is true, as we'll flush after each file anyway) + if ( + !sendAsSingleFiles && + fileGroup.length > 0 && + currentGroupSize + fileSize > effectiveMaxGroupSize + ) { + await flushGroup(); + } + + // Add file to current group + fileGroup.push({ filePath: filename, fileSize }); + currentGroupSize += fileSize; + + // If sendAsSingleFiles is true, flush after each file (group of size 1) + if (sendAsSingleFiles) { + await flushGroup(); + } + } catch (error) { + results.failed++; + results.errors.push({ file: filename, error: error.message }); + console.error(`Failed to process ${filename}: ${error.message}`); + } + }, + }); + + // Flush any remaining files in the group + await flushGroup(); + + if (!showProgress) { + console.log('Finished starting all stow operations; awaiting remaining store promises...'); + } + // limitUnsettled(1): resolve when unsettled count drops below 1 (i.e. 0 remaining) + await tracker.limitUnsettled(1, WAIT_ALL_TIMEOUT_MS); + + if (showProgress) { + progressReporter.finish(); + } + console.log(`\nStorage complete: ${results.success} succeeded, ${results.failed} failed`); + if (results.errors.length > 0) { + console.log('\nErrors:'); + results.errors.forEach(({ file, error }) => { + console.log(` ${file}: ${error}`); + }); + } + + return results; +} + +/** + * Stores multiple DICOM files to a STOW-RS endpoint in a single multipart request. + * Uses node:http/node:https so timeout is under our control (Bun's fetch has a hardcoded 5-minute limit). + * @param {Array<{filePath: string, fileSize: number}>} files - Array of file objects with path and size + * @param {string} endpointUrl - URL endpoint for STOW-RS storage + * @param {Object} additionalHeaders - Additional HTTP headers to include + * @param {boolean} [xmlResponse=false] - If true, request XML response format instead of JSON + * @param {number} [timeoutMs] - Request timeout in milliseconds; no timeout if omitted + */ +export async function stowFiles( + files, + endpointUrl, + additionalHeaders = {}, + xmlResponse = false, + timeoutMs +) { + if (files.length === 0) { + return; + } + + const boundary = `StaticWadoBoundary${randomUUID()}`; + const contentType = `multipart/related; type="application/dicom"; boundary=${boundary}`; + + // Create streaming multipart body with multiple files + const { bodyStream, contentLength } = createMultipartBodyStreamMultiple(files, boundary); + + // Prepare headers + const requestHeaders = { + 'Content-Type': contentType, + 'Content-Length': contentLength.toString(), + Accept: xmlResponse ? 'application/dicom+xml' : 'application/dicom+json', + ...additionalHeaders, + }; + + // Timeout: when timeoutMs is omitted, use 24h so long uploads don't fail. node:http allows this. + const effectiveTimeoutMs = + timeoutMs != null && timeoutMs > 0 ? timeoutMs : 24 * 60 * 60 * 1000; + if (timeoutMs != null && timeoutMs > 0) { + console.noQuiet('Setting timeout for', timeoutMs, 'ms'); + } + + const url = new URL(endpointUrl); + const isHttps = url.protocol === 'https:'; + const requestOptions = { + method: 'POST', + hostname: url.hostname, + port: url.port || (isHttps ? 443 : 80), + path: url.pathname + url.search, + headers: requestHeaders, + }; + + const client = isHttps ? https : http; + + const response = await new Promise((resolve, reject) => { + const req = client.request(requestOptions, (res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + const responseText = Buffer.concat(chunks).toString('utf-8'); + const responseHeaders = { ...res.headers }; + const responseLike = { + ok: res.statusCode >= 200 && res.statusCode < 300, + status: res.statusCode, + statusText: res.statusMessage || '', + headers: { + get(name) { + const key = Object.keys(responseHeaders).find( + (k) => k.toLowerCase() === name.toLowerCase() + ); + return key ? responseHeaders[key] : null; + }, + }, + }; + resolve({ responseLike, responseText, res }); + }); + res.on('error', reject); + }); + + req.on('error', reject); + + if (effectiveTimeoutMs > 0) { + req.setTimeout(effectiveTimeoutMs, () => { + req.destroy(new Error(`Request timed out after ${effectiveTimeoutMs} ms`)); + }); + } + + bodyStream.pipe(req); + }); + + const { responseLike, responseText, res } = response; + console.verbose('Server response status:', res.statusCode, res.statusMessage); + console.verbose('Server response headers:', res.headers); + if (responseText) { + console.verbose('Server response body:', responseText); + } + + if (!responseLike.ok) { + throw new Error( + `HTTP ${res.statusCode} ${res.statusMessage || ''}: ${responseText}` + ); + } + + // Parse JSON DICOM response if applicable and log errors + await parseAndLogDicomJsonErrors(responseLike, responseText, files); + + return responseLike; +} + +/** + * Creates a multipart/related body for STOW-RS request with multiple files + * @param {Array<{filePath: string, fileSize: number}>} files - Array of file objects + * @param {string} boundary - Multipart boundary + * @returns {Promise} Complete multipart body as buffer + */ +function createMultipartBodyStreamMultiple(files, boundary) { + const footerStr = `\r\n--${boundary}--\r\n`; + const footerLen = Buffer.byteLength(footerStr, 'utf-8'); + + let contentLength = footerLen; + + for (let i = 0; i < files.length; i++) { + const { filePath, fileSize } = files[i]; + const fileName = path.basename(filePath); + const headerStr = multipartPartHeader(boundary, fileName, i === 0); + const headerLen = Buffer.byteLength(headerStr, 'utf-8'); + contentLength += headerLen + fileSize; + } + + async function* gen() { + for (let i = 0; i < files.length; i++) { + const { filePath } = files[i]; + const fileName = path.basename(filePath); + + console.verbose(`Reading file: ${filePath}`); + + yield Buffer.from(multipartPartHeader(boundary, fileName, i === 0), 'utf-8'); + + const fileStream = fs.createReadStream(filePath); + for await (const chunk of fileStream) { + yield chunk; + } + } + + yield Buffer.from(footerStr, 'utf-8'); + } + + return { bodyStream: Readable.from(gen()), contentLength }; +} + +function multipartPartHeader(boundary, fileName, isFirstPart) { + // First part starts with --boundary, subsequent parts need \r\n before --boundary + const boundaryPrefix = isFirstPart ? '' : '\r\n'; + return [ + `${boundaryPrefix}--${boundary}\r\n`, + `Content-Type: application/dicom\r\n`, + `Content-Location: ${fileName}\r\n`, + `\r\n`, + ].join(''); +} + diff --git a/packages/create-dicomweb/lib/commands/studyMain.mjs b/packages/create-dicomweb/lib/commands/studyMain.mjs index 34dbfd7c..8f1194d6 100644 --- a/packages/create-dicomweb/lib/commands/studyMain.mjs +++ b/packages/create-dicomweb/lib/commands/studyMain.mjs @@ -1,28 +1,28 @@ -import { studySummary } from '../instance/StudySummary.mjs'; - -/** - * Main function for processing study metadata - * @param {string} studyUID - Study Instance UID - * @param {Object} options - Options object - * @param {string} [options.dicomdir] - Base directory path where DICOMweb structure is located - */ -export async function studyMain(studyUID, options = {}) { - const { dicomdir } = options; - - if (!dicomdir) { - throw new Error('dicomdir option is required'); - } - - if (!studyUID) { - throw new Error('studyUID is required'); - } - - try { - console.verbose(`Processing study ${studyUID}...`); - await studySummary(dicomdir, studyUID); - console.noQuiet(`Completed study ${studyUID}`); - } catch (error) { - console.error(`Error processing study ${studyUID}: ${error.message}`); - throw error; - } -} +import { studySummary } from '../instance/StudySummary.mjs'; + +/** + * Main function for processing study metadata + * @param {string} studyUID - Study Instance UID + * @param {Object} options - Options object + * @param {string} [options.dicomdir] - Base directory path where DICOMweb structure is located + */ +export async function studyMain(studyUID, options = {}) { + const { dicomdir } = options; + + if (!dicomdir) { + throw new Error('dicomdir option is required'); + } + + if (!studyUID) { + throw new Error('studyUID is required'); + } + + try { + console.verbose(`Processing study ${studyUID}...`); + await studySummary(dicomdir, studyUID); + console.noQuiet(`Completed study ${studyUID}`); + } catch (error) { + console.error(`Error processing study ${studyUID}: ${error.message}`); + throw error; + } +} diff --git a/packages/create-dicomweb/lib/index.mjs b/packages/create-dicomweb/lib/index.mjs index 98fce46d..5b37622e 100644 --- a/packages/create-dicomweb/lib/index.mjs +++ b/packages/create-dicomweb/lib/index.mjs @@ -1,10 +1,10 @@ -export * from './commands/index.mjs'; -export { DicomWebReader } from './instance/DicomWebReader.mjs'; -export { FileDicomWebReader } from './instance/FileDicomWebReader.mjs'; -export { DicomWebWriter } from './instance/DicomWebWriter.mjs'; -export { FileDicomWebWriter } from './instance/FileDicomWebWriter.mjs'; -export { DicomWebStream } from './instance/DicomWebStream.mjs'; -export { StreamInfo } from './instance/StreamInfo.mjs'; -export { seriesSummary } from './instance/SeriesSummary.mjs'; -export { indexSummary } from './instance/IndexSummary.mjs'; +export * from './commands/index.mjs'; +export { DicomWebReader } from './instance/DicomWebReader.mjs'; +export { FileDicomWebReader } from './instance/FileDicomWebReader.mjs'; +export { DicomWebWriter } from './instance/DicomWebWriter.mjs'; +export { FileDicomWebWriter } from './instance/FileDicomWebWriter.mjs'; +export { DicomWebStream } from './instance/DicomWebStream.mjs'; +export { StreamInfo } from './instance/StreamInfo.mjs'; +export { seriesSummary } from './instance/SeriesSummary.mjs'; +export { indexSummary } from './instance/IndexSummary.mjs'; export { instanceFromStream } from './instance/instanceFromStream.mjs'; \ No newline at end of file diff --git a/packages/create-dicomweb/lib/instance/DicomWebStream.mjs b/packages/create-dicomweb/lib/instance/DicomWebStream.mjs index 61365e49..62879576 100644 --- a/packages/create-dicomweb/lib/instance/DicomWebStream.mjs +++ b/packages/create-dicomweb/lib/instance/DicomWebStream.mjs @@ -1,79 +1,79 @@ -import { FileDicomWebReader } from './FileDicomWebReader.mjs'; -import { FileDicomWebWriter } from './FileDicomWebWriter.mjs'; -import { MultipartResponseDicomWebWriter } from './MultipartResponseDicomWebWriter.mjs'; -import { isDicomDirLocation, dicomDirPathFromLocation } from './dicomDirLocation.mjs'; - -/** - * Factory for file-based DICOMweb reader/writer instances from a dicomdir (file) location. - * Use this to obtain FileDicomWebReader or FileDicomWebWriter without introducing circular - * dependencies between base classes and file implementations. - */ -export class DicomWebStream { - /** - * Returns true if the location is a dicomdir (file-system) path. - * File locations have no protocol, use file:, or are Windows drive-letter paths (e.g. C:\). - * - * @param {string} location - URL or path string - * @returns {boolean} - */ - static isDicomDirLocation(location) { - return isDicomDirLocation(location); - } - - /** - * Returns true if the location is a multipart response (multipart:). - * Use with createWriter and options { response } to stream to an Express response. - * - * @param {string} location - URL or path string - * @returns {boolean} - */ - static isMultipartLocation(location) { - return typeof location === 'string' && location.trim().toLowerCase().startsWith('multipart:'); - } - - /** - * If the location is a dicomdir (file) location, returns a FileDicomWebReader instance; - * otherwise returns null. - * - * @param {string} location - Base directory URL or path (file, file:, or no protocol; not http/https etc.) - * @returns {import('./FileDicomWebReader.mjs').FileDicomWebReader|null} - */ - static createReader(location) { - const baseDir = dicomDirPathFromLocation(location); - if (baseDir == null) { - return null; - } - return new FileDicomWebReader(baseDir); - } - - /** - * Creates a writer from options. Handles the distinction between multipart response and output directory. - * - If baseDir/outputDir is "multipart:response:" or options.response is set: returns MultipartResponseDicomWebWriter (options.response required for multipart). - * - Else: returns FileDicomWebWriter with baseDir = options.outputDir ?? options.baseDir ?? '.'. - * - * @param {Object} informationProvider - The information provider instance with UIDs - * @param {Object} options - Options as-is: { response } for multipart; { outputDir } or { baseDir } for file output (use "multipart:response:" for multipart) - * @returns {import('./FileDicomWebWriter.mjs').FileDicomWebWriter|import('./MultipartResponseDicomWebWriter.mjs').MultipartResponseDicomWebWriter|null} - */ - static createWriter(informationProvider, options = {}) { - const opts = options || {}; - const baseDirOrOutput = opts.outputDir ?? opts.baseDir ?? '.'; - const useMultipart = - baseDirOrOutput === 'multipart:response:' || - String(baseDirOrOutput).startsWith('multipart:response:') || - opts.response; - if (useMultipart) { - if (!opts.response) { - throw new Error( - 'createWriter with multipart:response: or options.response requires options.response (Express response)' - ); - } - return new MultipartResponseDicomWebWriter(informationProvider, opts); - } - const resolved = dicomDirPathFromLocation(baseDirOrOutput); - if (resolved == null) { - return null; - } - return new FileDicomWebWriter(informationProvider, { ...opts, baseDir: resolved }); - } -} +import { FileDicomWebReader } from './FileDicomWebReader.mjs'; +import { FileDicomWebWriter } from './FileDicomWebWriter.mjs'; +import { MultipartResponseDicomWebWriter } from './MultipartResponseDicomWebWriter.mjs'; +import { isDicomDirLocation, dicomDirPathFromLocation } from './dicomDirLocation.mjs'; + +/** + * Factory for file-based DICOMweb reader/writer instances from a dicomdir (file) location. + * Use this to obtain FileDicomWebReader or FileDicomWebWriter without introducing circular + * dependencies between base classes and file implementations. + */ +export class DicomWebStream { + /** + * Returns true if the location is a dicomdir (file-system) path. + * File locations have no protocol, use file:, or are Windows drive-letter paths (e.g. C:\). + * + * @param {string} location - URL or path string + * @returns {boolean} + */ + static isDicomDirLocation(location) { + return isDicomDirLocation(location); + } + + /** + * Returns true if the location is a multipart response (multipart:). + * Use with createWriter and options { response } to stream to an Express response. + * + * @param {string} location - URL or path string + * @returns {boolean} + */ + static isMultipartLocation(location) { + return typeof location === 'string' && location.trim().toLowerCase().startsWith('multipart:'); + } + + /** + * If the location is a dicomdir (file) location, returns a FileDicomWebReader instance; + * otherwise returns null. + * + * @param {string} location - Base directory URL or path (file, file:, or no protocol; not http/https etc.) + * @returns {import('./FileDicomWebReader.mjs').FileDicomWebReader|null} + */ + static createReader(location) { + const baseDir = dicomDirPathFromLocation(location); + if (baseDir == null) { + return null; + } + return new FileDicomWebReader(baseDir); + } + + /** + * Creates a writer from options. Handles the distinction between multipart response and output directory. + * - If baseDir/outputDir is "multipart:response:" or options.response is set: returns MultipartResponseDicomWebWriter (options.response required for multipart). + * - Else: returns FileDicomWebWriter with baseDir = options.outputDir ?? options.baseDir ?? '.'. + * + * @param {Object} informationProvider - The information provider instance with UIDs + * @param {Object} options - Options as-is: { response } for multipart; { outputDir } or { baseDir } for file output (use "multipart:response:" for multipart) + * @returns {import('./FileDicomWebWriter.mjs').FileDicomWebWriter|import('./MultipartResponseDicomWebWriter.mjs').MultipartResponseDicomWebWriter|null} + */ + static createWriter(informationProvider, options = {}) { + const opts = options || {}; + const baseDirOrOutput = opts.outputDir ?? opts.baseDir ?? '.'; + const useMultipart = + baseDirOrOutput === 'multipart:response:' || + String(baseDirOrOutput).startsWith('multipart:response:') || + opts.response; + if (useMultipart) { + if (!opts.response) { + throw new Error( + 'createWriter with multipart:response: or options.response requires options.response (Express response)' + ); + } + return new MultipartResponseDicomWebWriter(informationProvider, opts); + } + const resolved = dicomDirPathFromLocation(baseDirOrOutput); + if (resolved == null) { + return null; + } + return new FileDicomWebWriter(informationProvider, { ...opts, baseDir: resolved }); + } +} diff --git a/packages/create-dicomweb/lib/instance/FileDicomWebWriter.mjs b/packages/create-dicomweb/lib/instance/FileDicomWebWriter.mjs index 12dfad92..fde8e678 100644 --- a/packages/create-dicomweb/lib/instance/FileDicomWebWriter.mjs +++ b/packages/create-dicomweb/lib/instance/FileDicomWebWriter.mjs @@ -1,358 +1,358 @@ -import fs from 'fs'; -import { open } from 'fs/promises'; -import path from 'path'; -import { DicomWebWriter } from './DicomWebWriter.mjs'; -import { removeStaleMetadataDirSync } from './removeStaleMetadataDir.mjs'; - -/** - * Derives the temp directory path for a given relativePath. - * If relativePath starts with 'studies//...', uses 'studies//temp/'. - * Otherwise uses 'studies/temp/'. - * @param {string} relativePath - The relative path (e.g. 'studies/1.2.3/series/4.5.6') - * @returns {string} - The temp directory relative path - */ -function getTempRelativePath(relativePath) { - const parts = relativePath.replace(/\\/g, '/').split('/'); - if (parts[0] === 'studies' && parts.length >= 2 && parts[1]) { - return `studies/${parts[1]}/temp`; - } - return 'studies/temp'; -} - -const COMPARE_BLOCK_SIZE = 4096; - -/** - * Compares two files by size then block-by-block without reading entire files into memory. - * Safe for arbitrarily large files (multi-GB). - * @param {string} fileA - Path to first file - * @param {string} fileB - Path to second file - * @returns {Promise} - True if files are byte-identical - */ -async function filesAreIdentical(fileA, fileB) { - let fdA, fdB; - try { - fdA = await open(fileA, 'r'); - fdB = await open(fileB, 'r'); - - const [statA, statB] = await Promise.all([fdA.stat(), fdB.stat()]); - if (statA.size !== statB.size) return false; - // Zero-length files with same size are identical - if (statA.size === 0) return true; - - // Capture destination mtime before comparison so we can detect concurrent writes - const mtimeBefore = statB.mtimeMs; - - const bufA = Buffer.allocUnsafe(COMPARE_BLOCK_SIZE); - const bufB = Buffer.allocUnsafe(COMPARE_BLOCK_SIZE); - let offset = 0; - - while (offset < statA.size) { - const [readA, readB] = await Promise.all([ - fdA.read(bufA, 0, COMPARE_BLOCK_SIZE, offset), - fdB.read(bufB, 0, COMPARE_BLOCK_SIZE, offset), - ]); - if (readA.bytesRead !== readB.bytesRead) return false; - if (!bufA.subarray(0, readA.bytesRead).equals(bufB.subarray(0, readB.bytesRead))) { - return false; - } - offset += readA.bytesRead; - } - - // Re-stat destination to ensure it was not modified during the comparison - const statBAfter = await fdB.stat(); - if (statBAfter?.mtimeMs !== mtimeBefore) return false; - - return true; - } catch { - return false; - } finally { - await fdA?.close().catch(() => {}); - await fdB?.close().catch(() => {}); - } -} - -/** - * Write status values set on streamInfo.writeStatus after closeStream: - * - 'identical' : new file is byte-identical to existing; temp deleted, no rename - * - 'created' : new file; no previous version existed - * - 'updated' : replaced existing file that had the expected mtime - * - 'updated-stale' : replaced existing file whose mtime changed since open (another writer touched it) - */ - -/** - * File-based implementation of DicomWebWriter - * Writes DICOMweb files to the filesystem using temp files for atomicity. - * Files are written to a temp directory and moved to their final location on close. - * - * When `options.deferFinalMove` is true, closeStream() writes and flushes data to - * temp files but does NOT rename them to the final location. Instead, stream info - * is accumulated in `pendingMoves`. Call `commitPendingMoves()` to atomically - * rename all temp files, or `rollbackPendingMoves()` to discard them. - * This supports a "validate before commit" pattern where a pluggable - * `validateInstance` hook runs after all data is written but before files are visible. - */ -export class FileDicomWebWriter extends DicomWebWriter { - constructor(informationProvider, options) { - super(informationProvider, options); - this.deferFinalMove = options?.deferFinalMove ?? false; - /** @type {Array} Stream info objects awaiting final rename */ - this.pendingMoves = []; - } - - /** - * Deletes a file and its .gz counterpart if present. Ignores ENOENT. - * @param {string} relativePath - Relative path within baseDir - * @param {string} filename - Filename to delete (without .gz) - */ - delete(relativePath, filename) { - const fullPath = path.join(this.options.baseDir, relativePath, filename); - for (const p of [fullPath, `${fullPath}.gz`]) { - try { - fs.unlinkSync(p); - } catch (err) { - if (removeStaleMetadataDirSync(p)) { - // Cleaned up old metadata directory - } else if (err?.code !== 'ENOENT') { - console.warn(`[FileDicomWebWriter] Could not delete ${p}:`, err?.message || err); - } - } - } - } - - /** - * Protected method to create the actual stream implementation. - * Writes to a temp file and records original file metadata for safe replacement. - * @param {string} relativePath - The relative path within baseDir - * @param {string} filename - The filename to write - * @param {Object} options - Stream options - * @param {boolean} [options.compareOnClose] - When true, compare temp with existing file on close - * and skip the rename if they are byte-identical - * @returns {Object} - Stream info object (without promise, streamKey) - * @protected - */ - _openStream(relativePath, filename, options = {}) { - console.verbose('openStream', relativePath, filename); - - // Final destination - const finalDir = path.join(this.options.baseDir, relativePath); - const finalFilepath = path.join(finalDir, filename); - - // Record original file's mtime if replacing an existing file - let originalMtime = null; - try { - const stat = fs.statSync(finalFilepath); - if (stat.isDirectory()) { - if (removeStaleMetadataDirSync(finalFilepath)) { - console.noQuiet(`[FileDicomWebWriter] Cleaned up old metadata directory at ${finalFilepath}`); - } else { - throw new Error(`Destination is a directory (not metadata), refusing to delete: ${finalFilepath}`); - } - } else { - originalMtime = stat.mtimeMs; - } - } catch (err) { - if (err?.message?.startsWith('Destination is a directory')) throw err; - // File doesn't exist yet, that's fine - } - - // If writing a .gz file, also clean up any stale metadata directory at the non-.gz path (old format) - if (filename.endsWith('.gz')) { - const nonGzPath = finalFilepath.slice(0, -3); - if (removeStaleMetadataDirSync(nonGzPath)) { - console.noQuiet(`[FileDicomWebWriter] Cleaned up old metadata directory format at ${nonGzPath}`); - } - } - - // Temp directory: studies//temp/ or studies/temp/ - const tempRelPath = getTempRelativePath(relativePath); - const tempDir = path.join(this.options.baseDir, tempRelPath); - - // Ensure temp directory exists - if (!fs.existsSync(tempDir)) { - fs.mkdirSync(tempDir, { recursive: true }); - } - - // Use a unique temp filename to avoid collisions between concurrent writers - // Sanitize filename: replace path separators so e.g. "instances/index.json.gz" doesn't create subdirs - const safeFilename = filename.replace(/[\\/]/g, '-'); - const tempFilename = `${Date.now()}-${Math.random().toString(36).slice(2)}-${safeFilename}`; - const tempFilepath = path.join(tempDir, tempFilename); - const fileStream = fs.createWriteStream(tempFilepath); - - const streamInfo = { - stream: fileStream, - fileStream, - filepath: finalFilepath, - tempFilepath, - finalDir, - filename, - relativePath, - originalMtime, - compareOnClose: options.compareOnClose ?? false, - contentType: options.contentType || 'application/octet-stream', - ...options, - }; - - return streamInfo; - } - - /** - * Closes a stream and moves the temp file to its final destination. - * When compareOnClose is enabled, skips rename if files are identical. - * Sets streamInfo.writeStatus to one of: 'identical', 'created', 'updated', 'updated-stale'. - * @param {string} streamKey - The key identifying the stream - * @returns {Promise} - Resolves with the relative path on success - */ - async closeStream(streamKey) { - const streamInfo = this.openStreams.get(streamKey); - const result = await super.closeStream(streamKey); - - if (result && streamInfo && streamInfo.tempFilepath) { - if (this.deferFinalMove) { - this.pendingMoves.push(streamInfo); - } else { - await this._moveTempToFinal(streamInfo); - } - } - - return result; - } - - /** - * Renames all deferred temp files to their final destinations. - * Only meaningful when `deferFinalMove` is true. Call after validation passes. - */ - async commitPendingMoves() { - for (const streamInfo of this.pendingMoves) { - await this._moveTempToFinal(streamInfo); - } - this.pendingMoves = []; - } - - /** - * Removes all deferred temp files without renaming. - * Only meaningful when `deferFinalMove` is true. Call when validation fails. - */ - rollbackPendingMoves() { - for (const streamInfo of this.pendingMoves) { - this._cleanupTempFile(streamInfo.tempFilepath); - } - this.pendingMoves = []; - } - - /** - * Moves a temp file to its final destination, warning if the original was modified. - * When compareOnClose is set, compares first and skips if identical. - * Sets streamInfo.writeStatus. - * @param {Object} streamInfo - The stream info - * @private - */ - async _moveTempToFinal(streamInfo) { - const { tempFilepath, filepath, finalDir, originalMtime, compareOnClose } = streamInfo; - - try { - // Check the current state of the destination file - let currentMtime = null; - try { - currentMtime = fs.statSync(filepath).mtimeMs; - } catch (err) { - if (err?.code !== 'ENOENT') { - console.warn(`[FileDicomWebWriter] Could not stat ${filepath}:`, err?.message || err); - } - } - - // Detect if the destination changed since we opened: - // - File existed at open and its mtime changed - // - File didn't exist at open but now it does (another writer created it) - const destinationChanged = - (originalMtime !== null && currentMtime !== null && currentMtime !== originalMtime) || - (originalMtime === null && currentMtime !== null); - - // If compareOnClose is enabled and a destination file exists, check identity - if (compareOnClose && currentMtime !== null) { - if (await filesAreIdentical(tempFilepath, filepath)) { - streamInfo.writeStatus = 'identical'; - this._cleanupTempFile(tempFilepath); - return; - } - } - - // Log warning if destination was modified by another writer - if (destinationChanged && originalMtime !== null) { - console.warn( - `[FileDicomWebWriter] WARNING: File ${filepath} was modified by another writer ` + - `since we started (original mtime: ${new Date(originalMtime).toISOString()}, ` + - `current mtime: ${new Date(currentMtime).toISOString()}). Overwriting.` - ); - } else if (destinationChanged && originalMtime === null) { - console.warn( - `[FileDicomWebWriter] WARNING: File ${filepath} was created by another writer ` + - `since we started. Overwriting.` - ); - } - - // Set writeStatus - if (destinationChanged) { - streamInfo.writeStatus = 'updated-stale'; - } else if (originalMtime === null) { - streamInfo.writeStatus = 'created'; - } else { - streamInfo.writeStatus = 'updated'; - } - - // Ensure the final directory exists (it may have been cleaned up, or filename - // may contain path components like "instances/index.json.gz") - const targetDir = path.dirname(filepath); - if (!fs.existsSync(targetDir)) { - fs.mkdirSync(targetDir, { recursive: true }); - } - - // Move temp file to final destination - fs.renameSync(tempFilepath, filepath); - } catch (err) { - console.warn( - `[FileDicomWebWriter] Failed to move temp file ${tempFilepath} to ${filepath}:`, - err?.message || err - ); - // Attempt cleanup of temp file - this._cleanupTempFile(tempFilepath); - throw err; - } - } - - /** - * Records a stream error and cleans up the associated temp file. - * @param {string} streamKey - The key identifying the stream - * @param {Error} error - The error that occurred - * @param {boolean} skipPromiseReject - If true, don't reject the promise - */ - recordStreamError(streamKey, error, skipPromiseReject = false) { - const streamInfo = this.openStreams.get(streamKey); - const tempFilepath = streamInfo?.tempFilepath; - - super.recordStreamError(streamKey, error, skipPromiseReject); - - // Clean up temp file after the stream has been destroyed - if (tempFilepath) { - this._cleanupTempFile(tempFilepath); - } - } - - /** - * Removes a temp file, ignoring ENOENT. - * @param {string} tempFilepath - Path to the temp file - * @private - */ - _cleanupTempFile(tempFilepath) { - try { - fs.unlinkSync(tempFilepath); - } catch (err) { - if (err?.code !== 'ENOENT') { - console.warn( - `[FileDicomWebWriter] Could not clean up temp file ${tempFilepath}:`, - err?.message || err - ); - } - } - } -} +import fs from 'fs'; +import { open } from 'fs/promises'; +import path from 'path'; +import { DicomWebWriter } from './DicomWebWriter.mjs'; +import { removeStaleMetadataDirSync } from './removeStaleMetadataDir.mjs'; + +/** + * Derives the temp directory path for a given relativePath. + * If relativePath starts with 'studies//...', uses 'studies//temp/'. + * Otherwise uses 'studies/temp/'. + * @param {string} relativePath - The relative path (e.g. 'studies/1.2.3/series/4.5.6') + * @returns {string} - The temp directory relative path + */ +function getTempRelativePath(relativePath) { + const parts = relativePath.replace(/\\/g, '/').split('/'); + if (parts[0] === 'studies' && parts.length >= 2 && parts[1]) { + return `studies/${parts[1]}/temp`; + } + return 'studies/temp'; +} + +const COMPARE_BLOCK_SIZE = 4096; + +/** + * Compares two files by size then block-by-block without reading entire files into memory. + * Safe for arbitrarily large files (multi-GB). + * @param {string} fileA - Path to first file + * @param {string} fileB - Path to second file + * @returns {Promise} - True if files are byte-identical + */ +async function filesAreIdentical(fileA, fileB) { + let fdA, fdB; + try { + fdA = await open(fileA, 'r'); + fdB = await open(fileB, 'r'); + + const [statA, statB] = await Promise.all([fdA.stat(), fdB.stat()]); + if (statA.size !== statB.size) return false; + // Zero-length files with same size are identical + if (statA.size === 0) return true; + + // Capture destination mtime before comparison so we can detect concurrent writes + const mtimeBefore = statB.mtimeMs; + + const bufA = Buffer.allocUnsafe(COMPARE_BLOCK_SIZE); + const bufB = Buffer.allocUnsafe(COMPARE_BLOCK_SIZE); + let offset = 0; + + while (offset < statA.size) { + const [readA, readB] = await Promise.all([ + fdA.read(bufA, 0, COMPARE_BLOCK_SIZE, offset), + fdB.read(bufB, 0, COMPARE_BLOCK_SIZE, offset), + ]); + if (readA.bytesRead !== readB.bytesRead) return false; + if (!bufA.subarray(0, readA.bytesRead).equals(bufB.subarray(0, readB.bytesRead))) { + return false; + } + offset += readA.bytesRead; + } + + // Re-stat destination to ensure it was not modified during the comparison + const statBAfter = await fdB.stat(); + if (statBAfter?.mtimeMs !== mtimeBefore) return false; + + return true; + } catch { + return false; + } finally { + await fdA?.close().catch(() => {}); + await fdB?.close().catch(() => {}); + } +} + +/** + * Write status values set on streamInfo.writeStatus after closeStream: + * - 'identical' : new file is byte-identical to existing; temp deleted, no rename + * - 'created' : new file; no previous version existed + * - 'updated' : replaced existing file that had the expected mtime + * - 'updated-stale' : replaced existing file whose mtime changed since open (another writer touched it) + */ + +/** + * File-based implementation of DicomWebWriter + * Writes DICOMweb files to the filesystem using temp files for atomicity. + * Files are written to a temp directory and moved to their final location on close. + * + * When `options.deferFinalMove` is true, closeStream() writes and flushes data to + * temp files but does NOT rename them to the final location. Instead, stream info + * is accumulated in `pendingMoves`. Call `commitPendingMoves()` to atomically + * rename all temp files, or `rollbackPendingMoves()` to discard them. + * This supports a "validate before commit" pattern where a pluggable + * `validateInstance` hook runs after all data is written but before files are visible. + */ +export class FileDicomWebWriter extends DicomWebWriter { + constructor(informationProvider, options) { + super(informationProvider, options); + this.deferFinalMove = options?.deferFinalMove ?? false; + /** @type {Array} Stream info objects awaiting final rename */ + this.pendingMoves = []; + } + + /** + * Deletes a file and its .gz counterpart if present. Ignores ENOENT. + * @param {string} relativePath - Relative path within baseDir + * @param {string} filename - Filename to delete (without .gz) + */ + delete(relativePath, filename) { + const fullPath = path.join(this.options.baseDir, relativePath, filename); + for (const p of [fullPath, `${fullPath}.gz`]) { + try { + fs.unlinkSync(p); + } catch (err) { + if (removeStaleMetadataDirSync(p)) { + // Cleaned up old metadata directory + } else if (err?.code !== 'ENOENT') { + console.warn(`[FileDicomWebWriter] Could not delete ${p}:`, err?.message || err); + } + } + } + } + + /** + * Protected method to create the actual stream implementation. + * Writes to a temp file and records original file metadata for safe replacement. + * @param {string} relativePath - The relative path within baseDir + * @param {string} filename - The filename to write + * @param {Object} options - Stream options + * @param {boolean} [options.compareOnClose] - When true, compare temp with existing file on close + * and skip the rename if they are byte-identical + * @returns {Object} - Stream info object (without promise, streamKey) + * @protected + */ + _openStream(relativePath, filename, options = {}) { + console.verbose('openStream', relativePath, filename); + + // Final destination + const finalDir = path.join(this.options.baseDir, relativePath); + const finalFilepath = path.join(finalDir, filename); + + // Record original file's mtime if replacing an existing file + let originalMtime = null; + try { + const stat = fs.statSync(finalFilepath); + if (stat.isDirectory()) { + if (removeStaleMetadataDirSync(finalFilepath)) { + console.noQuiet(`[FileDicomWebWriter] Cleaned up old metadata directory at ${finalFilepath}`); + } else { + throw new Error(`Destination is a directory (not metadata), refusing to delete: ${finalFilepath}`); + } + } else { + originalMtime = stat.mtimeMs; + } + } catch (err) { + if (err?.message?.startsWith('Destination is a directory')) throw err; + // File doesn't exist yet, that's fine + } + + // If writing a .gz file, also clean up any stale metadata directory at the non-.gz path (old format) + if (filename.endsWith('.gz')) { + const nonGzPath = finalFilepath.slice(0, -3); + if (removeStaleMetadataDirSync(nonGzPath)) { + console.noQuiet(`[FileDicomWebWriter] Cleaned up old metadata directory format at ${nonGzPath}`); + } + } + + // Temp directory: studies//temp/ or studies/temp/ + const tempRelPath = getTempRelativePath(relativePath); + const tempDir = path.join(this.options.baseDir, tempRelPath); + + // Ensure temp directory exists + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + + // Use a unique temp filename to avoid collisions between concurrent writers + // Sanitize filename: replace path separators so e.g. "instances/index.json.gz" doesn't create subdirs + const safeFilename = filename.replace(/[\\/]/g, '-'); + const tempFilename = `${Date.now()}-${Math.random().toString(36).slice(2)}-${safeFilename}`; + const tempFilepath = path.join(tempDir, tempFilename); + const fileStream = fs.createWriteStream(tempFilepath); + + const streamInfo = { + stream: fileStream, + fileStream, + filepath: finalFilepath, + tempFilepath, + finalDir, + filename, + relativePath, + originalMtime, + compareOnClose: options.compareOnClose ?? false, + contentType: options.contentType || 'application/octet-stream', + ...options, + }; + + return streamInfo; + } + + /** + * Closes a stream and moves the temp file to its final destination. + * When compareOnClose is enabled, skips rename if files are identical. + * Sets streamInfo.writeStatus to one of: 'identical', 'created', 'updated', 'updated-stale'. + * @param {string} streamKey - The key identifying the stream + * @returns {Promise} - Resolves with the relative path on success + */ + async closeStream(streamKey) { + const streamInfo = this.openStreams.get(streamKey); + const result = await super.closeStream(streamKey); + + if (result && streamInfo && streamInfo.tempFilepath) { + if (this.deferFinalMove) { + this.pendingMoves.push(streamInfo); + } else { + await this._moveTempToFinal(streamInfo); + } + } + + return result; + } + + /** + * Renames all deferred temp files to their final destinations. + * Only meaningful when `deferFinalMove` is true. Call after validation passes. + */ + async commitPendingMoves() { + for (const streamInfo of this.pendingMoves) { + await this._moveTempToFinal(streamInfo); + } + this.pendingMoves = []; + } + + /** + * Removes all deferred temp files without renaming. + * Only meaningful when `deferFinalMove` is true. Call when validation fails. + */ + rollbackPendingMoves() { + for (const streamInfo of this.pendingMoves) { + this._cleanupTempFile(streamInfo.tempFilepath); + } + this.pendingMoves = []; + } + + /** + * Moves a temp file to its final destination, warning if the original was modified. + * When compareOnClose is set, compares first and skips if identical. + * Sets streamInfo.writeStatus. + * @param {Object} streamInfo - The stream info + * @private + */ + async _moveTempToFinal(streamInfo) { + const { tempFilepath, filepath, finalDir, originalMtime, compareOnClose } = streamInfo; + + try { + // Check the current state of the destination file + let currentMtime = null; + try { + currentMtime = fs.statSync(filepath).mtimeMs; + } catch (err) { + if (err?.code !== 'ENOENT') { + console.warn(`[FileDicomWebWriter] Could not stat ${filepath}:`, err?.message || err); + } + } + + // Detect if the destination changed since we opened: + // - File existed at open and its mtime changed + // - File didn't exist at open but now it does (another writer created it) + const destinationChanged = + (originalMtime !== null && currentMtime !== null && currentMtime !== originalMtime) || + (originalMtime === null && currentMtime !== null); + + // If compareOnClose is enabled and a destination file exists, check identity + if (compareOnClose && currentMtime !== null) { + if (await filesAreIdentical(tempFilepath, filepath)) { + streamInfo.writeStatus = 'identical'; + this._cleanupTempFile(tempFilepath); + return; + } + } + + // Log warning if destination was modified by another writer + if (destinationChanged && originalMtime !== null) { + console.warn( + `[FileDicomWebWriter] WARNING: File ${filepath} was modified by another writer ` + + `since we started (original mtime: ${new Date(originalMtime).toISOString()}, ` + + `current mtime: ${new Date(currentMtime).toISOString()}). Overwriting.` + ); + } else if (destinationChanged && originalMtime === null) { + console.warn( + `[FileDicomWebWriter] WARNING: File ${filepath} was created by another writer ` + + `since we started. Overwriting.` + ); + } + + // Set writeStatus + if (destinationChanged) { + streamInfo.writeStatus = 'updated-stale'; + } else if (originalMtime === null) { + streamInfo.writeStatus = 'created'; + } else { + streamInfo.writeStatus = 'updated'; + } + + // Ensure the final directory exists (it may have been cleaned up, or filename + // may contain path components like "instances/index.json.gz") + const targetDir = path.dirname(filepath); + if (!fs.existsSync(targetDir)) { + fs.mkdirSync(targetDir, { recursive: true }); + } + + // Move temp file to final destination + fs.renameSync(tempFilepath, filepath); + } catch (err) { + console.warn( + `[FileDicomWebWriter] Failed to move temp file ${tempFilepath} to ${filepath}:`, + err?.message || err + ); + // Attempt cleanup of temp file + this._cleanupTempFile(tempFilepath); + throw err; + } + } + + /** + * Records a stream error and cleans up the associated temp file. + * @param {string} streamKey - The key identifying the stream + * @param {Error} error - The error that occurred + * @param {boolean} skipPromiseReject - If true, don't reject the promise + */ + recordStreamError(streamKey, error, skipPromiseReject = false) { + const streamInfo = this.openStreams.get(streamKey); + const tempFilepath = streamInfo?.tempFilepath; + + super.recordStreamError(streamKey, error, skipPromiseReject); + + // Clean up temp file after the stream has been destroyed + if (tempFilepath) { + this._cleanupTempFile(tempFilepath); + } + } + + /** + * Removes a temp file, ignoring ENOENT. + * @param {string} tempFilepath - Path to the temp file + * @private + */ + _cleanupTempFile(tempFilepath) { + try { + fs.unlinkSync(tempFilepath); + } catch (err) { + if (err?.code !== 'ENOENT') { + console.warn( + `[FileDicomWebWriter] Could not clean up temp file ${tempFilepath}:`, + err?.message || err + ); + } + } + } +} diff --git a/packages/create-dicomweb/lib/instance/IndexSummary.mjs b/packages/create-dicomweb/lib/instance/IndexSummary.mjs index 491b11df..2bf1b34d 100644 --- a/packages/create-dicomweb/lib/instance/IndexSummary.mjs +++ b/packages/create-dicomweb/lib/instance/IndexSummary.mjs @@ -1,137 +1,137 @@ -import fs from 'fs'; -import path from 'path'; -import { FileDicomWebReader } from './FileDicomWebReader.mjs'; -import { writeWithRetry } from './writeWithRetry.mjs'; -import { Tags, sortStudies } from '@radicalimaging/static-wado-util'; - -const { getValue } = Tags; - -/** - * Reads existing studies index and study singletons, merges them, and returns - * the final sorted studies index array as JSON. - * @param {FileDicomWebReader} reader - * @param {Set} studiesToProcess - * @returns {Promise} - JSON string of the final studies index - */ -async function buildStudiesIndex(reader, studiesToProcess) { - const studiesIndexPath = 'studies'; - - // Read existing studies/index.json.gz - const existingStudyUIDs = new Map(); - const existingData = await reader.readJsonFile(studiesIndexPath, 'index.json', { deleteFileOnError: true }); - if (existingData && Array.isArray(existingData)) { - for (const studyQuery of existingData) { - const studyUID = getValue(studyQuery, Tags.StudyInstanceUID); - if (studyUID) { - existingStudyUIDs.set(studyUID, studyQuery); - } - } - } - - // Read study singleton files for each study to process - const updatedStudyUIDs = new Map(); - for (const studyUID of studiesToProcess) { - const studyPath = reader.getStudyPath(studyUID); - const studySingleton = await reader.readJsonFile(studyPath, 'index.json', { deleteFileOnError: true }); - if (studySingleton) { - let studyQuery = Array.isArray(studySingleton) && studySingleton.length > 0 - ? studySingleton[0] - : studySingleton; - const studyUIDFromQuery = getValue(studyQuery, Tags.StudyInstanceUID); - if (studyUIDFromQuery) { - updatedStudyUIDs.set(studyUIDFromQuery, studyQuery); - console.verbose(`indexSummary: read study singleton for ${studyUIDFromQuery}`); - } else { - console.warn(`indexSummary: study singleton for ${studyUID} missing StudyInstanceUID`); - } - } else { - console.noQuiet(`indexSummary: study singleton file not found for ${studyUID}`); - } - } - - // Build the updated studies index - const finalStudiesIndex = []; - const processedUIDs = new Set(updatedStudyUIDs.keys()); - - for (const [studyUID, studyQuery] of existingStudyUIDs.entries()) { - if (!processedUIDs.has(studyUID)) { - const studyPath = reader.getStudyPath(studyUID); - const studyDirPath = path.join(reader.baseDir, studyPath); - if (fs.existsSync(studyDirPath) && fs.lstatSync(studyDirPath).isDirectory()) { - finalStudiesIndex.push(studyQuery); - } else { - console.noQuiet(`indexSummary: removing study ${studyUID} from index (directory not found)`); - } - } - } - - for (const [studyUID, studyQuery] of updatedStudyUIDs.entries()) { - finalStudiesIndex.push(studyQuery); - } - - sortStudies(finalStudiesIndex); - return JSON.stringify(finalStudiesIndex); -} - -/** - * Creates or updates studies/index.json.gz file by adding/updating study information - * - * @param {string} baseDir - Base directory for DICOMweb structure - * @param {string[]} studyUIDs - Optional array of Study Instance UIDs to process (if empty, scans all studies) - * @returns {Promise} - */ -export async function indexSummary(baseDir, studyUIDs = []) { - if (!baseDir) { - throw new Error('baseDir is required'); - } - - const reader = new FileDicomWebReader(baseDir); - const studiesIndexPath = 'studies'; - - // Determine which studies to process - let studiesToProcess = new Set(); - - if (studyUIDs.length === 0) { - console.noQuiet('indexSummary: scanning studies directory for all studies'); - const studiesPath = path.join(reader.baseDir, studiesIndexPath); - - if (fs.existsSync(studiesPath)) { - const studyDirectories = await reader.scanDirectory(studiesIndexPath, { withFileTypes: true }); - - for (const entry of studyDirectories) { - if (entry && typeof entry === 'object' && entry.isDirectory && entry.isDirectory()) { - studiesToProcess.add(entry.name); - } else if (typeof entry === 'string') { - const studyDirPath = `${studiesIndexPath}/${entry}`; - const fullStudyPath = path.join(reader.baseDir, studyDirPath); - try { - const stats = fs.lstatSync(fullStudyPath); - if (stats.isDirectory()) { - studiesToProcess.add(entry); - } - } catch (error) { - console.warn(`Could not stat ${studyDirPath}: ${error.message}`); - } - } - } - } - } else { - for (const studyUID of studyUIDs) { - studiesToProcess.add(studyUID); - } - } - - console.verbose(`indexSummary: processing ${studiesToProcess.size} studies`); - - // Write the updated studies/index.json.gz file with retry - console.noQuiet('indexSummary: writing updated studies index'); - - // Use an empty informationProvider since we're writing at the root studies level - await writeWithRetry({ - informationProvider: {}, - baseDir, - openStream: (writer) => writer.openStream(studiesIndexPath, 'index.json', { gzip: true, compareOnClose: true }), - generateData: () => buildStudiesIndex(reader, studiesToProcess), - label: 'indexSummary studies/index.json', - }); -} +import fs from 'fs'; +import path from 'path'; +import { FileDicomWebReader } from './FileDicomWebReader.mjs'; +import { writeWithRetry } from './writeWithRetry.mjs'; +import { Tags, sortStudies } from '@radicalimaging/static-wado-util'; + +const { getValue } = Tags; + +/** + * Reads existing studies index and study singletons, merges them, and returns + * the final sorted studies index array as JSON. + * @param {FileDicomWebReader} reader + * @param {Set} studiesToProcess + * @returns {Promise} - JSON string of the final studies index + */ +async function buildStudiesIndex(reader, studiesToProcess) { + const studiesIndexPath = 'studies'; + + // Read existing studies/index.json.gz + const existingStudyUIDs = new Map(); + const existingData = await reader.readJsonFile(studiesIndexPath, 'index.json', { deleteFileOnError: true }); + if (existingData && Array.isArray(existingData)) { + for (const studyQuery of existingData) { + const studyUID = getValue(studyQuery, Tags.StudyInstanceUID); + if (studyUID) { + existingStudyUIDs.set(studyUID, studyQuery); + } + } + } + + // Read study singleton files for each study to process + const updatedStudyUIDs = new Map(); + for (const studyUID of studiesToProcess) { + const studyPath = reader.getStudyPath(studyUID); + const studySingleton = await reader.readJsonFile(studyPath, 'index.json', { deleteFileOnError: true }); + if (studySingleton) { + let studyQuery = Array.isArray(studySingleton) && studySingleton.length > 0 + ? studySingleton[0] + : studySingleton; + const studyUIDFromQuery = getValue(studyQuery, Tags.StudyInstanceUID); + if (studyUIDFromQuery) { + updatedStudyUIDs.set(studyUIDFromQuery, studyQuery); + console.verbose(`indexSummary: read study singleton for ${studyUIDFromQuery}`); + } else { + console.warn(`indexSummary: study singleton for ${studyUID} missing StudyInstanceUID`); + } + } else { + console.noQuiet(`indexSummary: study singleton file not found for ${studyUID}`); + } + } + + // Build the updated studies index + const finalStudiesIndex = []; + const processedUIDs = new Set(updatedStudyUIDs.keys()); + + for (const [studyUID, studyQuery] of existingStudyUIDs.entries()) { + if (!processedUIDs.has(studyUID)) { + const studyPath = reader.getStudyPath(studyUID); + const studyDirPath = path.join(reader.baseDir, studyPath); + if (fs.existsSync(studyDirPath) && fs.lstatSync(studyDirPath).isDirectory()) { + finalStudiesIndex.push(studyQuery); + } else { + console.noQuiet(`indexSummary: removing study ${studyUID} from index (directory not found)`); + } + } + } + + for (const [studyUID, studyQuery] of updatedStudyUIDs.entries()) { + finalStudiesIndex.push(studyQuery); + } + + sortStudies(finalStudiesIndex); + return JSON.stringify(finalStudiesIndex); +} + +/** + * Creates or updates studies/index.json.gz file by adding/updating study information + * + * @param {string} baseDir - Base directory for DICOMweb structure + * @param {string[]} studyUIDs - Optional array of Study Instance UIDs to process (if empty, scans all studies) + * @returns {Promise} + */ +export async function indexSummary(baseDir, studyUIDs = []) { + if (!baseDir) { + throw new Error('baseDir is required'); + } + + const reader = new FileDicomWebReader(baseDir); + const studiesIndexPath = 'studies'; + + // Determine which studies to process + let studiesToProcess = new Set(); + + if (studyUIDs.length === 0) { + console.noQuiet('indexSummary: scanning studies directory for all studies'); + const studiesPath = path.join(reader.baseDir, studiesIndexPath); + + if (fs.existsSync(studiesPath)) { + const studyDirectories = await reader.scanDirectory(studiesIndexPath, { withFileTypes: true }); + + for (const entry of studyDirectories) { + if (entry && typeof entry === 'object' && entry.isDirectory && entry.isDirectory()) { + studiesToProcess.add(entry.name); + } else if (typeof entry === 'string') { + const studyDirPath = `${studiesIndexPath}/${entry}`; + const fullStudyPath = path.join(reader.baseDir, studyDirPath); + try { + const stats = fs.lstatSync(fullStudyPath); + if (stats.isDirectory()) { + studiesToProcess.add(entry); + } + } catch (error) { + console.warn(`Could not stat ${studyDirPath}: ${error.message}`); + } + } + } + } + } else { + for (const studyUID of studyUIDs) { + studiesToProcess.add(studyUID); + } + } + + console.verbose(`indexSummary: processing ${studiesToProcess.size} studies`); + + // Write the updated studies/index.json.gz file with retry + console.noQuiet('indexSummary: writing updated studies index'); + + // Use an empty informationProvider since we're writing at the root studies level + await writeWithRetry({ + informationProvider: {}, + baseDir, + openStream: (writer) => writer.openStream(studiesIndexPath, 'index.json', { gzip: true, compareOnClose: true }), + generateData: () => buildStudiesIndex(reader, studiesToProcess), + label: 'indexSummary studies/index.json', + }); +} diff --git a/packages/create-dicomweb/lib/instance/MultipartResponseDicomWebWriter.mjs b/packages/create-dicomweb/lib/instance/MultipartResponseDicomWebWriter.mjs index dbb35645..23e9f14f 100644 --- a/packages/create-dicomweb/lib/instance/MultipartResponseDicomWebWriter.mjs +++ b/packages/create-dicomweb/lib/instance/MultipartResponseDicomWebWriter.mjs @@ -1,164 +1,164 @@ -import { Writable } from 'stream'; -import { v4 as uuid } from 'uuid'; -import { DicomWebWriter } from './DicomWebWriter.mjs'; - -/** - * Writable that writes a single multipart/related part to an Express response. - * Does not end the response; the writer finalizes the response when the last stream is closed. - */ -class MultipartPartStream extends Writable { - /** - * @param {Object} response - Express response object - * @param {string} boundary - Multipart boundary - * @param {string} contentLocation - Content-Location (filename) for this part - */ - constructor(response, boundary, contentLocation) { - super(); - this.response = response; - this.boundary = boundary; - this.contentLocation = contentLocation; - this.headerWritten = false; - } - - _write(chunk, encoding, callback) { - if (!this.headerWritten) { - this.headerWritten = true; - const header = - `--${this.boundary}\r\n` + - `Content-Type: application/dicom\r\n` + - `Content-Location: ${this.contentLocation}\r\n` + - `\r\n`; - this.response.write(header, 'utf8', err => { - if (err) return callback(err); - this.response.write(chunk, encoding, callback); - }); - } else { - this.response.write(chunk, encoding, callback); - } - } - - _final(callback) { - this.response.write('\r\n', 'utf8', callback); - } -} - -/** - * DicomWebWriter that streams Part 10 instances to an Express response as multipart/related. - * Use location "multipart:" with DicomWebStream.createWriter and options { response }. - */ -export class MultipartResponseDicomWebWriter extends DicomWebWriter { - /** - * @param {Object} informationProvider - The information provider instance with UIDs - * @param {Object} options - Configuration options - * @param {import('express').Response} options.response - Express response object to stream to - */ - constructor(informationProvider, options = {}) { - if (!options?.response) { - throw new Error('options.response (Express response) is required for MultipartResponseDicomWebWriter'); - } - super(informationProvider, { ...options, baseDir: '.' }); - this.response = options.response; - this._boundary = null; - this._headersSent = false; - } - - _getBoundary() { - if (!this._boundary) { - this._boundary = `BOUNDARY_${uuid().replace(/-/g, '')}`; - } - return this._boundary; - } - - _ensureResponseHeaders() { - if (this._headersSent) return; - this._headersSent = true; - const boundary = this._getBoundary(); - this.response.setHeader( - 'Content-Type', - `multipart/related; type="application/dicom"; boundary=${boundary}` - ); - } - - /** - * @param {string} path - Ignored; path is not used for response streaming - * @param {string} filename - Used as Content-Location for the part - * @param {Object} options - Stream options - * @returns {Object} - Stream info with stream writing to the response - * @protected - */ - _openStream(path, filename, options = {}) { - this._ensureResponseHeaders(); - const boundary = this._getBoundary(); - const partStream = new MultipartPartStream(this.response, boundary, filename); - return { - stream: partStream, - fileStream: partStream, - filename, - relativePath: path, - contentType: 'application/dicom', - ...options, - }; - } - - /** - * Closes the stream and, if this was the last open stream, finalizes the multipart response. - * @param {string} streamKey - The key identifying the stream - * @returns {Promise} - */ - async closeStream(streamKey) { - const streamInfo = this.openStreams.get(streamKey); - if (!streamInfo) { - return undefined; - } - - try { - await streamInfo.end(); - const relativePath = streamInfo.failed ? undefined : streamInfo.getCloseResult(); - - if (streamInfo._resolve) { - streamInfo._resolve(relativePath); - } - - this.openStreams.delete(streamKey); - - if (this.openStreams.size === 0 && this.response && !this.response.writableEnded) { - const closing = `\r\n--${this._getBoundary()}--\r\n`; - return new Promise((resolve, reject) => { - this.response.write(closing, 'utf8', err => { - if (err) { - reject(err); - return; - } - this.response.end(() => resolve(relativePath)); - }); - }); - } - - return relativePath; - } catch (error) { - try { - this.recordStreamError(streamKey, error, true); - } catch (recordErr) { - console.error(`Error recording stream failure for ${streamKey}:`, recordErr); - } - - if (streamInfo._resolve) { - streamInfo._resolve(undefined); - } - - this.openStreams.delete(streamKey); - - if (this.openStreams.size === 0 && this.response && !this.response.writableEnded) { - try { - this.response.write(`\r\n--${this._getBoundary()}--\r\n`, 'utf8', () => { - this.response.end(); - }); - } catch (_) { - this.response.end(); - } - } - - return undefined; - } - } -} +import { Writable } from 'stream'; +import { v4 as uuid } from 'uuid'; +import { DicomWebWriter } from './DicomWebWriter.mjs'; + +/** + * Writable that writes a single multipart/related part to an Express response. + * Does not end the response; the writer finalizes the response when the last stream is closed. + */ +class MultipartPartStream extends Writable { + /** + * @param {Object} response - Express response object + * @param {string} boundary - Multipart boundary + * @param {string} contentLocation - Content-Location (filename) for this part + */ + constructor(response, boundary, contentLocation) { + super(); + this.response = response; + this.boundary = boundary; + this.contentLocation = contentLocation; + this.headerWritten = false; + } + + _write(chunk, encoding, callback) { + if (!this.headerWritten) { + this.headerWritten = true; + const header = + `--${this.boundary}\r\n` + + `Content-Type: application/dicom\r\n` + + `Content-Location: ${this.contentLocation}\r\n` + + `\r\n`; + this.response.write(header, 'utf8', err => { + if (err) return callback(err); + this.response.write(chunk, encoding, callback); + }); + } else { + this.response.write(chunk, encoding, callback); + } + } + + _final(callback) { + this.response.write('\r\n', 'utf8', callback); + } +} + +/** + * DicomWebWriter that streams Part 10 instances to an Express response as multipart/related. + * Use location "multipart:" with DicomWebStream.createWriter and options { response }. + */ +export class MultipartResponseDicomWebWriter extends DicomWebWriter { + /** + * @param {Object} informationProvider - The information provider instance with UIDs + * @param {Object} options - Configuration options + * @param {import('express').Response} options.response - Express response object to stream to + */ + constructor(informationProvider, options = {}) { + if (!options?.response) { + throw new Error('options.response (Express response) is required for MultipartResponseDicomWebWriter'); + } + super(informationProvider, { ...options, baseDir: '.' }); + this.response = options.response; + this._boundary = null; + this._headersSent = false; + } + + _getBoundary() { + if (!this._boundary) { + this._boundary = `BOUNDARY_${uuid().replace(/-/g, '')}`; + } + return this._boundary; + } + + _ensureResponseHeaders() { + if (this._headersSent) return; + this._headersSent = true; + const boundary = this._getBoundary(); + this.response.setHeader( + 'Content-Type', + `multipart/related; type="application/dicom"; boundary=${boundary}` + ); + } + + /** + * @param {string} path - Ignored; path is not used for response streaming + * @param {string} filename - Used as Content-Location for the part + * @param {Object} options - Stream options + * @returns {Object} - Stream info with stream writing to the response + * @protected + */ + _openStream(path, filename, options = {}) { + this._ensureResponseHeaders(); + const boundary = this._getBoundary(); + const partStream = new MultipartPartStream(this.response, boundary, filename); + return { + stream: partStream, + fileStream: partStream, + filename, + relativePath: path, + contentType: 'application/dicom', + ...options, + }; + } + + /** + * Closes the stream and, if this was the last open stream, finalizes the multipart response. + * @param {string} streamKey - The key identifying the stream + * @returns {Promise} + */ + async closeStream(streamKey) { + const streamInfo = this.openStreams.get(streamKey); + if (!streamInfo) { + return undefined; + } + + try { + await streamInfo.end(); + const relativePath = streamInfo.failed ? undefined : streamInfo.getCloseResult(); + + if (streamInfo._resolve) { + streamInfo._resolve(relativePath); + } + + this.openStreams.delete(streamKey); + + if (this.openStreams.size === 0 && this.response && !this.response.writableEnded) { + const closing = `\r\n--${this._getBoundary()}--\r\n`; + return new Promise((resolve, reject) => { + this.response.write(closing, 'utf8', err => { + if (err) { + reject(err); + return; + } + this.response.end(() => resolve(relativePath)); + }); + }); + } + + return relativePath; + } catch (error) { + try { + this.recordStreamError(streamKey, error, true); + } catch (recordErr) { + console.error(`Error recording stream failure for ${streamKey}:`, recordErr); + } + + if (streamInfo._resolve) { + streamInfo._resolve(undefined); + } + + this.openStreams.delete(streamKey); + + if (this.openStreams.size === 0 && this.response && !this.response.writableEnded) { + try { + this.response.write(`\r\n--${this._getBoundary()}--\r\n`, 'utf8', () => { + this.response.end(); + }); + } catch (_) { + this.response.end(); + } + } + + return undefined; + } + } +} diff --git a/packages/create-dicomweb/lib/instance/MultipartStreamWriter.mjs b/packages/create-dicomweb/lib/instance/MultipartStreamWriter.mjs index 34e06892..9d0a55ed 100644 --- a/packages/create-dicomweb/lib/instance/MultipartStreamWriter.mjs +++ b/packages/create-dicomweb/lib/instance/MultipartStreamWriter.mjs @@ -1,95 +1,95 @@ -import { Writable } from 'stream'; - -/** - * A stream wrapper that adds multipart/related headers and footers - * Wraps a parent stream and adds MIME multipart formatting - */ -export class MultipartStreamWriter extends Writable { - /** - * @param {Object} parentStream - The stream to write multipart data to - * @param {Object} options - Multipart options - * @param {string} options.boundary - The multipart boundary - * @param {string} options.contentType - The content type for the part - * @param {string} options.contentLocation - The content location (filename) - */ - constructor(parentStream, options = {}) { - super(); - - this.parentStream = parentStream; - this.boundary = options.boundary; - this.contentType = options.contentType; - this.contentLocation = options.contentLocation; - this.headerWritten = false; - this.footerWritten = false; - - // Forward errors from parent stream - this.parentStream.on('error', (error) => { - this.destroy(error); - }); - } - - /** - * Writes the multipart header on first write - * @private - */ - _writeHeader() { - if (this.headerWritten) return; - - const headerLines = [ - `--${this.boundary}\r\n`, - `Content-Type: ${this.contentType}\r\n`, - `Content-Location: ${this.contentLocation}\r\n`, - `\r\n`, - ].join(''); - - this.parentStream.write(headerLines, 'utf-8'); - this.headerWritten = true; - } - - /** - * Writes the multipart footer - * @private - */ - _writeFooter() { - if (this.footerWritten) return; - - const footer = `\r\n--${this.boundary}--\r\n`; - this.parentStream.write(footer, 'utf-8'); - this.footerWritten = true; - } - - /** - * Implements the writable stream _write method - * @param {Buffer|string} chunk - The data to write - * @param {string} encoding - The encoding (if chunk is string) - * @param {Function} callback - Callback when write is complete - * @private - */ - _write(chunk, encoding, callback) { - // Write header on first write - if (!this.headerWritten) { - this._writeHeader(); - } - - // Write the actual data to parent stream - this.parentStream.write(chunk, encoding, callback); - } - - /** - * Implements the writable stream _final method - * Called when the stream is ending - * @param {Function} callback - Callback when finalization is complete - * @private - */ - _final(callback) { - // Write footer before ending - this._writeFooter(); - - // End the parent stream - if (this.parentStream && typeof this.parentStream.end === 'function') { - this.parentStream.end(callback); - } else { - callback(); - } - } -} +import { Writable } from 'stream'; + +/** + * A stream wrapper that adds multipart/related headers and footers + * Wraps a parent stream and adds MIME multipart formatting + */ +export class MultipartStreamWriter extends Writable { + /** + * @param {Object} parentStream - The stream to write multipart data to + * @param {Object} options - Multipart options + * @param {string} options.boundary - The multipart boundary + * @param {string} options.contentType - The content type for the part + * @param {string} options.contentLocation - The content location (filename) + */ + constructor(parentStream, options = {}) { + super(); + + this.parentStream = parentStream; + this.boundary = options.boundary; + this.contentType = options.contentType; + this.contentLocation = options.contentLocation; + this.headerWritten = false; + this.footerWritten = false; + + // Forward errors from parent stream + this.parentStream.on('error', (error) => { + this.destroy(error); + }); + } + + /** + * Writes the multipart header on first write + * @private + */ + _writeHeader() { + if (this.headerWritten) return; + + const headerLines = [ + `--${this.boundary}\r\n`, + `Content-Type: ${this.contentType}\r\n`, + `Content-Location: ${this.contentLocation}\r\n`, + `\r\n`, + ].join(''); + + this.parentStream.write(headerLines, 'utf-8'); + this.headerWritten = true; + } + + /** + * Writes the multipart footer + * @private + */ + _writeFooter() { + if (this.footerWritten) return; + + const footer = `\r\n--${this.boundary}--\r\n`; + this.parentStream.write(footer, 'utf-8'); + this.footerWritten = true; + } + + /** + * Implements the writable stream _write method + * @param {Buffer|string} chunk - The data to write + * @param {string} encoding - The encoding (if chunk is string) + * @param {Function} callback - Callback when write is complete + * @private + */ + _write(chunk, encoding, callback) { + // Write header on first write + if (!this.headerWritten) { + this._writeHeader(); + } + + // Write the actual data to parent stream + this.parentStream.write(chunk, encoding, callback); + } + + /** + * Implements the writable stream _final method + * Called when the stream is ending + * @param {Function} callback - Callback when finalization is complete + * @private + */ + _final(callback) { + // Write footer before ending + this._writeFooter(); + + // End the parent stream + if (this.parentStream && typeof this.parentStream.end === 'function') { + this.parentStream.end(callback); + } else { + callback(); + } + } +} diff --git a/packages/create-dicomweb/lib/instance/SeriesSummary.mjs b/packages/create-dicomweb/lib/instance/SeriesSummary.mjs index 6c64c8b4..d1b8ef37 100644 --- a/packages/create-dicomweb/lib/instance/SeriesSummary.mjs +++ b/packages/create-dicomweb/lib/instance/SeriesSummary.mjs @@ -1,205 +1,205 @@ -import fs from 'fs'; -import path from 'path'; -import { FileDicomWebReader } from './FileDicomWebReader.mjs'; -import { writeMultipleWithRetry } from './writeWithRetry.mjs'; -import { Tags, TagLists } from '@radicalimaging/static-wado-util'; - -const { getValue, setValue } = Tags; - -/** - * Updates BulkDataURI values from instance-relative paths to series-relative paths - * so that series-level metadata can resolve bulk data and frames when read from - * the series directory. - * - * Instance-relative paths: - * - "../../../../bulkdata/" (4 levels up from instance) -> "../../bulkdata/" (2 levels up from series) - * - "./frames" -> "instances//frames" - * - * @param {Object} instanceMetadata - Instance metadata object to update - * @param {string} instanceUID - The instance (SOP Instance) UID for this metadata - * @returns {Object} - Updated instance metadata - */ -function updateLocation(instanceMetadata, instanceUID) { - if (!instanceMetadata || typeof instanceMetadata !== 'object') { - return instanceMetadata; - } - - function processObject(obj, instanceUid) { - if (Array.isArray(obj)) { - return obj.map(item => processObject(item, instanceUid)); - } - - if (obj && typeof obj === 'object') { - const result = {}; - - for (const [key, value] of Object.entries(obj)) { - if (key === 'BulkDataURI' && typeof value === 'string') { - if (value === './frames' || value.startsWith('./frames')) { - result[key] = `./instances/${instanceUid}/frames`; - } else { - result[key] = value.replace(/^(\.\.\/){4}bulkdata\//, '../../bulkdata/'); - } - } else { - result[key] = processObject(value, instanceUid); - } - } - - return result; - } - - return obj; - } - - return processObject(instanceMetadata, instanceUID); -} - -/** - * Reads all instance metadata and derives series query data for a series. - * @param {FileDicomWebReader} reader - * @param {string} studyUID - * @param {string} seriesUID - * @param {Set} actualInstanceUIDs - * @returns {Promise<{instanceMetadataArray: Object[], seriesQuery: Object|null, instancesQuery: Object[]}>} - */ -async function readSeriesData(reader, studyUID, seriesUID, actualInstanceUIDs) { - const instanceMetadataArray = []; - - for (const instanceUID of actualInstanceUIDs) { - const instancePath = reader.getInstancePath(studyUID, seriesUID, instanceUID); - let instanceMetadata = await reader.readJsonFile(instancePath, 'metadata', { - deleteFileOnError: true, - }); - if (instanceMetadata) { - if (Array.isArray(instanceMetadata) && instanceMetadata.length > 0) { - instanceMetadata = instanceMetadata[0]; - } - instanceMetadata = updateLocation(instanceMetadata, instanceUID); - instanceMetadataArray.push(instanceMetadata); - } - } - - // Sort by InstanceNumber - instanceMetadataArray.sort((a, b) => { - const instanceNumberA = getValue(a, Tags.InstanceNumber); - const instanceNumberB = getValue(b, Tags.InstanceNumber); - const numA = instanceNumberA !== undefined ? Number(instanceNumberA) : Number.MAX_SAFE_INTEGER; - const numB = instanceNumberB !== undefined ? Number(instanceNumberB) : Number.MAX_SAFE_INTEGER; - if (!isNaN(numA) && !isNaN(numB)) { - return numA - numB; - } - const strA = String(instanceNumberA || ''); - const strB = String(instanceNumberB || ''); - return strA.localeCompare(strB); - }); - - // Extract series query and instances query - let seriesQuery = null; - const instancesQuery = []; - - if (instanceMetadataArray.length > 0) { - const firstInstance = instanceMetadataArray[0]; - seriesQuery = TagLists.extract(firstInstance, 'series', TagLists.SeriesQuery); - setValue(seriesQuery, Tags.NumberOfSeriesRelatedInstances, instanceMetadataArray.length); - - for (const instanceMetadata of instanceMetadataArray) { - const instanceQuery = TagLists.extract(instanceMetadata, 'instance', TagLists.InstanceQuery); - instancesQuery.push(instanceQuery); - } - } - - return { instanceMetadataArray, seriesQuery, instancesQuery }; -} - -/** - * Creates or updates series metadata/index.json.gz file - * - * @param {string} baseDir - Base directory for DICOMweb structure - * @param {string} studyUID - Study Instance UID - * @param {string} seriesUID - Series Instance UID - * @returns {Promise} - */ -export async function seriesSummary(baseDir, studyUID, seriesUID, options) { - if (!baseDir || !studyUID || !seriesUID) { - throw new Error('baseDir, studyUID, and seriesUID are required'); - } - - const reader = new FileDicomWebReader(baseDir); - const seriesPath = reader.getSeriesPath(studyUID, seriesUID); - const instancesPath = `${seriesPath}/instances`; - - // Check if series metadata exists - let existingInstanceUIDs = new Set(); - const existingMetadata = await reader.readJsonFile(seriesPath, 'metadata', { - deleteFileOnError: true, - }); - if (existingMetadata && Array.isArray(existingMetadata)) { - for (const instance of existingMetadata) { - const sopUID = getValue(instance, Tags.SOPInstanceUID); - if (sopUID) { - existingInstanceUIDs.add(sopUID); - } - } - } - - // Scan the instances directory to get actual instance UIDs - const instanceDirectories = await reader.scanDirectory(instancesPath, { withFileTypes: true }); - const actualInstanceUIDs = new Set(); - - for (const entry of instanceDirectories) { - if (entry && typeof entry === 'object' && entry.isDirectory && entry.isDirectory()) { - actualInstanceUIDs.add(entry.name); - } else if (typeof entry === 'string') { - const instanceDirPath = `${instancesPath}/${entry}`; - const fullInstancePath = path.join(reader.baseDir, instanceDirPath); - try { - const stats = fs.lstatSync(fullInstancePath); - if (stats.isDirectory()) { - actualInstanceUIDs.add(entry); - } - } catch (error) { - console.warn(`Could not stat ${instanceDirPath}: ${error.message}`); - } - } - } - - // Compare sets - if they match exactly, return early - if ( - existingInstanceUIDs.size === actualInstanceUIDs.size && - [...existingInstanceUIDs].every(uid => actualInstanceUIDs.has(uid)) - ) { - console.verbose('seriesSummary: instance index is up to date'); - // return; // Metadata is up to date - } - - const informationProvider = { studyInstanceUid: studyUID, seriesInstanceUid: seriesUID }; - - // Write all series-level files in one retry loop so readSeriesData is called once per attempt - console.verbose('seriesSummary: writing series metadata and index files'); - await writeMultipleWithRetry({ - ...options, - informationProvider, - baseDir, - generatePayload: () => readSeriesData(reader, studyUID, seriesUID, actualInstanceUIDs), - writes: [ - { - openStream: writer => - writer.openSeriesStream('metadata', { gzip: true, compareOnClose: true }), - getData: data => JSON.stringify(data.instanceMetadataArray), - label: `seriesSummary(${studyUID}/${seriesUID}) metadata`, - }, - { - openStream: writer => - writer.openSeriesStream('series-singleton.json', { gzip: true, compareOnClose: true }), - getData: data => (data.seriesQuery ? JSON.stringify([data.seriesQuery]) : null), - label: `seriesSummary(${studyUID}/${seriesUID}) series-singleton.json`, - }, - { - openStream: writer => - writer.openSeriesStream('instances/index.json', { gzip: true, compareOnClose: true }), - getData: data => - data?.instancesQuery?.length ? JSON.stringify(data.instancesQuery) : null, - label: `seriesSummary(${studyUID}/${seriesUID}) instances/index.json`, - }, - ], - }); -} +import fs from 'fs'; +import path from 'path'; +import { FileDicomWebReader } from './FileDicomWebReader.mjs'; +import { writeMultipleWithRetry } from './writeWithRetry.mjs'; +import { Tags, TagLists } from '@radicalimaging/static-wado-util'; + +const { getValue, setValue } = Tags; + +/** + * Updates BulkDataURI values from instance-relative paths to series-relative paths + * so that series-level metadata can resolve bulk data and frames when read from + * the series directory. + * + * Instance-relative paths: + * - "../../../../bulkdata/" (4 levels up from instance) -> "../../bulkdata/" (2 levels up from series) + * - "./frames" -> "instances//frames" + * + * @param {Object} instanceMetadata - Instance metadata object to update + * @param {string} instanceUID - The instance (SOP Instance) UID for this metadata + * @returns {Object} - Updated instance metadata + */ +function updateLocation(instanceMetadata, instanceUID) { + if (!instanceMetadata || typeof instanceMetadata !== 'object') { + return instanceMetadata; + } + + function processObject(obj, instanceUid) { + if (Array.isArray(obj)) { + return obj.map(item => processObject(item, instanceUid)); + } + + if (obj && typeof obj === 'object') { + const result = {}; + + for (const [key, value] of Object.entries(obj)) { + if (key === 'BulkDataURI' && typeof value === 'string') { + if (value === './frames' || value.startsWith('./frames')) { + result[key] = `./instances/${instanceUid}/frames`; + } else { + result[key] = value.replace(/^(\.\.\/){4}bulkdata\//, '../../bulkdata/'); + } + } else { + result[key] = processObject(value, instanceUid); + } + } + + return result; + } + + return obj; + } + + return processObject(instanceMetadata, instanceUID); +} + +/** + * Reads all instance metadata and derives series query data for a series. + * @param {FileDicomWebReader} reader + * @param {string} studyUID + * @param {string} seriesUID + * @param {Set} actualInstanceUIDs + * @returns {Promise<{instanceMetadataArray: Object[], seriesQuery: Object|null, instancesQuery: Object[]}>} + */ +async function readSeriesData(reader, studyUID, seriesUID, actualInstanceUIDs) { + const instanceMetadataArray = []; + + for (const instanceUID of actualInstanceUIDs) { + const instancePath = reader.getInstancePath(studyUID, seriesUID, instanceUID); + let instanceMetadata = await reader.readJsonFile(instancePath, 'metadata', { + deleteFileOnError: true, + }); + if (instanceMetadata) { + if (Array.isArray(instanceMetadata) && instanceMetadata.length > 0) { + instanceMetadata = instanceMetadata[0]; + } + instanceMetadata = updateLocation(instanceMetadata, instanceUID); + instanceMetadataArray.push(instanceMetadata); + } + } + + // Sort by InstanceNumber + instanceMetadataArray.sort((a, b) => { + const instanceNumberA = getValue(a, Tags.InstanceNumber); + const instanceNumberB = getValue(b, Tags.InstanceNumber); + const numA = instanceNumberA !== undefined ? Number(instanceNumberA) : Number.MAX_SAFE_INTEGER; + const numB = instanceNumberB !== undefined ? Number(instanceNumberB) : Number.MAX_SAFE_INTEGER; + if (!isNaN(numA) && !isNaN(numB)) { + return numA - numB; + } + const strA = String(instanceNumberA || ''); + const strB = String(instanceNumberB || ''); + return strA.localeCompare(strB); + }); + + // Extract series query and instances query + let seriesQuery = null; + const instancesQuery = []; + + if (instanceMetadataArray.length > 0) { + const firstInstance = instanceMetadataArray[0]; + seriesQuery = TagLists.extract(firstInstance, 'series', TagLists.SeriesQuery); + setValue(seriesQuery, Tags.NumberOfSeriesRelatedInstances, instanceMetadataArray.length); + + for (const instanceMetadata of instanceMetadataArray) { + const instanceQuery = TagLists.extract(instanceMetadata, 'instance', TagLists.InstanceQuery); + instancesQuery.push(instanceQuery); + } + } + + return { instanceMetadataArray, seriesQuery, instancesQuery }; +} + +/** + * Creates or updates series metadata/index.json.gz file + * + * @param {string} baseDir - Base directory for DICOMweb structure + * @param {string} studyUID - Study Instance UID + * @param {string} seriesUID - Series Instance UID + * @returns {Promise} + */ +export async function seriesSummary(baseDir, studyUID, seriesUID, options) { + if (!baseDir || !studyUID || !seriesUID) { + throw new Error('baseDir, studyUID, and seriesUID are required'); + } + + const reader = new FileDicomWebReader(baseDir); + const seriesPath = reader.getSeriesPath(studyUID, seriesUID); + const instancesPath = `${seriesPath}/instances`; + + // Check if series metadata exists + let existingInstanceUIDs = new Set(); + const existingMetadata = await reader.readJsonFile(seriesPath, 'metadata', { + deleteFileOnError: true, + }); + if (existingMetadata && Array.isArray(existingMetadata)) { + for (const instance of existingMetadata) { + const sopUID = getValue(instance, Tags.SOPInstanceUID); + if (sopUID) { + existingInstanceUIDs.add(sopUID); + } + } + } + + // Scan the instances directory to get actual instance UIDs + const instanceDirectories = await reader.scanDirectory(instancesPath, { withFileTypes: true }); + const actualInstanceUIDs = new Set(); + + for (const entry of instanceDirectories) { + if (entry && typeof entry === 'object' && entry.isDirectory && entry.isDirectory()) { + actualInstanceUIDs.add(entry.name); + } else if (typeof entry === 'string') { + const instanceDirPath = `${instancesPath}/${entry}`; + const fullInstancePath = path.join(reader.baseDir, instanceDirPath); + try { + const stats = fs.lstatSync(fullInstancePath); + if (stats.isDirectory()) { + actualInstanceUIDs.add(entry); + } + } catch (error) { + console.warn(`Could not stat ${instanceDirPath}: ${error.message}`); + } + } + } + + // Compare sets - if they match exactly, return early + if ( + existingInstanceUIDs.size === actualInstanceUIDs.size && + [...existingInstanceUIDs].every(uid => actualInstanceUIDs.has(uid)) + ) { + console.verbose('seriesSummary: instance index is up to date'); + // return; // Metadata is up to date + } + + const informationProvider = { studyInstanceUid: studyUID, seriesInstanceUid: seriesUID }; + + // Write all series-level files in one retry loop so readSeriesData is called once per attempt + console.verbose('seriesSummary: writing series metadata and index files'); + await writeMultipleWithRetry({ + ...options, + informationProvider, + baseDir, + generatePayload: () => readSeriesData(reader, studyUID, seriesUID, actualInstanceUIDs), + writes: [ + { + openStream: writer => + writer.openSeriesStream('metadata', { gzip: true, compareOnClose: true }), + getData: data => JSON.stringify(data.instanceMetadataArray), + label: `seriesSummary(${studyUID}/${seriesUID}) metadata`, + }, + { + openStream: writer => + writer.openSeriesStream('series-singleton.json', { gzip: true, compareOnClose: true }), + getData: data => (data.seriesQuery ? JSON.stringify([data.seriesQuery]) : null), + label: `seriesSummary(${studyUID}/${seriesUID}) series-singleton.json`, + }, + { + openStream: writer => + writer.openSeriesStream('instances/index.json', { gzip: true, compareOnClose: true }), + getData: data => + data?.instancesQuery?.length ? JSON.stringify(data.instancesQuery) : null, + label: `seriesSummary(${studyUID}/${seriesUID}) instances/index.json`, + }, + ], + }); +} diff --git a/packages/create-dicomweb/lib/instance/StreamInfo.mjs b/packages/create-dicomweb/lib/instance/StreamInfo.mjs index ce595c58..dfa90ed4 100644 --- a/packages/create-dicomweb/lib/instance/StreamInfo.mjs +++ b/packages/create-dicomweb/lib/instance/StreamInfo.mjs @@ -1,312 +1,312 @@ -/** Total number of streams currently open (all StreamInfo instances) */ -let streamOpenCount = 0; -/** Total number of streams closed (all StreamInfo instances, cumulative) */ -let streamClosedCount = 0; - -/** - * Returns current global stream open and closed counts (for logging from DicomWebWriter). - * @returns {{ streamOpenCount: number, streamClosedCount: number }} - */ -export function getStreamCounts() { - return { streamOpenCount, streamClosedCount }; -} -/** High-water log threshold; log when open count exceeds this */ -const STREAM_OPEN_WARN_THRESHOLD = 500; -/** Log again every this many beyond the threshold */ -const STREAM_OPEN_WARN_STEP = 100; -/** Next count at which to log (500, 600, 700, …); reset when count drops below threshold */ -let _streamOpenNextLogAt = STREAM_OPEN_WARN_THRESHOLD; - -/** - * Normalizes a value to an array of Buffers. Supports ArrayBuffer, Buffer, TypedArray, or Array of same. - * @param {ArrayBuffer|Buffer|TypedArray|Array} value - * @returns {Buffer[]} - * @throws {Error} If value type is unsupported - */ -function toBuffers(value) { - if (value instanceof ArrayBuffer) { - return [Buffer.from(value)]; - } - if (Buffer.isBuffer(value)) { - return [value]; - } - if (ArrayBuffer.isView(value)) { - return [Buffer.from(value)]; - } - if (Array.isArray(value)) { - return value.flatMap(toBuffers); - } - throw new Error( - `Unsupported binary value type: ${typeof value}${value?.constructor?.name ? ` (${value.constructor.name})` : ''}. Expected ArrayBuffer or Buffer.` - ); -} - -/** - * StreamInfo encapsulates an open write stream and its failure/write behaviour. - * Provides write() (sync, handles binary types/arrays, backpressure, queue), and end(). - * recordFailure marks the stream as failed; getFailureMessage() retrieves the failure. - * end() waits until all data is flushed, records when ended and any failures, and never throws. - */ -export class StreamInfo { - /** - * @param {Object} writer - The DicomWebWriter instance (used for _recordStreamFailure) - * @param {Object} data - Stream data: streamKey, stream, filename, fileStream, and any other properties - * (e.g. relativePath, gzipStream, wrappedStream, gzipped, isMultipart, etc.) - */ - constructor(writer, data) { - this.writer = writer; - Object.assign(this, data); - - this.failed = false; - this.error = null; - this._ended = false; - this._closedLogged = false; - - /** @type {Array<{ buffers?: Buffer[], run?: () => Promise, resolve?: () => void, reject?: (err: Error) => void }>} */ - this._queue = []; - this._processing = false; - - streamOpenCount += 1; - if (streamOpenCount >= _streamOpenNextLogAt) { - const excess = streamOpenCount - STREAM_OPEN_WARN_THRESHOLD; - console.noQuiet( - `[StreamInfo] open file count exceeded ${STREAM_OPEN_WARN_THRESHOLD} by ${excess}: totalOpen=${streamOpenCount} totalClosed=${streamClosedCount}` - ); - _streamOpenNextLogAt = - STREAM_OPEN_WARN_THRESHOLD + - STREAM_OPEN_WARN_STEP * Math.floor(excess / STREAM_OPEN_WARN_STEP) + - STREAM_OPEN_WARN_STEP; - } - console.verbose( - `[StreamInfo] open stream streamKey=${this.streamKey ?? 'unknown'} totalOpen=${streamOpenCount} totalClosed=${streamClosedCount}` - ); - - const completionPromise = new Promise((resolve, reject) => { - this._resolve = resolve; - this._reject = reject; - }); - this.promise = completionPromise; - } - - /** - * Logs stream close and updates open/closed counts. Called at most once per instance. - * @private - */ - _markClosed() { - if (this._closedLogged) return; - this._closedLogged = true; - streamOpenCount -= 1; - if (streamOpenCount < STREAM_OPEN_WARN_THRESHOLD) { - _streamOpenNextLogAt = STREAM_OPEN_WARN_THRESHOLD; - } - streamClosedCount += 1; - console.verbose( - `[StreamInfo] close stream streamKey=${this.streamKey ?? 'unknown'} totalOpen=${streamOpenCount} totalClosed=${streamClosedCount}` - ); - } - - /** - * Records a failure for this stream. Records the error; subsequent writes are no-ops until close. - * Handles all error recording synchronously without throwing. Marks this stream as failed - * and notifies the writer; any internal errors are swallowed so this method never throws. - * @param {Error} error - The error that occurred - */ - recordFailure(error) { - this.failed = true; - this.error = error; - try { - this.writer._recordStreamFailure(this.streamKey, this, error); - } catch (_) { - // Internal recording failed; we're already in failure state, don't throw - } - } - - /** - * Terminates the stream by destroying stream, gzipStream (if present), and fileStream (if present). - * Never throws; errors during destruction are caught and warned. - * @param {Error} error - The error to pass to destroy() - */ - destroyStreams(error) { - this._markClosed(); - // Reject the completion promise (tracked by streamWritePromiseTracker) - if (this._reject && !this._ended) { - this._reject(error); - this._ended = true; - } - try { - if (this.stream && typeof this.stream.destroy === 'function') { - this.stream.destroy(error); - } - if (this.gzipStream && typeof this.gzipStream.destroy === 'function') { - this.gzipStream.destroy(error); - } - if (this.fileStream && typeof this.fileStream.destroy === 'function') { - this.fileStream.destroy(error); - } - } catch (destroyError) { - console.warn(`Error destroying stream ${this.streamKey}:`, destroyError); - } - } - - /** - * Gets the failure message for this stream, if any. - * @returns {string|null} - The error message, or null if no failure - */ - getFailureMessage() { - return this.error?.message ?? null; - } - - /** - * Returns the close result for this stream (e.g. relative path for file-based writers). - * Used after end() to get the result of closing. Returns undefined if this stream - * does not have path info (e.g. non-file writers). - * @returns {string|undefined} - The close result (e.g. relative path), or undefined - */ - getCloseResult() { - if (this.relativePath != null && this.filename != null) { - return `${this.relativePath}/${this.filename}`.replace(/\\/g, '/'); - } - return undefined; - } - - /** - * Writes binary data to the stream. Accepts ArrayBuffer, Buffer, TypedArray, or Array of same. - * Synchronous: returns backpressure as a boolean. When the queue is already being drained, - * new data is appended to the queue and this returns false (caller should slow down or wait for 'drain'). - * @param {ArrayBuffer|Buffer|TypedArray|Array} value - Data to write - * @returns {boolean} - true = no backpressure (OK to continue); false = backpressure (queue busy, slow down) - */ - write(value) { - if (this.failed || this._ended) return true; - const buffers = toBuffers(value).filter(b => b.length > 0); - if (buffers.length === 0) return true; - - this._queue.push({ buffers }); - if (this._processing) return false; - this._processing = true; - void this._processQueue(); - return true; - } - - async _processQueue() { - const stream = this.stream; - while (this._queue.length > 0) { - const item = this._queue.shift(); - if (item.buffers) { - for (const buf of item.buffers) { - const ok = stream.write(buf); - if (!ok) { - await new Promise(resolve => { - const timeout = setTimeout(() => resolve(), 250); - stream.once('drain', () => { - clearTimeout(timeout); - resolve(); - }); - }); - } - } - } else if (item.run !== undefined) { - try { - await item.run(); - item.resolve?.(); - } catch (e) { - item.reject?.(e); - } - } - } - this._processing = false; - } - - /** - * Drains the write queue, ends the stream, and waits until all data is flushed. - * Records when end has taken place and any failures; never throws. - * Failures are retrieved via getFailureMessage(). - * @returns {Promise} - */ - async end() { - if (this._ended) { - return; - } - - const drainPromise = new Promise((resolve, reject) => { - const run = async () => {}; - this._queue.push({ run, resolve, reject }); - if (!this._processing) { - this._processing = true; - void this._processQueue(); - } - }); - await drainPromise; - - if (this.failed) { - this._markClosed(); - this._ended = true; - // Reject the completion promise (tracked by streamWritePromiseTracker) - if (this._reject) { - this._reject(this.error); - } - return; - } - - const s = this.stream; - const fs = this.fileStream ?? s; - const destroyed = s?.destroyed ?? false; - if (destroyed) { - this._markClosed(); - this._ended = true; - // Resolve the completion promise (tracked by streamWritePromiseTracker) - // Stream was destroyed, but we treat it as complete - if (this._resolve) { - this._resolve(); - } - return; - } - - return new Promise(resolve => { - const done = () => { - this._markClosed(); - this._ended = true; - // Resolve the completion promise (tracked by streamWritePromiseTracker) - if (this.failed && this._reject) { - console.verbose( - `[StreamInfo] rejecting promise for ${this.streamKey ?? 'unknown'}: ${this.error?.message}` - ); - this._reject(this.error); - } else if (this._resolve) { - console.verbose(`[StreamInfo] resolving promise for ${this.streamKey ?? 'unknown'}`); - this._resolve(); - } - resolve(); - }; - - const onError = err => { - this.recordFailure(err); - cleanup(); - done(); - }; - - const onFinish = () => { - cleanup(); - done(); - }; - - const cleanup = () => { - fs.removeListener('finish', onFinish); - fs.removeListener('error', onError); - if (s !== fs) s.removeListener('error', onError); - }; - - fs.once('finish', onFinish); - fs.once('error', onError); - if (s !== fs) s.once('error', onError); - - try { - s.end(); - } catch (err) { - this.recordFailure(err); - cleanup(); - done(); - } - }); - } -} +/** Total number of streams currently open (all StreamInfo instances) */ +let streamOpenCount = 0; +/** Total number of streams closed (all StreamInfo instances, cumulative) */ +let streamClosedCount = 0; + +/** + * Returns current global stream open and closed counts (for logging from DicomWebWriter). + * @returns {{ streamOpenCount: number, streamClosedCount: number }} + */ +export function getStreamCounts() { + return { streamOpenCount, streamClosedCount }; +} +/** High-water log threshold; log when open count exceeds this */ +const STREAM_OPEN_WARN_THRESHOLD = 500; +/** Log again every this many beyond the threshold */ +const STREAM_OPEN_WARN_STEP = 100; +/** Next count at which to log (500, 600, 700, …); reset when count drops below threshold */ +let _streamOpenNextLogAt = STREAM_OPEN_WARN_THRESHOLD; + +/** + * Normalizes a value to an array of Buffers. Supports ArrayBuffer, Buffer, TypedArray, or Array of same. + * @param {ArrayBuffer|Buffer|TypedArray|Array} value + * @returns {Buffer[]} + * @throws {Error} If value type is unsupported + */ +function toBuffers(value) { + if (value instanceof ArrayBuffer) { + return [Buffer.from(value)]; + } + if (Buffer.isBuffer(value)) { + return [value]; + } + if (ArrayBuffer.isView(value)) { + return [Buffer.from(value)]; + } + if (Array.isArray(value)) { + return value.flatMap(toBuffers); + } + throw new Error( + `Unsupported binary value type: ${typeof value}${value?.constructor?.name ? ` (${value.constructor.name})` : ''}. Expected ArrayBuffer or Buffer.` + ); +} + +/** + * StreamInfo encapsulates an open write stream and its failure/write behaviour. + * Provides write() (sync, handles binary types/arrays, backpressure, queue), and end(). + * recordFailure marks the stream as failed; getFailureMessage() retrieves the failure. + * end() waits until all data is flushed, records when ended and any failures, and never throws. + */ +export class StreamInfo { + /** + * @param {Object} writer - The DicomWebWriter instance (used for _recordStreamFailure) + * @param {Object} data - Stream data: streamKey, stream, filename, fileStream, and any other properties + * (e.g. relativePath, gzipStream, wrappedStream, gzipped, isMultipart, etc.) + */ + constructor(writer, data) { + this.writer = writer; + Object.assign(this, data); + + this.failed = false; + this.error = null; + this._ended = false; + this._closedLogged = false; + + /** @type {Array<{ buffers?: Buffer[], run?: () => Promise, resolve?: () => void, reject?: (err: Error) => void }>} */ + this._queue = []; + this._processing = false; + + streamOpenCount += 1; + if (streamOpenCount >= _streamOpenNextLogAt) { + const excess = streamOpenCount - STREAM_OPEN_WARN_THRESHOLD; + console.noQuiet( + `[StreamInfo] open file count exceeded ${STREAM_OPEN_WARN_THRESHOLD} by ${excess}: totalOpen=${streamOpenCount} totalClosed=${streamClosedCount}` + ); + _streamOpenNextLogAt = + STREAM_OPEN_WARN_THRESHOLD + + STREAM_OPEN_WARN_STEP * Math.floor(excess / STREAM_OPEN_WARN_STEP) + + STREAM_OPEN_WARN_STEP; + } + console.verbose( + `[StreamInfo] open stream streamKey=${this.streamKey ?? 'unknown'} totalOpen=${streamOpenCount} totalClosed=${streamClosedCount}` + ); + + const completionPromise = new Promise((resolve, reject) => { + this._resolve = resolve; + this._reject = reject; + }); + this.promise = completionPromise; + } + + /** + * Logs stream close and updates open/closed counts. Called at most once per instance. + * @private + */ + _markClosed() { + if (this._closedLogged) return; + this._closedLogged = true; + streamOpenCount -= 1; + if (streamOpenCount < STREAM_OPEN_WARN_THRESHOLD) { + _streamOpenNextLogAt = STREAM_OPEN_WARN_THRESHOLD; + } + streamClosedCount += 1; + console.verbose( + `[StreamInfo] close stream streamKey=${this.streamKey ?? 'unknown'} totalOpen=${streamOpenCount} totalClosed=${streamClosedCount}` + ); + } + + /** + * Records a failure for this stream. Records the error; subsequent writes are no-ops until close. + * Handles all error recording synchronously without throwing. Marks this stream as failed + * and notifies the writer; any internal errors are swallowed so this method never throws. + * @param {Error} error - The error that occurred + */ + recordFailure(error) { + this.failed = true; + this.error = error; + try { + this.writer._recordStreamFailure(this.streamKey, this, error); + } catch (_) { + // Internal recording failed; we're already in failure state, don't throw + } + } + + /** + * Terminates the stream by destroying stream, gzipStream (if present), and fileStream (if present). + * Never throws; errors during destruction are caught and warned. + * @param {Error} error - The error to pass to destroy() + */ + destroyStreams(error) { + this._markClosed(); + // Reject the completion promise (tracked by streamWritePromiseTracker) + if (this._reject && !this._ended) { + this._reject(error); + this._ended = true; + } + try { + if (this.stream && typeof this.stream.destroy === 'function') { + this.stream.destroy(error); + } + if (this.gzipStream && typeof this.gzipStream.destroy === 'function') { + this.gzipStream.destroy(error); + } + if (this.fileStream && typeof this.fileStream.destroy === 'function') { + this.fileStream.destroy(error); + } + } catch (destroyError) { + console.warn(`Error destroying stream ${this.streamKey}:`, destroyError); + } + } + + /** + * Gets the failure message for this stream, if any. + * @returns {string|null} - The error message, or null if no failure + */ + getFailureMessage() { + return this.error?.message ?? null; + } + + /** + * Returns the close result for this stream (e.g. relative path for file-based writers). + * Used after end() to get the result of closing. Returns undefined if this stream + * does not have path info (e.g. non-file writers). + * @returns {string|undefined} - The close result (e.g. relative path), or undefined + */ + getCloseResult() { + if (this.relativePath != null && this.filename != null) { + return `${this.relativePath}/${this.filename}`.replace(/\\/g, '/'); + } + return undefined; + } + + /** + * Writes binary data to the stream. Accepts ArrayBuffer, Buffer, TypedArray, or Array of same. + * Synchronous: returns backpressure as a boolean. When the queue is already being drained, + * new data is appended to the queue and this returns false (caller should slow down or wait for 'drain'). + * @param {ArrayBuffer|Buffer|TypedArray|Array} value - Data to write + * @returns {boolean} - true = no backpressure (OK to continue); false = backpressure (queue busy, slow down) + */ + write(value) { + if (this.failed || this._ended) return true; + const buffers = toBuffers(value).filter(b => b.length > 0); + if (buffers.length === 0) return true; + + this._queue.push({ buffers }); + if (this._processing) return false; + this._processing = true; + void this._processQueue(); + return true; + } + + async _processQueue() { + const stream = this.stream; + while (this._queue.length > 0) { + const item = this._queue.shift(); + if (item.buffers) { + for (const buf of item.buffers) { + const ok = stream.write(buf); + if (!ok) { + await new Promise(resolve => { + const timeout = setTimeout(() => resolve(), 250); + stream.once('drain', () => { + clearTimeout(timeout); + resolve(); + }); + }); + } + } + } else if (item.run !== undefined) { + try { + await item.run(); + item.resolve?.(); + } catch (e) { + item.reject?.(e); + } + } + } + this._processing = false; + } + + /** + * Drains the write queue, ends the stream, and waits until all data is flushed. + * Records when end has taken place and any failures; never throws. + * Failures are retrieved via getFailureMessage(). + * @returns {Promise} + */ + async end() { + if (this._ended) { + return; + } + + const drainPromise = new Promise((resolve, reject) => { + const run = async () => {}; + this._queue.push({ run, resolve, reject }); + if (!this._processing) { + this._processing = true; + void this._processQueue(); + } + }); + await drainPromise; + + if (this.failed) { + this._markClosed(); + this._ended = true; + // Reject the completion promise (tracked by streamWritePromiseTracker) + if (this._reject) { + this._reject(this.error); + } + return; + } + + const s = this.stream; + const fs = this.fileStream ?? s; + const destroyed = s?.destroyed ?? false; + if (destroyed) { + this._markClosed(); + this._ended = true; + // Resolve the completion promise (tracked by streamWritePromiseTracker) + // Stream was destroyed, but we treat it as complete + if (this._resolve) { + this._resolve(); + } + return; + } + + return new Promise(resolve => { + const done = () => { + this._markClosed(); + this._ended = true; + // Resolve the completion promise (tracked by streamWritePromiseTracker) + if (this.failed && this._reject) { + console.verbose( + `[StreamInfo] rejecting promise for ${this.streamKey ?? 'unknown'}: ${this.error?.message}` + ); + this._reject(this.error); + } else if (this._resolve) { + console.verbose(`[StreamInfo] resolving promise for ${this.streamKey ?? 'unknown'}`); + this._resolve(); + } + resolve(); + }; + + const onError = err => { + this.recordFailure(err); + cleanup(); + done(); + }; + + const onFinish = () => { + cleanup(); + done(); + }; + + const cleanup = () => { + fs.removeListener('finish', onFinish); + fs.removeListener('error', onError); + if (s !== fs) s.removeListener('error', onError); + }; + + fs.once('finish', onFinish); + fs.once('error', onError); + if (s !== fs) s.once('error', onError); + + try { + s.end(); + } catch (err) { + this.recordFailure(err); + cleanup(); + done(); + } + }); + } +} diff --git a/packages/create-dicomweb/lib/instance/dicomDirLocation.mjs b/packages/create-dicomweb/lib/instance/dicomDirLocation.mjs index 2385fcdd..53ac3664 100644 --- a/packages/create-dicomweb/lib/instance/dicomDirLocation.mjs +++ b/packages/create-dicomweb/lib/instance/dicomDirLocation.mjs @@ -1,58 +1,58 @@ -import { fileURLToPath } from 'url'; - -/** - * Recognizes locations that refer to a dicomdir (file-system) DICOMweb root. - * File locations are: no protocol, file: protocol, or Windows drive-letter paths (e.g. C:\). - * Non-file protocols (e.g. http:, https:) are not recognized as dicomdir. - * - * @param {string} location - URL or path string - * @returns {boolean} - True if the location is a file/dicomdir location - */ -export function isDicomDirLocation(location) { - if (typeof location !== 'string' || !location.trim()) { - return false; - } - const s = location.trim(); - const protocolMatch = s.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/); - if (!protocolMatch) { - return true; // no protocol → file path - } - const protocol = protocolMatch[1].toLowerCase(); - if (protocol === 'file') { - return true; - } - // Windows drive letter: single letter + colon (e.g. C:\ or C:/) - if (protocol.length === 1 && /^[a-zA-Z]:[/\\]/.test(s)) { - return true; - } - return false; -} - -/** - * Returns the filesystem path for a dicomdir location, or null if not a file location. - * Strips file: protocol and normalizes to a path; passes through paths without protocol. - * - * @param {string} location - URL or path string - * @returns {string|null} - Resolved path or null - */ -export function dicomDirPathFromLocation(location) { - if (!isDicomDirLocation(location)) { - return null; - } - const s = location.trim(); - const protocolMatch = s.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/); - if (protocolMatch) { - const protocol = protocolMatch[1].toLowerCase(); - if (protocol === 'file') { - try { - return fileURLToPath(s); - } catch { - return s; - } - } - if (protocol.length === 1 && /^[a-zA-Z]:[/\\]/.test(s)) { - return s; - } - } - return s; -} +import { fileURLToPath } from 'url'; + +/** + * Recognizes locations that refer to a dicomdir (file-system) DICOMweb root. + * File locations are: no protocol, file: protocol, or Windows drive-letter paths (e.g. C:\). + * Non-file protocols (e.g. http:, https:) are not recognized as dicomdir. + * + * @param {string} location - URL or path string + * @returns {boolean} - True if the location is a file/dicomdir location + */ +export function isDicomDirLocation(location) { + if (typeof location !== 'string' || !location.trim()) { + return false; + } + const s = location.trim(); + const protocolMatch = s.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/); + if (!protocolMatch) { + return true; // no protocol → file path + } + const protocol = protocolMatch[1].toLowerCase(); + if (protocol === 'file') { + return true; + } + // Windows drive letter: single letter + colon (e.g. C:\ or C:/) + if (protocol.length === 1 && /^[a-zA-Z]:[/\\]/.test(s)) { + return true; + } + return false; +} + +/** + * Returns the filesystem path for a dicomdir location, or null if not a file location. + * Strips file: protocol and normalizes to a path; passes through paths without protocol. + * + * @param {string} location - URL or path string + * @returns {string|null} - Resolved path or null + */ +export function dicomDirPathFromLocation(location) { + if (!isDicomDirLocation(location)) { + return null; + } + const s = location.trim(); + const protocolMatch = s.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/); + if (protocolMatch) { + const protocol = protocolMatch[1].toLowerCase(); + if (protocol === 'file') { + try { + return fileURLToPath(s); + } catch { + return s; + } + } + if (protocol.length === 1 && /^[a-zA-Z]:[/\\]/.test(s)) { + return s; + } + } + return s; +} diff --git a/packages/create-dicomweb/lib/instance/inlineBinaryFilter.mjs b/packages/create-dicomweb/lib/instance/inlineBinaryFilter.mjs index 8e8f1a4c..df0f14ad 100644 --- a/packages/create-dicomweb/lib/instance/inlineBinaryFilter.mjs +++ b/packages/create-dicomweb/lib/instance/inlineBinaryFilter.mjs @@ -1,48 +1,48 @@ -import crypto from 'crypto'; -import { constants } from 'dcmjs'; -import { FileDicomWebWriter } from './FileDicomWebWriter.mjs'; - -const { TagHex, BULKDATA_VRS } = constants; - -/** - * Filter that converts to inline binary. - */ -export function inlineBinaryFilter(options = {}) { - /** - * Filter method: Called when a tag is being closed (popped from stack). - * When converting to InlineBinary, performs the default pop logic and returns - * without calling next, so the released dcmjs base pop (which logs when - * InlineBinary is set) is never run. - */ - function pop(next) { - const current = this.current; - const dest = current?.dest; - - // Check if this tag has a Value array and is eligible for inline binary - if ( - !Array.isArray(dest?.Value) || - !dest.Value.length || - !dest.Value.every(value => value instanceof ArrayBuffer || Buffer.isBuffer(value)) - ) { - return next(); - } - - const buffer = Array.isArray(dest.Value) - ? Buffer.concat(dest.Value.map(value => Buffer.from(value))) - : dest.Value; - const base64 = Buffer.from(buffer).toString('base64'); - delete dest.Value; - dest.InlineBinary = base64; - - // Default pop behavior without calling next (avoids dcmjs InlineBinary log) - const result = this.current.pop?.() ?? this.current.dest; - delete result.Value; - result.InlineBinary = base64; - this.current = this.current.parent; - return result; - } - - return { - pop, - }; -} +import crypto from 'crypto'; +import { constants } from 'dcmjs'; +import { FileDicomWebWriter } from './FileDicomWebWriter.mjs'; + +const { TagHex, BULKDATA_VRS } = constants; + +/** + * Filter that converts to inline binary. + */ +export function inlineBinaryFilter(options = {}) { + /** + * Filter method: Called when a tag is being closed (popped from stack). + * When converting to InlineBinary, performs the default pop logic and returns + * without calling next, so the released dcmjs base pop (which logs when + * InlineBinary is set) is never run. + */ + function pop(next) { + const current = this.current; + const dest = current?.dest; + + // Check if this tag has a Value array and is eligible for inline binary + if ( + !Array.isArray(dest?.Value) || + !dest.Value.length || + !dest.Value.every(value => value instanceof ArrayBuffer || Buffer.isBuffer(value)) + ) { + return next(); + } + + const buffer = Array.isArray(dest.Value) + ? Buffer.concat(dest.Value.map(value => Buffer.from(value))) + : dest.Value; + const base64 = Buffer.from(buffer).toString('base64'); + delete dest.Value; + dest.InlineBinary = base64; + + // Default pop behavior without calling next (avoids dcmjs InlineBinary log) + const result = this.current.pop?.() ?? this.current.dest; + delete result.Value; + result.InlineBinary = base64; + this.current = this.current.parent; + return result; + } + + return { + pop, + }; +} diff --git a/packages/create-dicomweb/lib/instance/writeBulkdataFilter.mjs b/packages/create-dicomweb/lib/instance/writeBulkdataFilter.mjs index b617d25b..6d59344b 100644 --- a/packages/create-dicomweb/lib/instance/writeBulkdataFilter.mjs +++ b/packages/create-dicomweb/lib/instance/writeBulkdataFilter.mjs @@ -1,287 +1,287 @@ -import crypto from 'crypto'; -import { constants } from 'dcmjs'; -import { FileDicomWebWriter } from './FileDicomWebWriter.mjs'; - -const { TagHex, BULKDATA_VRS } = constants; - -/** - * Determines if a DICOM tag is a private tag - * Private tags have an odd group number (first 4 hex digits) - * @param {string} tag - DICOM tag in hex format (e.g., '00100010') - * @returns {boolean} - */ -function isPrivateTag(tag) { - if (!tag || tag.length !== 8) return false; - const groupNumber = parseInt(tag.substring(0, 4), 16); - return (groupNumber & 1) === 1; // Odd group number = private -} - -/** - * Generates a hash-based path for bulkdata content - * @param {ArrayBuffer|Buffer|Array} data - The binary data to hash - * @returns {string} - Hash-based filename - */ -function generateHashPath(data) { - const hash = crypto.createHash('sha256'); - - if (Array.isArray(data)) { - // Hash each array element - for (const element of data) { - if (element instanceof ArrayBuffer) { - hash.update(Buffer.from(element)); - } else if (Buffer.isBuffer(element)) { - hash.update(element); - } - } - } else if (data instanceof ArrayBuffer) { - hash.update(Buffer.from(data)); - } else if (Buffer.isBuffer(data)) { - hash.update(data); - } - - const digest = hash.digest('hex'); - // Create a hierarchical path structure: first 2 chars / next 2 chars / rest - return `${digest.substring(0, 2)}/${digest.substring(2, 4)}/${digest.substring(4)}.mht`; -} - -/** - * Calculates the size of data (binary, text, or other types) - * Handles nested arrays recursively - * @param {*} value - The value to measure - * @returns {number} - Approximate size in bytes - */ -function calculateDataSize(value) { - // Null or undefined - if (value === null) return 2; - if (value === undefined) return 0; - - // Arrays - recurse through elements - if (Array.isArray(value)) { - let totalSize = 0; - for (const element of value) { - totalSize += calculateDataSize(element); // Recursive call for nested arrays - } - return totalSize; - } - - // ArrayBuffer - if (value instanceof ArrayBuffer) { - return value.byteLength; - } - - // Buffer (Node.js) - if (Buffer.isBuffer(value)) { - return value.length; - } - - // Uint8Array and other TypedArrays - if (ArrayBuffer.isView(value)) { - return value.byteLength; - } - - // String - use string length as approximation - if (typeof value === 'string') { - return value.length; - } - - // Number - approximate as 4 bytes - if (typeof value === 'number') { - return 4; - } - - // Boolean - 2 bytes - if (typeof value === 'boolean') { - return 2; - } - - // Default for other types - return 4; -} - -/** - * Filter for DicomMetadataListener that writes large binary data to bulkdata files - * - * @param {Object} options - Configuration options - * @param {string} options.dicomdir - Base directory path where bulkdata files will be written - * @param {FileDicomWebWriter} options.writer - Optional writer instance - * @param {number} options.sizeBulkdataTags - Size threshold in bytes for public tags (default: 128k + 2 bytes) - * @param {number} options.sizePrivateBulkdataTags - Size threshold in bytes for private tags (default: 128 bytes) - * @returns {Object} Filter object with value and pop methods - */ -export function writeBulkdataFilter(options = {}) { - const { - dicomdir, - writer: providedWriter, - sizeBulkdataTags = 131074, // 128k + 2 bytes default - sizePrivateBulkdataTags = 128, // 128 bytes default - } = options; - - // If no writer is provided, dicomdir is required for creating one - if (!providedWriter && !dicomdir) { - throw new Error('Either writer or dicomdir option is required for writeBulkdataFilter'); - } - - // Use the provided writer or create one lazily - let writer = providedWriter || null; - - /** - * Gets or creates the writer, using the listener's information - * @param {Object} listener - The DicomMetadataListener instance (accessed via 'this') - * @returns {FileDicomWebWriter} - */ - function getWriter(listener) { - if (!writer && listener.information) { - writer = new FileDicomWebWriter(listener.information, { baseDir: dicomdir }); - } - return writer; - } - - /** - * Filter method: Called when a tag is added - * Returns expectsRaw: true if this tag qualifies for bulkdata based on VR, length, and threshold - */ - function addTag(next, tag, tagInfo) { - const result = next(tag, tagInfo) || {}; - - // Get tag info from result or tagInfo - const vr = result?.vr || tagInfo?.vr; - const length = tagInfo?.length; - const level = this.current?.level ?? 0; - - // Check if this tag could qualify for bulkdata - // Never request raw for top-level PixelData (handled by frame writer) - if (tag === TagHex.PixelData && level <= 2) { - return result; - } - - // Only request raw data if VR is appropriate for bulkdata - if (!vr || !BULKDATA_VRS.has(vr) || !length) { - return result; - } - - const isPrivate = isPrivateTag(tag); - const threshold = isPrivate ? sizePrivateBulkdataTags : sizeBulkdataTags; - - // Only request raw data if the length exceeds the threshold - if (length >= threshold) { - return { ...result, expectsRaw: true }; - } - - return result; - } - - /** - * Filter method: Called when a tag is being closed (popped from stack) - * Determines if the tag should be written as bulkdata and performs the write - */ - function pop(next, result) { - // Access the current tag context - const current = this.current; - const currentTag = current?.tag; - const currentVR = current?.vr; - const level = current?.level ?? 0; - const dest = current?.dest; - - // Check if this tag has a Value array and is eligible for bulkdata - if (!Array.isArray(dest?.Value) || !BULKDATA_VRS.has(currentVR)) { - return next(result); - } - - // Never capture top-level PixelData - that's handled by the frame writer - if (currentTag === TagHex.PixelData && level <= 2) { - return next(result); - } - - // Determine if this is a private tag - const isPrivate = isPrivateTag(currentTag); - const threshold = isPrivate ? sizePrivateBulkdataTags : sizeBulkdataTags; - - // Calculate the total size of all values - const totalSize = current?.length ?? calculateDataSize(dest.Value); - - // If total size is below threshold, don't convert to bulkdata - if (totalSize < threshold) { - return next(result); - } - - // Get the writer – if the study/series/SOP UIDs are not yet available the - // writer (or its UID accessors) may throw. In that case, fall back to - // keeping the data inline (binary/base64). - let bulkdataWriter, studyUID; - try { - bulkdataWriter = getWriter(this); - if (!bulkdataWriter) { - return next(result); - } - studyUID = bulkdataWriter.getStudyUID(); - } catch { - // UIDs not available yet – keep data inline - return next(result); - } - - if (!studyUID) { - // StudyInstanceUID not yet available, skip bulkdata conversion - return next(result); - } - - // Generate hash-based path from the entire Value array - const hashPath = generateHashPath(dest.Value); - - // Capture the Value array before we delete it - const valueArray = dest.Value; - - // Check if the Value array contains only ArrayBuffers or Buffers - const isWritableData = valueArray.every( - value => value instanceof ArrayBuffer || Buffer.isBuffer(value) - ); - - // Replace Value array with BulkDataURI immediately (synchronously) - delete dest.Value; - dest.BulkDataURI = `../../../../bulkdata/${hashPath}.gz`; - - // Only write bulkdata file if the value is an array of ArrayBuffers or Buffers - if (!isWritableData) { - console.warn( - `Skipping bulkdata write for tag ${currentTag}: not an array of ArrayBuffers/Buffers` - ); - return next(result); - } - - const streamKey = `bulkdata:${currentTag}@${level}:${hashPath}`; - - try { - const relativePath = `studies/${studyUID}/bulkdata/${hashPath.substring(0, hashPath.lastIndexOf('/'))}`; - const filename = hashPath.substring(hashPath.lastIndexOf('/') + 1); - - const contentType = 'application/octet-stream'; - const boundary = `BOUNDARY_${crypto.randomUUID()}`; - - const streamInfo = bulkdataWriter.openStream(relativePath, filename, { - multipart: true, - contentType, - boundary, - gzip: true, - streamKey, - }); - - for (const v of valueArray) { - const buf = v instanceof ArrayBuffer ? Buffer.from(v) : v; - if (buf.length === 0) continue; - streamInfo.write(buf); - } - - bulkdataWriter.closeStream(streamKey); - } catch (error) { - console.error(`Error writing bulkdata for tag ${currentTag}:`, error); - bulkdataWriter.recordStreamError(streamKey, error, true); - } - - return next(result); - } - - return { - addTag, - pop, - getWriter: () => writer, - }; -} +import crypto from 'crypto'; +import { constants } from 'dcmjs'; +import { FileDicomWebWriter } from './FileDicomWebWriter.mjs'; + +const { TagHex, BULKDATA_VRS } = constants; + +/** + * Determines if a DICOM tag is a private tag + * Private tags have an odd group number (first 4 hex digits) + * @param {string} tag - DICOM tag in hex format (e.g., '00100010') + * @returns {boolean} + */ +function isPrivateTag(tag) { + if (!tag || tag.length !== 8) return false; + const groupNumber = parseInt(tag.substring(0, 4), 16); + return (groupNumber & 1) === 1; // Odd group number = private +} + +/** + * Generates a hash-based path for bulkdata content + * @param {ArrayBuffer|Buffer|Array} data - The binary data to hash + * @returns {string} - Hash-based filename + */ +function generateHashPath(data) { + const hash = crypto.createHash('sha256'); + + if (Array.isArray(data)) { + // Hash each array element + for (const element of data) { + if (element instanceof ArrayBuffer) { + hash.update(Buffer.from(element)); + } else if (Buffer.isBuffer(element)) { + hash.update(element); + } + } + } else if (data instanceof ArrayBuffer) { + hash.update(Buffer.from(data)); + } else if (Buffer.isBuffer(data)) { + hash.update(data); + } + + const digest = hash.digest('hex'); + // Create a hierarchical path structure: first 2 chars / next 2 chars / rest + return `${digest.substring(0, 2)}/${digest.substring(2, 4)}/${digest.substring(4)}.mht`; +} + +/** + * Calculates the size of data (binary, text, or other types) + * Handles nested arrays recursively + * @param {*} value - The value to measure + * @returns {number} - Approximate size in bytes + */ +function calculateDataSize(value) { + // Null or undefined + if (value === null) return 2; + if (value === undefined) return 0; + + // Arrays - recurse through elements + if (Array.isArray(value)) { + let totalSize = 0; + for (const element of value) { + totalSize += calculateDataSize(element); // Recursive call for nested arrays + } + return totalSize; + } + + // ArrayBuffer + if (value instanceof ArrayBuffer) { + return value.byteLength; + } + + // Buffer (Node.js) + if (Buffer.isBuffer(value)) { + return value.length; + } + + // Uint8Array and other TypedArrays + if (ArrayBuffer.isView(value)) { + return value.byteLength; + } + + // String - use string length as approximation + if (typeof value === 'string') { + return value.length; + } + + // Number - approximate as 4 bytes + if (typeof value === 'number') { + return 4; + } + + // Boolean - 2 bytes + if (typeof value === 'boolean') { + return 2; + } + + // Default for other types + return 4; +} + +/** + * Filter for DicomMetadataListener that writes large binary data to bulkdata files + * + * @param {Object} options - Configuration options + * @param {string} options.dicomdir - Base directory path where bulkdata files will be written + * @param {FileDicomWebWriter} options.writer - Optional writer instance + * @param {number} options.sizeBulkdataTags - Size threshold in bytes for public tags (default: 128k + 2 bytes) + * @param {number} options.sizePrivateBulkdataTags - Size threshold in bytes for private tags (default: 128 bytes) + * @returns {Object} Filter object with value and pop methods + */ +export function writeBulkdataFilter(options = {}) { + const { + dicomdir, + writer: providedWriter, + sizeBulkdataTags = 131074, // 128k + 2 bytes default + sizePrivateBulkdataTags = 128, // 128 bytes default + } = options; + + // If no writer is provided, dicomdir is required for creating one + if (!providedWriter && !dicomdir) { + throw new Error('Either writer or dicomdir option is required for writeBulkdataFilter'); + } + + // Use the provided writer or create one lazily + let writer = providedWriter || null; + + /** + * Gets or creates the writer, using the listener's information + * @param {Object} listener - The DicomMetadataListener instance (accessed via 'this') + * @returns {FileDicomWebWriter} + */ + function getWriter(listener) { + if (!writer && listener.information) { + writer = new FileDicomWebWriter(listener.information, { baseDir: dicomdir }); + } + return writer; + } + + /** + * Filter method: Called when a tag is added + * Returns expectsRaw: true if this tag qualifies for bulkdata based on VR, length, and threshold + */ + function addTag(next, tag, tagInfo) { + const result = next(tag, tagInfo) || {}; + + // Get tag info from result or tagInfo + const vr = result?.vr || tagInfo?.vr; + const length = tagInfo?.length; + const level = this.current?.level ?? 0; + + // Check if this tag could qualify for bulkdata + // Never request raw for top-level PixelData (handled by frame writer) + if (tag === TagHex.PixelData && level <= 2) { + return result; + } + + // Only request raw data if VR is appropriate for bulkdata + if (!vr || !BULKDATA_VRS.has(vr) || !length) { + return result; + } + + const isPrivate = isPrivateTag(tag); + const threshold = isPrivate ? sizePrivateBulkdataTags : sizeBulkdataTags; + + // Only request raw data if the length exceeds the threshold + if (length >= threshold) { + return { ...result, expectsRaw: true }; + } + + return result; + } + + /** + * Filter method: Called when a tag is being closed (popped from stack) + * Determines if the tag should be written as bulkdata and performs the write + */ + function pop(next, result) { + // Access the current tag context + const current = this.current; + const currentTag = current?.tag; + const currentVR = current?.vr; + const level = current?.level ?? 0; + const dest = current?.dest; + + // Check if this tag has a Value array and is eligible for bulkdata + if (!Array.isArray(dest?.Value) || !BULKDATA_VRS.has(currentVR)) { + return next(result); + } + + // Never capture top-level PixelData - that's handled by the frame writer + if (currentTag === TagHex.PixelData && level <= 2) { + return next(result); + } + + // Determine if this is a private tag + const isPrivate = isPrivateTag(currentTag); + const threshold = isPrivate ? sizePrivateBulkdataTags : sizeBulkdataTags; + + // Calculate the total size of all values + const totalSize = current?.length ?? calculateDataSize(dest.Value); + + // If total size is below threshold, don't convert to bulkdata + if (totalSize < threshold) { + return next(result); + } + + // Get the writer – if the study/series/SOP UIDs are not yet available the + // writer (or its UID accessors) may throw. In that case, fall back to + // keeping the data inline (binary/base64). + let bulkdataWriter, studyUID; + try { + bulkdataWriter = getWriter(this); + if (!bulkdataWriter) { + return next(result); + } + studyUID = bulkdataWriter.getStudyUID(); + } catch { + // UIDs not available yet – keep data inline + return next(result); + } + + if (!studyUID) { + // StudyInstanceUID not yet available, skip bulkdata conversion + return next(result); + } + + // Generate hash-based path from the entire Value array + const hashPath = generateHashPath(dest.Value); + + // Capture the Value array before we delete it + const valueArray = dest.Value; + + // Check if the Value array contains only ArrayBuffers or Buffers + const isWritableData = valueArray.every( + value => value instanceof ArrayBuffer || Buffer.isBuffer(value) + ); + + // Replace Value array with BulkDataURI immediately (synchronously) + delete dest.Value; + dest.BulkDataURI = `../../../../bulkdata/${hashPath}.gz`; + + // Only write bulkdata file if the value is an array of ArrayBuffers or Buffers + if (!isWritableData) { + console.warn( + `Skipping bulkdata write for tag ${currentTag}: not an array of ArrayBuffers/Buffers` + ); + return next(result); + } + + const streamKey = `bulkdata:${currentTag}@${level}:${hashPath}`; + + try { + const relativePath = `studies/${studyUID}/bulkdata/${hashPath.substring(0, hashPath.lastIndexOf('/'))}`; + const filename = hashPath.substring(hashPath.lastIndexOf('/') + 1); + + const contentType = 'application/octet-stream'; + const boundary = `BOUNDARY_${crypto.randomUUID()}`; + + const streamInfo = bulkdataWriter.openStream(relativePath, filename, { + multipart: true, + contentType, + boundary, + gzip: true, + streamKey, + }); + + for (const v of valueArray) { + const buf = v instanceof ArrayBuffer ? Buffer.from(v) : v; + if (buf.length === 0) continue; + streamInfo.write(buf); + } + + bulkdataWriter.closeStream(streamKey); + } catch (error) { + console.error(`Error writing bulkdata for tag ${currentTag}:`, error); + bulkdataWriter.recordStreamError(streamKey, error, true); + } + + return next(result); + } + + return { + addTag, + pop, + getWriter: () => writer, + }; +} diff --git a/packages/create-dicomweb/test/writeBulkdataFilter.jest.mjs b/packages/create-dicomweb/test/writeBulkdataFilter.jest.mjs index f2ba08bb..f1312110 100644 --- a/packages/create-dicomweb/test/writeBulkdataFilter.jest.mjs +++ b/packages/create-dicomweb/test/writeBulkdataFilter.jest.mjs @@ -1,127 +1,127 @@ -import fs from 'fs'; -import path from 'path'; -import { constants } from 'dcmjs'; -import { instanceFromStream } from '../lib/instance/instanceFromStream.mjs'; - -const { TagHex } = constants; - -describe('writeBulkdataFilter', () => { - let packageRoot; - let dicomFilePath; - let tempDir; - let result; - let testDir; - - beforeAll(async () => { - packageRoot = process.cwd().endsWith('create-dicomweb') - ? process.cwd() - : path.resolve(process.cwd(), 'packages/create-dicomweb'); - testDir = path.join(packageRoot, 'tests'); - - // Path to the test DICOM file - dicomFilePath = path.resolve( - packageRoot, - '../../packages/static-wado-creator/dicom/jpeg8bit.dcm' - ); - - // Create a temporary directory for output - tempDir = path.join(packageRoot, 'tmp/test-bulkdata'); - if (!fs.existsSync(tempDir)) { - fs.mkdirSync(tempDir, { recursive: true }); - } - - // Read the DICOM file as a stream - const stream = fs.createReadStream(dicomFilePath); - - // Process the DICOM file with bulkdata filter - // Set low thresholds to capture PixelData as bulkdata - result = await instanceFromStream(stream, { - dicomdir: tempDir, - bulkdata: true, - sizeBulkdataTags: 1024, // 1KB threshold for public tags - sizePrivateBulkdataTags: 64, // 1KB threshold for private tags - }); - - console.log('instanceFromStream result:', { - hasWriter: !!result.writer, - hasMeta: !!result.meta, - hasDict: !!result.dict, - dictKeys: result.dict ? Object.keys(result.dict).length : 0, - information: result.information, - studyUID: result.dict?.['0020000D']?.Value?.[0], - }); - // console.warn("result:\n", JSON.stringify(result.dict,null,2)); - }, 30_000); - - // Helper function to find the bulkdata directory - const findBulkdataDir = baseDir => { - const studiesDir = path.join(baseDir, 'studies'); - if (!fs.existsSync(studiesDir)) return null; - - const studies = fs.readdirSync(studiesDir); - if (studies.length === 0) return null; - - const studyDir = path.join(studiesDir, studies[0]); - const bulkdataDir = path.join(studyDir, 'bulkdata'); - return fs.existsSync(bulkdataDir) ? bulkdataDir : null; - }; - - test('should write bulkdata for large tags', async () => { - // Check for bulkdata directory - const bulkdataDir = findBulkdataDir(tempDir); - - expect(bulkdataDir).not.toBeNull(); - expect(fs.existsSync(bulkdataDir)).toBe(true); - - // Count bulkdata files (recursively) - const countMhtFiles = dir => { - let count = 0; - const items = fs.readdirSync(dir); - for (const item of items) { - const fullPath = path.join(dir, item); - const stat = fs.statSync(fullPath); - if (stat.isDirectory()) { - count += countMhtFiles(fullPath); - } else if (item.endsWith('.mht') || item.endsWith('.mht.gz')) { - count++; - } - } - return count; - }; - - const bulkdataFileCount = countMhtFiles(bulkdataDir); - - // Expect at least one bulkdata file (other large tags besides PixelData) - expect(bulkdataFileCount).toBeGreaterThan(0); - }); - - test('should write frames bulkdata', async () => { - const { studyInstanceUid: studyUid, seriesInstanceUid: seriesUid, sopInstanceUid: sopUid, numberOfFrames } = result.information; - const frameBase = `studies/${studyUid}/series/${seriesUid}/instances/${sopUid}/frames`; - expect(fs.existsSync(path.join(tempDir,frameBase))).toBe(true); - for(let i=1; i<=numberOfFrames; i++) { - expect(fs.existsSync(path.join(tempDir, frameBase, `${i}.mht`))); - } - }); - - test('should assign frame uri', () => { - const pixelData = result.dict['7FE00010']; - expect(pixelData).not.toBeUndefined(); - expect(pixelData.BulkDataURI).toBe('./frames'); - }); - - test('should assign bulkdata for tag 50003000', () => { - const { studyInstanceUid: studyUid, seriesInstanceUid: seriesUid, sopInstanceUid: sopUid, numberOfFrames } = result.information; - const bulkdata = result.dict['50003000']; - expect(bulkdata).not.toBeUndefined(); - expect(bulkdata.BulkDataURI).toBe('../../../../bulkdata/ec/61/2cb6a152c80ca5de04faf017d4ac2b52c44308b14d5f98e6ca5915d8ec16.mht.gz'); - const imageBase = `studies/${studyUid}/series/${seriesUid}/instances/${sopUid}`; - expect(fs.existsSync(path.join(tempDir, imageBase, bulkdata.BulkDataURI))).toBe(true); - }); - - test('should not write short bulkdata', () => { - const studyTag = result.dict['0020000D']; - expect(studyTag).not.toBeUndefined(); - expect(studyTag.BulkDataURI).toBeUndefined(); - }) -}); +import fs from 'fs'; +import path from 'path'; +import { constants } from 'dcmjs'; +import { instanceFromStream } from '../lib/instance/instanceFromStream.mjs'; + +const { TagHex } = constants; + +describe('writeBulkdataFilter', () => { + let packageRoot; + let dicomFilePath; + let tempDir; + let result; + let testDir; + + beforeAll(async () => { + packageRoot = process.cwd().endsWith('create-dicomweb') + ? process.cwd() + : path.resolve(process.cwd(), 'packages/create-dicomweb'); + testDir = path.join(packageRoot, 'tests'); + + // Path to the test DICOM file + dicomFilePath = path.resolve( + packageRoot, + '../../packages/static-wado-creator/dicom/jpeg8bit.dcm' + ); + + // Create a temporary directory for output + tempDir = path.join(packageRoot, 'tmp/test-bulkdata'); + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + + // Read the DICOM file as a stream + const stream = fs.createReadStream(dicomFilePath); + + // Process the DICOM file with bulkdata filter + // Set low thresholds to capture PixelData as bulkdata + result = await instanceFromStream(stream, { + dicomdir: tempDir, + bulkdata: true, + sizeBulkdataTags: 1024, // 1KB threshold for public tags + sizePrivateBulkdataTags: 64, // 1KB threshold for private tags + }); + + console.log('instanceFromStream result:', { + hasWriter: !!result.writer, + hasMeta: !!result.meta, + hasDict: !!result.dict, + dictKeys: result.dict ? Object.keys(result.dict).length : 0, + information: result.information, + studyUID: result.dict?.['0020000D']?.Value?.[0], + }); + // console.warn("result:\n", JSON.stringify(result.dict,null,2)); + }, 30_000); + + // Helper function to find the bulkdata directory + const findBulkdataDir = baseDir => { + const studiesDir = path.join(baseDir, 'studies'); + if (!fs.existsSync(studiesDir)) return null; + + const studies = fs.readdirSync(studiesDir); + if (studies.length === 0) return null; + + const studyDir = path.join(studiesDir, studies[0]); + const bulkdataDir = path.join(studyDir, 'bulkdata'); + return fs.existsSync(bulkdataDir) ? bulkdataDir : null; + }; + + test('should write bulkdata for large tags', async () => { + // Check for bulkdata directory + const bulkdataDir = findBulkdataDir(tempDir); + + expect(bulkdataDir).not.toBeNull(); + expect(fs.existsSync(bulkdataDir)).toBe(true); + + // Count bulkdata files (recursively) + const countMhtFiles = dir => { + let count = 0; + const items = fs.readdirSync(dir); + for (const item of items) { + const fullPath = path.join(dir, item); + const stat = fs.statSync(fullPath); + if (stat.isDirectory()) { + count += countMhtFiles(fullPath); + } else if (item.endsWith('.mht') || item.endsWith('.mht.gz')) { + count++; + } + } + return count; + }; + + const bulkdataFileCount = countMhtFiles(bulkdataDir); + + // Expect at least one bulkdata file (other large tags besides PixelData) + expect(bulkdataFileCount).toBeGreaterThan(0); + }); + + test('should write frames bulkdata', async () => { + const { studyInstanceUid: studyUid, seriesInstanceUid: seriesUid, sopInstanceUid: sopUid, numberOfFrames } = result.information; + const frameBase = `studies/${studyUid}/series/${seriesUid}/instances/${sopUid}/frames`; + expect(fs.existsSync(path.join(tempDir,frameBase))).toBe(true); + for(let i=1; i<=numberOfFrames; i++) { + expect(fs.existsSync(path.join(tempDir, frameBase, `${i}.mht`))); + } + }); + + test('should assign frame uri', () => { + const pixelData = result.dict['7FE00010']; + expect(pixelData).not.toBeUndefined(); + expect(pixelData.BulkDataURI).toBe('./frames'); + }); + + test('should assign bulkdata for tag 50003000', () => { + const { studyInstanceUid: studyUid, seriesInstanceUid: seriesUid, sopInstanceUid: sopUid, numberOfFrames } = result.information; + const bulkdata = result.dict['50003000']; + expect(bulkdata).not.toBeUndefined(); + expect(bulkdata.BulkDataURI).toBe('../../../../bulkdata/ec/61/2cb6a152c80ca5de04faf017d4ac2b52c44308b14d5f98e6ca5915d8ec16.mht.gz'); + const imageBase = `studies/${studyUid}/series/${seriesUid}/instances/${sopUid}`; + expect(fs.existsSync(path.join(tempDir, imageBase, bulkdata.BulkDataURI))).toBe(true); + }); + + test('should not write short bulkdata', () => { + const studyTag = result.dict['0020000D']; + expect(studyTag).not.toBeUndefined(); + expect(studyTag.BulkDataURI).toBeUndefined(); + }) +}); diff --git a/packages/cs3d/src/createIImage.mjs b/packages/cs3d/src/createIImage.mjs index 3a4eeae9..fec93d89 100644 --- a/packages/cs3d/src/createIImage.mjs +++ b/packages/cs3d/src/createIImage.mjs @@ -1,6 +1,6 @@ -export function createIImage(canvas) { - return { - width: canvas.width, - getCanvas: () => canvas, - }; -} +export function createIImage(canvas) { + return { + width: canvas.width, + getCanvas: () => canvas, + }; +} diff --git a/packages/cs3d/src/cs3d.ts b/packages/cs3d/src/cs3d.ts index 67a6afc7..19825a0f 100644 --- a/packages/cs3d/src/cs3d.ts +++ b/packages/cs3d/src/cs3d.ts @@ -1,59 +1,59 @@ -import { createCanvas } from 'canvas'; -import { utilities } from '@cornerstonejs/core'; -import { VOILUTFunctionType } from '@cornerstonejs/core/enums'; -import { JSDOM } from 'jsdom'; - -const dom = new JSDOM(``, { pretendToBeVisual: true }); -globalThis.window = dom.window; -globalThis.document = window.document; - -const rows = 64; -const columns = 64; -const numberOfComponents = 1; - -const bytePixelData = new Uint8Array(rows * columns * numberOfComponents); - -const image = { - imageId: 'test:123', - isPreScaled: false, - minPixelValue: 0, - maxPixelValue: 255, - slope: 1, - intercept: 0, - windowCenter: 128, - windowWidth: 255, - voiLUTFunction: VOILUTFunctionType.LINEAR, - getPixelData: () => bytePixelData, - getCanvas: null, - rows, - columns, - numberOfComponents, - height: rows, - width: columns, - sizeInBytes: bytePixelData.byteLength, - rowPixelSpacing: 1, - columnPixelSpacing: 1, - invert: false, - color: numberOfComponents > 1, - rgba: false, - dataType: null, - voxelManager: utilities.VoxelManager.createImageVoxelManager({ - width: columns, - height: rows, - scalarData: bytePixelData, - numberOfComponents, - }), -}; - -async function main() { - const canvas = createCanvas(rows, columns); - for (let i = 0; i < rows * columns; i++) { - bytePixelData[i] = i % columns; - } - await utilities.renderToCanvasCPU(canvas as unknown as HTMLCanvasElement, image); - console.log('canvas', canvas.toDataURL('image/png')); -} - -main().then(() => { - console.log('Done converting'); -}); +import { createCanvas } from 'canvas'; +import { utilities } from '@cornerstonejs/core'; +import { VOILUTFunctionType } from '@cornerstonejs/core/enums'; +import { JSDOM } from 'jsdom'; + +const dom = new JSDOM(``, { pretendToBeVisual: true }); +globalThis.window = dom.window; +globalThis.document = window.document; + +const rows = 64; +const columns = 64; +const numberOfComponents = 1; + +const bytePixelData = new Uint8Array(rows * columns * numberOfComponents); + +const image = { + imageId: 'test:123', + isPreScaled: false, + minPixelValue: 0, + maxPixelValue: 255, + slope: 1, + intercept: 0, + windowCenter: 128, + windowWidth: 255, + voiLUTFunction: VOILUTFunctionType.LINEAR, + getPixelData: () => bytePixelData, + getCanvas: null, + rows, + columns, + numberOfComponents, + height: rows, + width: columns, + sizeInBytes: bytePixelData.byteLength, + rowPixelSpacing: 1, + columnPixelSpacing: 1, + invert: false, + color: numberOfComponents > 1, + rgba: false, + dataType: null, + voxelManager: utilities.VoxelManager.createImageVoxelManager({ + width: columns, + height: rows, + scalarData: bytePixelData, + numberOfComponents, + }), +}; + +async function main() { + const canvas = createCanvas(rows, columns); + for (let i = 0; i < rows * columns; i++) { + bytePixelData[i] = i % columns; + } + await utilities.renderToCanvasCPU(canvas as unknown as HTMLCanvasElement, image); + console.log('canvas', canvas.toDataURL('image/png')); +} + +main().then(() => { + console.log('Done converting'); +}); diff --git a/packages/cs3d/src/dcmjs.d.ts b/packages/cs3d/src/dcmjs.d.ts index 89b4e54a..8b3439d8 100644 --- a/packages/cs3d/src/dcmjs.d.ts +++ b/packages/cs3d/src/dcmjs.d.ts @@ -1,29 +1,29 @@ -declare module 'dcmjs' { - export class DicomMetaDictionary { - static naturalizeDataset(dataset: any): any; - static denaturalizeDataset(dataset: any, nameMap?: any): any; - static namifyDataset(dataset: any): any; - static cleanDataset(dataset: any): any; - static punctuateTag(rawTag: string): string; - static unpunctuateTag(tag: string): string; - static parseIntFromTag(tag: string): number; - static tagAsIntegerFromName(name: string): number | undefined; - static uid(): string; - static date(): string; - static time(): string; - static dateTime(): string; - } - - export interface DcmjsData { - DicomMetaDictionary: typeof DicomMetaDictionary; - [key: string]: any; - } - - export interface Dcmjs { - data: DcmjsData; - [key: string]: any; - } - - const dcmjs: Dcmjs; - export default dcmjs; -} +declare module 'dcmjs' { + export class DicomMetaDictionary { + static naturalizeDataset(dataset: any): any; + static denaturalizeDataset(dataset: any, nameMap?: any): any; + static namifyDataset(dataset: any): any; + static cleanDataset(dataset: any): any; + static punctuateTag(rawTag: string): string; + static unpunctuateTag(tag: string): string; + static parseIntFromTag(tag: string): number; + static tagAsIntegerFromName(name: string): number | undefined; + static uid(): string; + static date(): string; + static time(): string; + static dateTime(): string; + } + + export interface DcmjsData { + DicomMetaDictionary: typeof DicomMetaDictionary; + [key: string]: any; + } + + export interface Dcmjs { + data: DcmjsData; + [key: string]: any; + } + + const dcmjs: Dcmjs; + export default dcmjs; +} diff --git a/packages/cs3d/test.ts b/packages/cs3d/test.ts index e5b3bda8..4d6a3b44 100644 --- a/packages/cs3d/test.ts +++ b/packages/cs3d/test.ts @@ -1 +1 @@ -ECHO is on. +ECHO is on. diff --git a/packages/cs3d/test/jestSetup.js b/packages/cs3d/test/jestSetup.js index 3d1bdd63..6811ebcc 100644 --- a/packages/cs3d/test/jestSetup.js +++ b/packages/cs3d/test/jestSetup.js @@ -1,19 +1,19 @@ -/** - * Jest setup: register console.verbose and console.noQuiet before tests run. - * Tests (e.g. cs3d.jest.js) call these; same contract as createVerboseLog in static-wado-util. - */ -function registerConsoleVerbose() { - const verbose = false; - const quiet = false; - console.verbose = (...args) => { - if (!verbose) return; - console.log(...args); - }; - console.quiet = quiet; - console.noQuiet = (...args) => { - if (quiet) return; - console.log(...args); - }; -} - -registerConsoleVerbose(); +/** + * Jest setup: register console.verbose and console.noQuiet before tests run. + * Tests (e.g. cs3d.jest.js) call these; same contract as createVerboseLog in static-wado-util. + */ +function registerConsoleVerbose() { + const verbose = false; + const quiet = false; + console.verbose = (...args) => { + if (!verbose) return; + console.log(...args); + }; + console.quiet = quiet; + console.noQuiet = (...args) => { + if (quiet) return; + console.log(...args); + }; +} + +registerConsoleVerbose(); diff --git a/packages/cs3d/tsconfig.json b/packages/cs3d/tsconfig.json index 4601a7ed..c2b3e351 100644 --- a/packages/cs3d/tsconfig.json +++ b/packages/cs3d/tsconfig.json @@ -1,8 +1,8 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" - }, - "include": ["./src/**/*"] -} +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["./src/**/*"] +} diff --git a/packages/healthlake/.babelrc b/packages/healthlake/.babelrc index 233429f0..717b8536 100644 --- a/packages/healthlake/.babelrc +++ b/packages/healthlake/.babelrc @@ -1,7 +1,7 @@ -{ - "presets": ["@babel/preset-env"], - "plugins": [ - "@babel/plugin-proposal-class-properties", - "@babel/transform-runtime" - ] -} +{ + "presets": ["@babel/preset-env"], + "plugins": [ + "@babel/plugin-proposal-class-properties", + "@babel/transform-runtime" + ] +} diff --git a/packages/static-wado-creator/lib/index.mjs b/packages/static-wado-creator/lib/index.mjs index 175577b6..db9dbe4a 100644 --- a/packages/static-wado-creator/lib/index.mjs +++ b/packages/static-wado-creator/lib/index.mjs @@ -1,18 +1,18 @@ -import mkdicomwebConfig from './mkdicomwebConfig.js'; -import StaticWado from './StaticWado.js'; -import programIndex from './program/index.js'; -import createMain from './createMain.js'; -import deleteMain from './deleteMain.js'; -import adaptProgramOpts from './util/adaptProgramOpts.js'; -import { uids } from '@radicalimaging/static-wado-util'; - -const { configureProgram } = programIndex; - -StaticWado.mkdicomwebConfig = mkdicomwebConfig; -StaticWado.createMain = createMain; -StaticWado.deleteMain = deleteMain; -StaticWado.adaptProgramOpts = adaptProgramOpts; -StaticWado.configureProgram = configureProgram; - -export default StaticWado; -export { uids, StaticWado }; +import mkdicomwebConfig from './mkdicomwebConfig.js'; +import StaticWado from './StaticWado.js'; +import programIndex from './program/index.js'; +import createMain from './createMain.js'; +import deleteMain from './deleteMain.js'; +import adaptProgramOpts from './util/adaptProgramOpts.js'; +import { uids } from '@radicalimaging/static-wado-util'; + +const { configureProgram } = programIndex; + +StaticWado.mkdicomwebConfig = mkdicomwebConfig; +StaticWado.createMain = createMain; +StaticWado.deleteMain = deleteMain; +StaticWado.adaptProgramOpts = adaptProgramOpts; +StaticWado.configureProgram = configureProgram; + +export default StaticWado; +export { uids, StaticWado }; diff --git a/packages/static-wado-creator/lib/util/resultsReport.ts b/packages/static-wado-creator/lib/util/resultsReport.ts index 222af6b3..5944f059 100644 --- a/packages/static-wado-creator/lib/util/resultsReport.ts +++ b/packages/static-wado-creator/lib/util/resultsReport.ts @@ -1,87 +1,87 @@ -import fs from 'fs'; - -const results = []; -let sopResponse = null; - -function ensureSOPResponse(data) { - if (!data.ReferencedSOPSequence && !data.FailedSOPSequence) { - return; - } - if (!sopResponse) { - sopResponse = { - ...data, - FailedSOPSequence: undefined, - ReferencedSOPSequence: undefined, - }; - results.push(sopResponse); - } - if (data.ReferencedSOPSequence) { - sopResponse.ReferencedSOPSequence ||= []; - sopResponse.ReferencedSOPSequence.push(...data.ReferencedSOPSequence); - } - if (data.FailedSOPSequence) { - sopResponse.FailedSOPSequence ||= []; - sopResponse.FailedSOPSequence.push(...data.FailedSOPSequence); - } -} -export function Success(options) { - const { deleteInput = false } = options; - - return (_action, data, file) => { - if (data.ReferencedSOPSequence) { - ensureSOPResponse(data); - } else { - results.push(data); - } - if (file && deleteInput) { - console.noQuiet('Deleting', file); - fs.unlinkSync(file); - } else if (file) { - console.verbose('Not deleting', file); - } - }; -} - -export function Failure(options) { - const { deleteFailed } = options; - - return (_action, data, file) => { - if (data.FailedSOPSequence) { - ensureSOPResponse(data); - } else { - results.push(data); - } - if (file && deleteFailed) { - console.noQuiet('Deleting failure', file); - fs.unlinkSync(file); - } else if (file) { - console.verbose('Not deleting failure file', file); - } - }; -} - -export function clearResults() { - results.splice(0, results.length); - sopResponse = null; -} - -export function WriteResults(options) { - const { multipart } = options; - - return function () { - if (multipart) { - const str = - results.length === 1 - ? JSON.stringify(results[0]) - : `[\n${results.map(r => JSON.stringify(r, null, 2)).join(',\n')}\n]`; - console.log( - '\r\n--boundary-response\r\n' + - 'content-type: application/json\r\n\r\n' + - str + - '\r\n--boundary-response--\r\n' - ); - } - - clearResults(); - }; -} +import fs from 'fs'; + +const results = []; +let sopResponse = null; + +function ensureSOPResponse(data) { + if (!data.ReferencedSOPSequence && !data.FailedSOPSequence) { + return; + } + if (!sopResponse) { + sopResponse = { + ...data, + FailedSOPSequence: undefined, + ReferencedSOPSequence: undefined, + }; + results.push(sopResponse); + } + if (data.ReferencedSOPSequence) { + sopResponse.ReferencedSOPSequence ||= []; + sopResponse.ReferencedSOPSequence.push(...data.ReferencedSOPSequence); + } + if (data.FailedSOPSequence) { + sopResponse.FailedSOPSequence ||= []; + sopResponse.FailedSOPSequence.push(...data.FailedSOPSequence); + } +} +export function Success(options) { + const { deleteInput = false } = options; + + return (_action, data, file) => { + if (data.ReferencedSOPSequence) { + ensureSOPResponse(data); + } else { + results.push(data); + } + if (file && deleteInput) { + console.noQuiet('Deleting', file); + fs.unlinkSync(file); + } else if (file) { + console.verbose('Not deleting', file); + } + }; +} + +export function Failure(options) { + const { deleteFailed } = options; + + return (_action, data, file) => { + if (data.FailedSOPSequence) { + ensureSOPResponse(data); + } else { + results.push(data); + } + if (file && deleteFailed) { + console.noQuiet('Deleting failure', file); + fs.unlinkSync(file); + } else if (file) { + console.verbose('Not deleting failure file', file); + } + }; +} + +export function clearResults() { + results.splice(0, results.length); + sopResponse = null; +} + +export function WriteResults(options) { + const { multipart } = options; + + return function () { + if (multipart) { + const str = + results.length === 1 + ? JSON.stringify(results[0]) + : `[\n${results.map(r => JSON.stringify(r, null, 2)).join(',\n')}\n]`; + console.log( + '\r\n--boundary-response\r\n' + + 'content-type: application/json\r\n\r\n' + + str + + '\r\n--boundary-response--\r\n' + ); + } + + clearResults(); + }; +} diff --git a/packages/static-wado-util/lib/StatusMonitor.mjs b/packages/static-wado-util/lib/StatusMonitor.mjs index 9656b576..b8afc1e5 100644 --- a/packages/static-wado-util/lib/StatusMonitor.mjs +++ b/packages/static-wado-util/lib/StatusMonitor.mjs @@ -1,136 +1,136 @@ -/** - * Static class to track job statistics by type: running count, completed count, - * and optional per-job data for ongoing jobs (e.g. for /status?detailed). - * - * Job types are arbitrary strings (e.g. 'stowPost', 'stowInstances'). - * Each job has a unique id, type, startedAt, and a mutable data object. - */ - -let jobIdCounter = 0; -const runningJobs = new Map(); // jobId -> { type, startedAt, data } -const completedCountByType = new Map(); // typeId -> number -const failedCountByType = new Map(); // typeId -> number (subset of completed: aborted or failed) - -/** - * Generate a unique job id. - * @returns {string} - */ -function nextJobId() { - jobIdCounter += 1; - return `job-${jobIdCounter}-${Date.now()}`; -} - -/** - * StatusMonitor static class. - */ -export class StatusMonitor { - /** - * Start a new job of the given type. - * @param {string} typeId - Job type (e.g. 'stowPost', 'stowInstances') - * @param {object} [initialData={}] - Initial data for the job (e.g. { parts: 0, totalBytes: 0 }) - * @returns {string} - Unique job id - */ - static startJob(typeId, initialData = {}) { - const id = nextJobId(); - runningJobs.set(id, { - type: typeId, - startedAt: Date.now(), - data: { ...initialData }, - }); - return id; - } - - /** - * Update a running job's data. No-op if the job is not found or already completed. - * @param {string} typeId - Job type - * @param {string} jobId - Job id returned from startJob - * @param {object} dataUpdates - Object to merge into job.data - */ - static updateJob(typeId, jobId, dataUpdates) { - const job = runningJobs.get(jobId); - if (!job || job.type !== typeId) return; - Object.assign(job.data, dataUpdates); - } - - /** - * End a job and move it to completed count. - * If finalData.failed or finalData.aborted is truthy, or finalData.failedCount > 0, the job is counted as failed. - * @param {string} typeId - Job type - * @param {string} jobId - Job id - * @param {object} [finalData={}] - Final data to merge (e.g. totalTimeMs, failed: true, aborted: true, failedCount: number) - */ - static endJob(typeId, jobId, finalData = {}) { - const job = runningJobs.get(jobId); - if (!job || job.type !== typeId) return; - runningJobs.delete(jobId); - const count = completedCountByType.get(typeId) ?? 0; - completedCountByType.set(typeId, count + 1); - const isFailed = - finalData.failed || - finalData.aborted || - (typeof finalData.failedCount === 'number' && finalData.failedCount > 0); - if (isFailed) { - const failedCount = failedCountByType.get(typeId) || 0; - failedCountByType.set(typeId, failedCount + 1); - } - } - - /** - * Get summary of job counts by type: running, completed, and failed (failed is a subset of completed). - * @returns {{ jobTypes: Record }} - */ - static getSummary() { - const byType = new Map(); - for (const job of runningJobs.values()) { - const t = job.type; - const entry = byType.get(t) ?? { running: 0, completed: 0, failed: 0 }; - entry.running += 1; - byType.set(t, entry); - } - for (const [typeId, count] of completedCountByType) { - const entry = byType.get(typeId) ?? { running: 0, completed: 0, failed: 0 }; - entry.completed = count; - entry.failed = failedCountByType.get(typeId) ?? 0; - byType.set(typeId, entry); - } - const jobTypes = Object.fromEntries(byType); - return { jobTypes }; - } - - /** - * Get all ongoing (running) jobs, optionally filtered by type. - * Each item includes id, type, startedAt, and data (with computed fields if needed). - * @param {string} [typeId] - If provided, only return jobs of this type - * @returns {Array<{ id: string, type: string, startedAt: number, data: object }>} - */ - static getOngoingJobs(typeId) { - const now = Date.now(); - const result = []; - for (const [id, job] of runningJobs) { - if (typeId != null && job.type !== typeId) continue; - const data = { ...job.data }; - if (data.lastBytesReceivedAt != null) { - data.secondsSinceProgress = (now - data.lastBytesReceivedAt) / 1000; - delete data.lastBytesReceivedAt; // report only "how long ago", not absolute time - } - if (job.startedAt != null) { - data.secondsSinceStarted = (now - job.startedAt) / 1000; - } - result.push({ - id, - type: job.type, - data, - }); - } - return result; - } - - /** - * Reset all state (for tests). - */ - static reset() { - runningJobs.clear(); - completedCountByType.clear(); - failedCountByType.clear(); - } -} +/** + * Static class to track job statistics by type: running count, completed count, + * and optional per-job data for ongoing jobs (e.g. for /status?detailed). + * + * Job types are arbitrary strings (e.g. 'stowPost', 'stowInstances'). + * Each job has a unique id, type, startedAt, and a mutable data object. + */ + +let jobIdCounter = 0; +const runningJobs = new Map(); // jobId -> { type, startedAt, data } +const completedCountByType = new Map(); // typeId -> number +const failedCountByType = new Map(); // typeId -> number (subset of completed: aborted or failed) + +/** + * Generate a unique job id. + * @returns {string} + */ +function nextJobId() { + jobIdCounter += 1; + return `job-${jobIdCounter}-${Date.now()}`; +} + +/** + * StatusMonitor static class. + */ +export class StatusMonitor { + /** + * Start a new job of the given type. + * @param {string} typeId - Job type (e.g. 'stowPost', 'stowInstances') + * @param {object} [initialData={}] - Initial data for the job (e.g. { parts: 0, totalBytes: 0 }) + * @returns {string} - Unique job id + */ + static startJob(typeId, initialData = {}) { + const id = nextJobId(); + runningJobs.set(id, { + type: typeId, + startedAt: Date.now(), + data: { ...initialData }, + }); + return id; + } + + /** + * Update a running job's data. No-op if the job is not found or already completed. + * @param {string} typeId - Job type + * @param {string} jobId - Job id returned from startJob + * @param {object} dataUpdates - Object to merge into job.data + */ + static updateJob(typeId, jobId, dataUpdates) { + const job = runningJobs.get(jobId); + if (!job || job.type !== typeId) return; + Object.assign(job.data, dataUpdates); + } + + /** + * End a job and move it to completed count. + * If finalData.failed or finalData.aborted is truthy, or finalData.failedCount > 0, the job is counted as failed. + * @param {string} typeId - Job type + * @param {string} jobId - Job id + * @param {object} [finalData={}] - Final data to merge (e.g. totalTimeMs, failed: true, aborted: true, failedCount: number) + */ + static endJob(typeId, jobId, finalData = {}) { + const job = runningJobs.get(jobId); + if (!job || job.type !== typeId) return; + runningJobs.delete(jobId); + const count = completedCountByType.get(typeId) ?? 0; + completedCountByType.set(typeId, count + 1); + const isFailed = + finalData.failed || + finalData.aborted || + (typeof finalData.failedCount === 'number' && finalData.failedCount > 0); + if (isFailed) { + const failedCount = failedCountByType.get(typeId) || 0; + failedCountByType.set(typeId, failedCount + 1); + } + } + + /** + * Get summary of job counts by type: running, completed, and failed (failed is a subset of completed). + * @returns {{ jobTypes: Record }} + */ + static getSummary() { + const byType = new Map(); + for (const job of runningJobs.values()) { + const t = job.type; + const entry = byType.get(t) ?? { running: 0, completed: 0, failed: 0 }; + entry.running += 1; + byType.set(t, entry); + } + for (const [typeId, count] of completedCountByType) { + const entry = byType.get(typeId) ?? { running: 0, completed: 0, failed: 0 }; + entry.completed = count; + entry.failed = failedCountByType.get(typeId) ?? 0; + byType.set(typeId, entry); + } + const jobTypes = Object.fromEntries(byType); + return { jobTypes }; + } + + /** + * Get all ongoing (running) jobs, optionally filtered by type. + * Each item includes id, type, startedAt, and data (with computed fields if needed). + * @param {string} [typeId] - If provided, only return jobs of this type + * @returns {Array<{ id: string, type: string, startedAt: number, data: object }>} + */ + static getOngoingJobs(typeId) { + const now = Date.now(); + const result = []; + for (const [id, job] of runningJobs) { + if (typeId != null && job.type !== typeId) continue; + const data = { ...job.data }; + if (data.lastBytesReceivedAt != null) { + data.secondsSinceProgress = (now - data.lastBytesReceivedAt) / 1000; + delete data.lastBytesReceivedAt; // report only "how long ago", not absolute time + } + if (job.startedAt != null) { + data.secondsSinceStarted = (now - job.startedAt) / 1000; + } + result.push({ + id, + type: job.type, + data, + }); + } + return result; + } + + /** + * Reset all state (for tests). + */ + static reset() { + runningJobs.clear(); + completedCountByType.clear(); + failedCountByType.clear(); + } +} diff --git a/packages/static-wado-util/lib/TagLists.mjs b/packages/static-wado-util/lib/TagLists.mjs index 4b35bd2d..68e84a0a 100644 --- a/packages/static-wado-util/lib/TagLists.mjs +++ b/packages/static-wado-util/lib/TagLists.mjs @@ -1,160 +1,160 @@ -import { hasher as createHasher } from 'node-object-hash'; -import Tags from './dictionary/Tags.mjs'; - -/* eslint "no-param-reassign": "off" */ - -const hasher = createHasher(); - -const { PatientID, PatientName, IssuerOfPatientID } = Tags; -const { StudyDescription, AccessionNumber, StudyInstanceUID, StudyDate, StudyTime } = Tags; -const { SeriesDescription, SeriesNumber, SeriesInstanceUID, SeriesDate, SeriesTime } = Tags; - -const { DeduppedHash, DeduppedRef, DeduppedType } = Tags; - -const { pushList, setValue, getValue } = Tags; - -const PatientQuery = [ - PatientID, - Tags.OtherPatientIDsSequence, - PatientName, - IssuerOfPatientID, - Tags.PatientBirthDate, - Tags.PatientBirthTime, - Tags.PatientSex, - Tags.PatientIdentityRemoved, - Tags.DeidentificationMethodCodeSequence, -]; -const StudyQuery = [ - StudyDescription, - AccessionNumber, - StudyInstanceUID, - StudyDate, - StudyTime, - Tags.StudyStatusID, - Tags.StudyPriorityID, - Tags.StudyID, -]; - -const PatientStudyQuery = [...PatientQuery, ...StudyQuery]; - -// The difference between the extracts and the query is that the query contains the parent query values -// TODO - make it into a self-cpontained object that can generate either -const SeriesExtract = [ - SeriesDescription, - SeriesNumber, - SeriesInstanceUID, - Tags.Modality, - SeriesDate, - SeriesTime, - Tags.SpecificCharacterSet, - Tags.Manufacturer, - Tags.InstitutionName, - Tags.ReferringPhysicianName, - Tags.StationName, - Tags.OperatorsName, - Tags.ManufacturerModelName, - Tags.BodyPartExamined, - Tags.ProtocolName, - Tags.RequestedProcedureDescription, -]; -const SeriesQuery = [StudyInstanceUID, ...SeriesExtract]; - -const InstanceQuery = [ - Tags.SOPInstanceUID, - Tags.InstanceNumber, - Tags.SeriesInstanceUID, - Tags.StudyInstanceUID, - Tags.ContentDate, - Tags.ContentTime, - Tags.AcquisitionDate, - Tags.AcquisitionTime, - Tags.AcquisitionNumber, - Tags.SamplesPerPixel, - Tags.PhotometricInterpretation, - Tags.PlanarConfiguration, - Tags.Rows, - Tags.Columns, - Tags.BitsAllocated, - Tags.BitsStored, - Tags.HighBit, - Tags.PixelRepresentation, - Tags.WindowCenter, - Tags.WindowWidth, - Tags.WindowCenterWidthExplanation, - Tags.InstanceCreationTime, -]; - -const ImageExtract = [ - Tags.ImageType, - Tags.InstanceCreationDate, - Tags.SOPClassUID, - Tags.ConversionType, - Tags.ManufacturerModelName, - Tags.ContentDate, - Tags.AcquisitionDate, - Tags.SecondaryCaptureDeviceID, - Tags.DateOfSecondaryCapture, - Tags.SecondaryCaptureDeviceManufacturer, - Tags.SecondaryCaptureDeviceManufacturerModelName, - Tags.SecondaryCaptureDeviceSoftwareVersions, - Tags.SoftwareVersions, - Tags.SamplesPerPixel, - Tags.PhotometricInterpretation, - Tags.PlanarConfiguration, - Tags.Rows, - Tags.Columns, - Tags.BitsAllocated, - Tags.BitsStored, - Tags.HighBit, - Tags.PixelRepresentation, - Tags.WindowCenter, - Tags.WindowWidth, - Tags.WindowCenterWidthExplanation, - Tags.PatientPosition, - Tags.PatientOrientation, -]; - -const addHash = (data, type) => { - const existing = getValue(data, DeduppedHash); - if (existing) { - return existing; - } - - const hashValue = hasher.hash(data); - setValue(data, DeduppedHash, hashValue); - setValue(data, DeduppedType, type); - return hashValue; -}; - -const TagSets = { - PatientQuery, - StudyQuery, - PatientStudyQuery, - SeriesQuery, - SeriesExtract, - InstanceQuery, - ImageExtract, - - QueryExtract: { remove: false, hashRef: false }, - RemoveExtract: { remove: true, hashRef: true }, - - extract: (data, type, tagSet, options) => { - const ret = {}; - const { remove, hashRef } = options || {}; - tagSet.forEach(tag => { - const value = data[tag]; - if (value) { - ret[tag] = value; - if (remove) delete data[tag]; - } - }); - const hashValue = addHash(ret, type); - if (hashRef) { - pushList(data, DeduppedRef, hashValue); - } - return ret; - }, - addHash, -}; - -export default TagSets; +import { hasher as createHasher } from 'node-object-hash'; +import Tags from './dictionary/Tags.mjs'; + +/* eslint "no-param-reassign": "off" */ + +const hasher = createHasher(); + +const { PatientID, PatientName, IssuerOfPatientID } = Tags; +const { StudyDescription, AccessionNumber, StudyInstanceUID, StudyDate, StudyTime } = Tags; +const { SeriesDescription, SeriesNumber, SeriesInstanceUID, SeriesDate, SeriesTime } = Tags; + +const { DeduppedHash, DeduppedRef, DeduppedType } = Tags; + +const { pushList, setValue, getValue } = Tags; + +const PatientQuery = [ + PatientID, + Tags.OtherPatientIDsSequence, + PatientName, + IssuerOfPatientID, + Tags.PatientBirthDate, + Tags.PatientBirthTime, + Tags.PatientSex, + Tags.PatientIdentityRemoved, + Tags.DeidentificationMethodCodeSequence, +]; +const StudyQuery = [ + StudyDescription, + AccessionNumber, + StudyInstanceUID, + StudyDate, + StudyTime, + Tags.StudyStatusID, + Tags.StudyPriorityID, + Tags.StudyID, +]; + +const PatientStudyQuery = [...PatientQuery, ...StudyQuery]; + +// The difference between the extracts and the query is that the query contains the parent query values +// TODO - make it into a self-cpontained object that can generate either +const SeriesExtract = [ + SeriesDescription, + SeriesNumber, + SeriesInstanceUID, + Tags.Modality, + SeriesDate, + SeriesTime, + Tags.SpecificCharacterSet, + Tags.Manufacturer, + Tags.InstitutionName, + Tags.ReferringPhysicianName, + Tags.StationName, + Tags.OperatorsName, + Tags.ManufacturerModelName, + Tags.BodyPartExamined, + Tags.ProtocolName, + Tags.RequestedProcedureDescription, +]; +const SeriesQuery = [StudyInstanceUID, ...SeriesExtract]; + +const InstanceQuery = [ + Tags.SOPInstanceUID, + Tags.InstanceNumber, + Tags.SeriesInstanceUID, + Tags.StudyInstanceUID, + Tags.ContentDate, + Tags.ContentTime, + Tags.AcquisitionDate, + Tags.AcquisitionTime, + Tags.AcquisitionNumber, + Tags.SamplesPerPixel, + Tags.PhotometricInterpretation, + Tags.PlanarConfiguration, + Tags.Rows, + Tags.Columns, + Tags.BitsAllocated, + Tags.BitsStored, + Tags.HighBit, + Tags.PixelRepresentation, + Tags.WindowCenter, + Tags.WindowWidth, + Tags.WindowCenterWidthExplanation, + Tags.InstanceCreationTime, +]; + +const ImageExtract = [ + Tags.ImageType, + Tags.InstanceCreationDate, + Tags.SOPClassUID, + Tags.ConversionType, + Tags.ManufacturerModelName, + Tags.ContentDate, + Tags.AcquisitionDate, + Tags.SecondaryCaptureDeviceID, + Tags.DateOfSecondaryCapture, + Tags.SecondaryCaptureDeviceManufacturer, + Tags.SecondaryCaptureDeviceManufacturerModelName, + Tags.SecondaryCaptureDeviceSoftwareVersions, + Tags.SoftwareVersions, + Tags.SamplesPerPixel, + Tags.PhotometricInterpretation, + Tags.PlanarConfiguration, + Tags.Rows, + Tags.Columns, + Tags.BitsAllocated, + Tags.BitsStored, + Tags.HighBit, + Tags.PixelRepresentation, + Tags.WindowCenter, + Tags.WindowWidth, + Tags.WindowCenterWidthExplanation, + Tags.PatientPosition, + Tags.PatientOrientation, +]; + +const addHash = (data, type) => { + const existing = getValue(data, DeduppedHash); + if (existing) { + return existing; + } + + const hashValue = hasher.hash(data); + setValue(data, DeduppedHash, hashValue); + setValue(data, DeduppedType, type); + return hashValue; +}; + +const TagSets = { + PatientQuery, + StudyQuery, + PatientStudyQuery, + SeriesQuery, + SeriesExtract, + InstanceQuery, + ImageExtract, + + QueryExtract: { remove: false, hashRef: false }, + RemoveExtract: { remove: true, hashRef: true }, + + extract: (data, type, tagSet, options) => { + const ret = {}; + const { remove, hashRef } = options || {}; + tagSet.forEach(tag => { + const value = data[tag]; + if (value) { + ret[tag] = value; + if (remove) delete data[tag]; + } + }); + const hashValue = addHash(ret, type); + if (hashRef) { + pushList(data, DeduppedRef, hashValue); + } + return ret; + }, + addHash, +}; + +export default TagSets; diff --git a/packages/static-wado-util/lib/compareTo.mjs b/packages/static-wado-util/lib/compareTo.mjs index 94f52a5e..c01f0596 100644 --- a/packages/static-wado-util/lib/compareTo.mjs +++ b/packages/static-wado-util/lib/compareTo.mjs @@ -1,58 +1,58 @@ -/** - * Compares two values and returns -1, 0, or +1 - * Handles undefined, null, numbers, strings, and other types - * - Numbers are compared numerically - * - Strings are compared using localeCompare - * - undefined/null values are treated as less than defined values - * - Other types are converted to strings for comparison - * - * @param {*} a - First value to compare - * @param {*} b - Second value to compare - * @returns {number} - -1 if a < b, 0 if a === b, +1 if a > b - */ -export function compareTo(a, b) { - // Handle undefined/null cases - if (a === undefined || a === null) { - if (b === undefined || b === null) { - return 0; // Both are undefined/null, equal - } - return -1; // a is undefined/null, b is defined, a < b - } - if (b === undefined || b === null) { - return 1; // b is undefined/null, a is defined, a > b - } - - // Handle numbers - if (typeof a === 'number' && typeof b === 'number') { - if (isNaN(a)) { - return isNaN(b) ? 0 : -1; // NaN < any number - } - if (isNaN(b)) { - return 1; // any number > NaN - } - return a < b ? -1 : (a > b ? 1 : 0); - } - - // Try numeric comparison if both can be converted to numbers - const numA = Number(a); - const numB = Number(b); - if (!isNaN(numA) && !isNaN(numB)) { - return numA < numB ? -1 : (numA > numB ? 1 : 0); - } - - // Handle strings (including string representations) - const strA = String(a); - const strB = String(b); - - // If both are empty strings, they're equal - if (!strA && !strB) { - return 0; - } - - // If one is empty, it's less than the other - if (!strA) return -1; - if (!strB) return 1; - - // Use localeCompare for string comparison - return strA.localeCompare(strB); -} +/** + * Compares two values and returns -1, 0, or +1 + * Handles undefined, null, numbers, strings, and other types + * - Numbers are compared numerically + * - Strings are compared using localeCompare + * - undefined/null values are treated as less than defined values + * - Other types are converted to strings for comparison + * + * @param {*} a - First value to compare + * @param {*} b - Second value to compare + * @returns {number} - -1 if a < b, 0 if a === b, +1 if a > b + */ +export function compareTo(a, b) { + // Handle undefined/null cases + if (a === undefined || a === null) { + if (b === undefined || b === null) { + return 0; // Both are undefined/null, equal + } + return -1; // a is undefined/null, b is defined, a < b + } + if (b === undefined || b === null) { + return 1; // b is undefined/null, a is defined, a > b + } + + // Handle numbers + if (typeof a === 'number' && typeof b === 'number') { + if (isNaN(a)) { + return isNaN(b) ? 0 : -1; // NaN < any number + } + if (isNaN(b)) { + return 1; // any number > NaN + } + return a < b ? -1 : (a > b ? 1 : 0); + } + + // Try numeric comparison if both can be converted to numbers + const numA = Number(a); + const numB = Number(b); + if (!isNaN(numA) && !isNaN(numB)) { + return numA < numB ? -1 : (numA > numB ? 1 : 0); + } + + // Handle strings (including string representations) + const strA = String(a); + const strB = String(b); + + // If both are empty strings, they're equal + if (!strA && !strB) { + return 0; + } + + // If one is empty, it's less than the other + if (!strA) return -1; + if (!strB) return 1; + + // Use localeCompare for string comparison + return strA.localeCompare(strB); +} diff --git a/packages/static-wado-util/lib/createPromiseTracker.mjs b/packages/static-wado-util/lib/createPromiseTracker.mjs index 843f1d70..5000d460 100644 --- a/packages/static-wado-util/lib/createPromiseTracker.mjs +++ b/packages/static-wado-util/lib/createPromiseTracker.mjs @@ -1,225 +1,225 @@ -import { StatusMonitor } from './StatusMonitor.mjs'; - -/** Threshold above which we log a warning about pending promise count */ -const PENDING_PROMISE_WARN_THRESHOLD = 500; -/** Log again every this many beyond the threshold */ -const PENDING_PROMISE_WARN_STEP = 100; - -/** Default interval (ms) for status monitor updates */ -const DEFAULT_STATUS_MONITOR_INTERVAL_MS = 500; - -/** Auto-stop status monitor after this many ms with no change in total (settled + unsettled) or if still 0 */ -const STATUS_MONITOR_IDLE_STOP_MS = 5 * 60 * 1000; - -/** Counter for assigning unique IDs to trackers */ -let trackerIdCounter = 0; - -/** - * Tracks a list of promises and counts how many have settled. - * Provides back pressure via limitUnsettled for flow control. - * - * @param {string} [name] - Optional name for this tracker (for debugging) - * @example - * const tracker = createPromiseTracker('fileTracker'); - * tracker.add(someAsyncOperation()); - * await tracker.limitUnsettled(10, 30000); // Wait until <10 unsettled or 30s - */ -export function createPromiseTracker(name) { - const promises = new Set(); - const settleCallbacks = new Set(); - /** Total number of promises that have settled (fulfilled or rejected) since creation */ - let settledCount = 0; - /** Next count at which to log (500, 600, 700, …); reset when count drops below threshold */ - let pendingNextLogAt = PENDING_PROMISE_WARN_THRESHOLD; - /** Unique ID for this tracker instance (for debugging) */ - const trackerIdNum = ++trackerIdCounter; - const trackerId = name ? `${name}#${trackerIdNum}` : `#${trackerIdNum}`; - - function notifySettle() { - for (const cb of settleCallbacks) { - cb(); - } - } - - /** - * Add a promise to track. The promise is removed from the unsettled count when it settles. - * - * @param {Promise} promise - The promise to track - * @returns {Promise} - The same promise (for chaining) - */ - function add(promise) { - const p = Promise.resolve(promise); - promises.add(p); - if (promises.size >= pendingNextLogAt) { - const excess = promises.size - PENDING_PROMISE_WARN_THRESHOLD; - console.warn( - `[createPromiseTracker ${trackerId}] pending promise count exceeded ${PENDING_PROMISE_WARN_THRESHOLD} by ${excess}: pending=${promises.size} settled=${settledCount}` - ); - pendingNextLogAt = - PENDING_PROMISE_WARN_THRESHOLD + - PENDING_PROMISE_WARN_STEP * Math.floor(excess / PENDING_PROMISE_WARN_STEP) + - PENDING_PROMISE_WARN_STEP; - } - // Attach .catch() to the promise from .finally() so that when the tracked - // promise rejects, we don't get an unhandled rejection (e.g. invalid DICOM). - p.finally(() => { - promises.delete(p); - settledCount += 1; - if (promises.size < PENDING_PROMISE_WARN_THRESHOLD) { - pendingNextLogAt = PENDING_PROMISE_WARN_THRESHOLD; - } - notifySettle(); - }).catch(() => { - // Rejection is expected (e.g. invalid file); caller awaits the same promise - // and handles the error. We only track settlement for back pressure. - }); - return p; - } - - /** - * Returns the current count of unsettled (pending) promises. - * - * @returns {number} - */ - function getUnsettledCount() { - return promises.size; - } - - /** - * Returns the total count of promises that have settled (fulfilled or rejected) since creation. - * - * @returns {number} - */ - function getSettledCount() { - return settledCount; - } - - /** - * Returns a promise that resolves when either: - * - The count of unsettled items drops below maxUnsettled, OR - * - The given timeout in milliseconds is exceeded. - * - * Resolves to the number of unsettled items at the time of resolution. - * Useful for back pressure: await this before accepting new work. - * - * @param {number} maxUnsettled - Maximum number of unsettled promises allowed before resolving - * @param {number} timeoutMs - Maximum time to wait in milliseconds - * @returns {Promise} - Resolves to the unsettled count at resolution time - */ - function limitUnsettled(maxUnsettled, timeoutMs = 10000) { - return new Promise(resolve => { - let timeoutId; - const cleanup = () => { - settleCallbacks.delete(onSettle); - clearTimeout(timeoutId); - }; - - const onSettle = () => { - const count = promises.size; - if (count < maxUnsettled) { - cleanup(); - resolve(count); - return true; - } - return false; - }; - - if (onSettle()) return; - - settleCallbacks.add(onSettle); - timeoutId = setTimeout(() => { - cleanup(); - resolve(promises.size); - }, timeoutMs); - }); - } - - /** - * Returns the unique ID of this tracker instance (for debugging). - * - * @returns {number} - */ - function getTrackerId() { - return trackerId; - } - - /** Interval id when status monitor is running; null otherwise */ - let statusMonitorIntervalId = null; - - /** - * Start periodically updating a StatusMonitor job with this tracker's settled/unsettled counts. - * Stops automatically when getUnsettledCount() becomes 0 (job is done from this tracker's perspective). - * Call stopStatusMonitor() to stop early (e.g. on abort). - * - * @param {string} typeId - Job type (e.g. 'stowInstances') - * @param {string} jobId - Job id from StatusMonitor.startJob - * @param {object} [options] - * @param {number} [options.intervalMs=500] - How often to update - * @param {(settled: number, unsettled: number) => object} [options.buildData] - Build job data from counts. If omitted, uses { [settledKey]: settled, [unsettledKey]: unsettled }. - * @param {string} [options.settledKey='settled'] - Key for settled count when buildData is omitted - * @param {string} [options.unsettledKey='unsettled'] - Key for unsettled count when buildData is omitted - * @param {number} [options.idleStopMs=300000] - Auto-stop after this many ms if (settled+unsettled) is still 0 or stops increasing. Default 5 minutes. - */ - function startStatusMonitor(typeId, jobId, options = {}) { - if (statusMonitorIntervalId != null) { - clearInterval(statusMonitorIntervalId); - statusMonitorIntervalId = null; - } - const intervalMs = options.intervalMs ?? DEFAULT_STATUS_MONITOR_INTERVAL_MS; - const idleStopMs = options.idleStopMs ?? STATUS_MONITOR_IDLE_STOP_MS; - const buildData = - options.buildData ?? - ((s, u) => ({ - [options.settledKey ?? 'settled']: s, - [options.unsettledKey ?? 'unsettled']: u, - })); - - const startTime = Date.now(); - let lastTotal = -1; - let lastTotalChangeTime = startTime; - - function tick() { - const settled = getSettledCount(); - const unsettled = getUnsettledCount(); - const total = settled + unsettled; - StatusMonitor.updateJob(typeId, jobId, buildData(settled, unsettled)); - - const now = Date.now(); - if (total !== lastTotal) { - lastTotal = total; - lastTotalChangeTime = now; - } - // Auto-stop when: (1) still 0 after idleStopMs, or (2) total has not increased for idleStopMs - if (total === 0 && now - startTime >= idleStopMs) { - stopStatusMonitor(); - return; - } - if (now - lastTotalChangeTime >= idleStopMs) { - stopStatusMonitor(); - } - } - - statusMonitorIntervalId = setInterval(tick, intervalMs); - tick(); - } - - /** - * Stop the status monitor interval (if running). No-op if not started or already stopped. - */ - function stopStatusMonitor() { - if (statusMonitorIntervalId != null) { - clearInterval(statusMonitorIntervalId); - statusMonitorIntervalId = null; - } - } - - return { - add, - limitUnsettled, - getUnsettledCount, - getSettledCount, - getTrackerId, - startStatusMonitor, - stopStatusMonitor, - }; -} +import { StatusMonitor } from './StatusMonitor.mjs'; + +/** Threshold above which we log a warning about pending promise count */ +const PENDING_PROMISE_WARN_THRESHOLD = 500; +/** Log again every this many beyond the threshold */ +const PENDING_PROMISE_WARN_STEP = 100; + +/** Default interval (ms) for status monitor updates */ +const DEFAULT_STATUS_MONITOR_INTERVAL_MS = 500; + +/** Auto-stop status monitor after this many ms with no change in total (settled + unsettled) or if still 0 */ +const STATUS_MONITOR_IDLE_STOP_MS = 5 * 60 * 1000; + +/** Counter for assigning unique IDs to trackers */ +let trackerIdCounter = 0; + +/** + * Tracks a list of promises and counts how many have settled. + * Provides back pressure via limitUnsettled for flow control. + * + * @param {string} [name] - Optional name for this tracker (for debugging) + * @example + * const tracker = createPromiseTracker('fileTracker'); + * tracker.add(someAsyncOperation()); + * await tracker.limitUnsettled(10, 30000); // Wait until <10 unsettled or 30s + */ +export function createPromiseTracker(name) { + const promises = new Set(); + const settleCallbacks = new Set(); + /** Total number of promises that have settled (fulfilled or rejected) since creation */ + let settledCount = 0; + /** Next count at which to log (500, 600, 700, …); reset when count drops below threshold */ + let pendingNextLogAt = PENDING_PROMISE_WARN_THRESHOLD; + /** Unique ID for this tracker instance (for debugging) */ + const trackerIdNum = ++trackerIdCounter; + const trackerId = name ? `${name}#${trackerIdNum}` : `#${trackerIdNum}`; + + function notifySettle() { + for (const cb of settleCallbacks) { + cb(); + } + } + + /** + * Add a promise to track. The promise is removed from the unsettled count when it settles. + * + * @param {Promise} promise - The promise to track + * @returns {Promise} - The same promise (for chaining) + */ + function add(promise) { + const p = Promise.resolve(promise); + promises.add(p); + if (promises.size >= pendingNextLogAt) { + const excess = promises.size - PENDING_PROMISE_WARN_THRESHOLD; + console.warn( + `[createPromiseTracker ${trackerId}] pending promise count exceeded ${PENDING_PROMISE_WARN_THRESHOLD} by ${excess}: pending=${promises.size} settled=${settledCount}` + ); + pendingNextLogAt = + PENDING_PROMISE_WARN_THRESHOLD + + PENDING_PROMISE_WARN_STEP * Math.floor(excess / PENDING_PROMISE_WARN_STEP) + + PENDING_PROMISE_WARN_STEP; + } + // Attach .catch() to the promise from .finally() so that when the tracked + // promise rejects, we don't get an unhandled rejection (e.g. invalid DICOM). + p.finally(() => { + promises.delete(p); + settledCount += 1; + if (promises.size < PENDING_PROMISE_WARN_THRESHOLD) { + pendingNextLogAt = PENDING_PROMISE_WARN_THRESHOLD; + } + notifySettle(); + }).catch(() => { + // Rejection is expected (e.g. invalid file); caller awaits the same promise + // and handles the error. We only track settlement for back pressure. + }); + return p; + } + + /** + * Returns the current count of unsettled (pending) promises. + * + * @returns {number} + */ + function getUnsettledCount() { + return promises.size; + } + + /** + * Returns the total count of promises that have settled (fulfilled or rejected) since creation. + * + * @returns {number} + */ + function getSettledCount() { + return settledCount; + } + + /** + * Returns a promise that resolves when either: + * - The count of unsettled items drops below maxUnsettled, OR + * - The given timeout in milliseconds is exceeded. + * + * Resolves to the number of unsettled items at the time of resolution. + * Useful for back pressure: await this before accepting new work. + * + * @param {number} maxUnsettled - Maximum number of unsettled promises allowed before resolving + * @param {number} timeoutMs - Maximum time to wait in milliseconds + * @returns {Promise} - Resolves to the unsettled count at resolution time + */ + function limitUnsettled(maxUnsettled, timeoutMs = 10000) { + return new Promise(resolve => { + let timeoutId; + const cleanup = () => { + settleCallbacks.delete(onSettle); + clearTimeout(timeoutId); + }; + + const onSettle = () => { + const count = promises.size; + if (count < maxUnsettled) { + cleanup(); + resolve(count); + return true; + } + return false; + }; + + if (onSettle()) return; + + settleCallbacks.add(onSettle); + timeoutId = setTimeout(() => { + cleanup(); + resolve(promises.size); + }, timeoutMs); + }); + } + + /** + * Returns the unique ID of this tracker instance (for debugging). + * + * @returns {number} + */ + function getTrackerId() { + return trackerId; + } + + /** Interval id when status monitor is running; null otherwise */ + let statusMonitorIntervalId = null; + + /** + * Start periodically updating a StatusMonitor job with this tracker's settled/unsettled counts. + * Stops automatically when getUnsettledCount() becomes 0 (job is done from this tracker's perspective). + * Call stopStatusMonitor() to stop early (e.g. on abort). + * + * @param {string} typeId - Job type (e.g. 'stowInstances') + * @param {string} jobId - Job id from StatusMonitor.startJob + * @param {object} [options] + * @param {number} [options.intervalMs=500] - How often to update + * @param {(settled: number, unsettled: number) => object} [options.buildData] - Build job data from counts. If omitted, uses { [settledKey]: settled, [unsettledKey]: unsettled }. + * @param {string} [options.settledKey='settled'] - Key for settled count when buildData is omitted + * @param {string} [options.unsettledKey='unsettled'] - Key for unsettled count when buildData is omitted + * @param {number} [options.idleStopMs=300000] - Auto-stop after this many ms if (settled+unsettled) is still 0 or stops increasing. Default 5 minutes. + */ + function startStatusMonitor(typeId, jobId, options = {}) { + if (statusMonitorIntervalId != null) { + clearInterval(statusMonitorIntervalId); + statusMonitorIntervalId = null; + } + const intervalMs = options.intervalMs ?? DEFAULT_STATUS_MONITOR_INTERVAL_MS; + const idleStopMs = options.idleStopMs ?? STATUS_MONITOR_IDLE_STOP_MS; + const buildData = + options.buildData ?? + ((s, u) => ({ + [options.settledKey ?? 'settled']: s, + [options.unsettledKey ?? 'unsettled']: u, + })); + + const startTime = Date.now(); + let lastTotal = -1; + let lastTotalChangeTime = startTime; + + function tick() { + const settled = getSettledCount(); + const unsettled = getUnsettledCount(); + const total = settled + unsettled; + StatusMonitor.updateJob(typeId, jobId, buildData(settled, unsettled)); + + const now = Date.now(); + if (total !== lastTotal) { + lastTotal = total; + lastTotalChangeTime = now; + } + // Auto-stop when: (1) still 0 after idleStopMs, or (2) total has not increased for idleStopMs + if (total === 0 && now - startTime >= idleStopMs) { + stopStatusMonitor(); + return; + } + if (now - lastTotalChangeTime >= idleStopMs) { + stopStatusMonitor(); + } + } + + statusMonitorIntervalId = setInterval(tick, intervalMs); + tick(); + } + + /** + * Stop the status monitor interval (if running). No-op if not started or already stopped. + */ + function stopStatusMonitor() { + if (statusMonitorIntervalId != null) { + clearInterval(statusMonitorIntervalId); + statusMonitorIntervalId = null; + } + } + + return { + add, + limitUnsettled, + getUnsettledCount, + getSettledCount, + getTrackerId, + startStatusMonitor, + stopStatusMonitor, + }; +} diff --git a/packages/static-wado-util/lib/createStudyDirectories.ts b/packages/static-wado-util/lib/createStudyDirectories.ts index ebcdb2f0..0dd31721 100644 --- a/packages/static-wado-util/lib/createStudyDirectories.ts +++ b/packages/static-wado-util/lib/createStudyDirectories.ts @@ -1,11 +1,11 @@ -import JSONWriter from './writer/JSONWriter'; -import fs from 'fs'; - -export function createStudyDirectories(dir) { - fs.mkdirSync(`${dir}/studies`, { recursive: true }); - fs.mkdirSync(`${dir}/temp`, { recursive: true }); - - JSONWriter(dir, 'index.json', [], { gzip: true }); -} - -export default createStudyDirectories; +import JSONWriter from './writer/JSONWriter'; +import fs from 'fs'; + +export function createStudyDirectories(dir) { + fs.mkdirSync(`${dir}/studies`, { recursive: true }); + fs.mkdirSync(`${dir}/temp`, { recursive: true }); + + JSONWriter(dir, 'index.json', [], { gzip: true }); +} + +export default createStudyDirectories; diff --git a/packages/static-wado-util/lib/dicomToXml.ts b/packages/static-wado-util/lib/dicomToXml.ts index 750e88d8..4a789598 100644 --- a/packages/static-wado-util/lib/dicomToXml.ts +++ b/packages/static-wado-util/lib/dicomToXml.ts @@ -1,41 +1,41 @@ -export function dicomToXml(json) { - const xml = [ - '', - '', - ]; - dicomObjectToXml(xml, json); - xml.push(''); - return xml.join('\n'); -} - -export function dicomObjectToXml(xml, json) { - for (const [tag, value] of Object.entries(json)) { - dicomTagToXml(xml, tag, value); - } - return xml; -} - -export function dicomTagToXml(xml, tag, value) { - if (!value || !value.vr || !value.Value) { - return xml; - } - xml.push(``); - const isSQ = value.vr === 'SQ'; - - let index = 1; - if (isSQ) { - for (const item of value.Value) { - xml.push(``); - dicomObjectToXml(xml, item); - xml.push(''); - } - } else { - for (const item of value.Value) { - xml.push(`${item}`); - } - } - xml.push(''); - return xml; -} +export function dicomToXml(json) { + const xml = [ + '', + '', + ]; + dicomObjectToXml(xml, json); + xml.push(''); + return xml.join('\n'); +} + +export function dicomObjectToXml(xml, json) { + for (const [tag, value] of Object.entries(json)) { + dicomTagToXml(xml, tag, value); + } + return xml; +} + +export function dicomTagToXml(xml, tag, value) { + if (!value || !value.vr || !value.Value) { + return xml; + } + xml.push(``); + const isSQ = value.vr === 'SQ'; + + let index = 1; + if (isSQ) { + for (const item of value.Value) { + xml.push(``); + dicomObjectToXml(xml, item); + xml.push(''); + } + } else { + for (const item of value.Value) { + xml.push(`${item}`); + } + } + xml.push(''); + return xml; +} diff --git a/packages/static-wado-util/lib/dictionary/Tags.mjs b/packages/static-wado-util/lib/dictionary/Tags.mjs index b0f4107f..75ff8d59 100644 --- a/packages/static-wado-util/lib/dictionary/Tags.mjs +++ b/packages/static-wado-util/lib/dictionary/Tags.mjs @@ -1,134 +1,134 @@ -import dcmjs from 'dcmjs'; -import dataDictionary from './dataDictionary.js'; - -const { naturalizeDataset, denaturalizeDataset } = dcmjs.data.DicomMetaDictionary; - -/** Find the actual tag for a private value */ -const findPrivate = (item, tagObject, create) => { - if (typeof tagObject === 'string') return tagObject; - if (typeof tagObject === 'number') return `00000000${tagObject.toString(16)}`.slice(-8); - const { creator, tag } = tagObject; - if (!creator) return tag; - const start = tag.substring(0, 4); - const end = tag.substring(6, 8); - // Should technically go all the way up, but for now only test 0x30 items - let assignPosition; - for (let offset = 0x10; offset < 0x40; offset++) { - const testTag = `${start}00${offset.toString(16)}`; - const testCreator = item[testTag]; - if (testCreator === undefined && assignPosition === undefined) assignPosition = offset; - if (testCreator && testCreator.Value && testCreator.Value[0] === creator) { - return `${start}${offset.toString(16)}${end}`; - } - } - if (create) { - if (!assignPosition) - throw new Error(`Couldn't find any assign positions for ${creator} ${tag} in ${item}`); - const creatorTag = `${start}00${assignPosition.toString(16)}`; - item[creatorTag] = { Value: [creator], vr: 'CS' }; - return `${start}${assignPosition.toString(16)}${end}`; - } -}; - -const DeduppedCreator = 'dedupped'; - -export const Tags = { - // Raw tags have the x before them, not parsed yet - RawMinTag: 'x00000000', - RawFirstBodyTag: 'x00080000', - RawTransferSyntaxUID: 'x00020010', - RawSopInstanceUID: 'x00080018', - RawSpecificCharacterSet: 'x00080005', - RawSamplesPerPixel: 'x00280002', - RawPhotometricInterpretation: 'x00280004', - - // This one isn't defined in the dataDictionary - AvailableTransferSyntaxUID: '00083002', - - DeduppedCreator, - - // The references to extract data included in this object, 1..n values - DeduppedRef: { creator: DeduppedCreator, tag: '00091010', vr: 'LO' }, - - // The hash value of THIS object - DeduppedHash: { creator: DeduppedCreator, tag: '00091011', vr: 'LO' }, - - // Type of hash instance - DeduppedType: { creator: DeduppedCreator, tag: '00091012', vr: 'LO' }, - InstanceType: 'instance', - DeletedType: 'deleted', - InfoType: 'info', - - naturalizeDataset, - denaturalizeDataset, - - setValue: (item, tag, value) => { - const actualTag = findPrivate(item, tag, true); - item[actualTag] = { - Value: Array.isArray(value) ? value : [value], - vr: dataDictionary[actualTag]?.vr || tag.vr || 'UN', - }; - return actualTag; - }, - - setList: (item, tag, value) => { - const actualTag = findPrivate(item, tag, true); - item[actualTag] = { - Value: Array.isArray(value) ? value : [value], - vr: dataDictionary[actualTag]?.vr || tag.vr || 'UN', - }; - return actualTag; - }, - - pushList: (item, tag, value) => { - const actualTag = findPrivate(item, tag, true); - if (!item[actualTag]) - item[actualTag] = { - Value: [], - vr: dataDictionary[actualTag]?.vr || tag.vr || 'UN', - }; - item[actualTag].Value.push(value); - return actualTag; - }, - - getValue: (item, tag) => { - const actualTag = findPrivate(item, tag); - if (!actualTag) return undefined; - const value = item[actualTag]; - if (value === undefined) return undefined; - if (Array.isArray(value)) { - if (value.length == 1) return value[0]; - return value.length === 0 ? '' : value; - } - if (value.Value) { - if (value.Value.length == 1) return value.Value[0]; - return value.Value.length === 0 ? '' : value.Value; - } - return value; - }, - - getList: (item, tag) => { - const actualTag = findPrivate(item, tag); - if (!actualTag) return undefined; - const value = item[actualTag]; - if (value === undefined) return undefined; - if (Array.isArray(value)) return value; - if (value.Value) return value.Value; - return [value]; - }, - - removeValue: (item, tag) => { - const actualTag = findPrivate(item, tag); - if (actualTag) delete item[actualTag]; - // TODO, remove creator tag if not used any longer - }, - - findPrivate, -}; - -Object.keys(dataDictionary).forEach(key => { - const value = dataDictionary[key]; - Tags[value.name] = key; -}); - -export default Tags; +import dcmjs from 'dcmjs'; +import dataDictionary from './dataDictionary.js'; + +const { naturalizeDataset, denaturalizeDataset } = dcmjs.data.DicomMetaDictionary; + +/** Find the actual tag for a private value */ +const findPrivate = (item, tagObject, create) => { + if (typeof tagObject === 'string') return tagObject; + if (typeof tagObject === 'number') return `00000000${tagObject.toString(16)}`.slice(-8); + const { creator, tag } = tagObject; + if (!creator) return tag; + const start = tag.substring(0, 4); + const end = tag.substring(6, 8); + // Should technically go all the way up, but for now only test 0x30 items + let assignPosition; + for (let offset = 0x10; offset < 0x40; offset++) { + const testTag = `${start}00${offset.toString(16)}`; + const testCreator = item[testTag]; + if (testCreator === undefined && assignPosition === undefined) assignPosition = offset; + if (testCreator && testCreator.Value && testCreator.Value[0] === creator) { + return `${start}${offset.toString(16)}${end}`; + } + } + if (create) { + if (!assignPosition) + throw new Error(`Couldn't find any assign positions for ${creator} ${tag} in ${item}`); + const creatorTag = `${start}00${assignPosition.toString(16)}`; + item[creatorTag] = { Value: [creator], vr: 'CS' }; + return `${start}${assignPosition.toString(16)}${end}`; + } +}; + +const DeduppedCreator = 'dedupped'; + +export const Tags = { + // Raw tags have the x before them, not parsed yet + RawMinTag: 'x00000000', + RawFirstBodyTag: 'x00080000', + RawTransferSyntaxUID: 'x00020010', + RawSopInstanceUID: 'x00080018', + RawSpecificCharacterSet: 'x00080005', + RawSamplesPerPixel: 'x00280002', + RawPhotometricInterpretation: 'x00280004', + + // This one isn't defined in the dataDictionary + AvailableTransferSyntaxUID: '00083002', + + DeduppedCreator, + + // The references to extract data included in this object, 1..n values + DeduppedRef: { creator: DeduppedCreator, tag: '00091010', vr: 'LO' }, + + // The hash value of THIS object + DeduppedHash: { creator: DeduppedCreator, tag: '00091011', vr: 'LO' }, + + // Type of hash instance + DeduppedType: { creator: DeduppedCreator, tag: '00091012', vr: 'LO' }, + InstanceType: 'instance', + DeletedType: 'deleted', + InfoType: 'info', + + naturalizeDataset, + denaturalizeDataset, + + setValue: (item, tag, value) => { + const actualTag = findPrivate(item, tag, true); + item[actualTag] = { + Value: Array.isArray(value) ? value : [value], + vr: dataDictionary[actualTag]?.vr || tag.vr || 'UN', + }; + return actualTag; + }, + + setList: (item, tag, value) => { + const actualTag = findPrivate(item, tag, true); + item[actualTag] = { + Value: Array.isArray(value) ? value : [value], + vr: dataDictionary[actualTag]?.vr || tag.vr || 'UN', + }; + return actualTag; + }, + + pushList: (item, tag, value) => { + const actualTag = findPrivate(item, tag, true); + if (!item[actualTag]) + item[actualTag] = { + Value: [], + vr: dataDictionary[actualTag]?.vr || tag.vr || 'UN', + }; + item[actualTag].Value.push(value); + return actualTag; + }, + + getValue: (item, tag) => { + const actualTag = findPrivate(item, tag); + if (!actualTag) return undefined; + const value = item[actualTag]; + if (value === undefined) return undefined; + if (Array.isArray(value)) { + if (value.length == 1) return value[0]; + return value.length === 0 ? '' : value; + } + if (value.Value) { + if (value.Value.length == 1) return value.Value[0]; + return value.Value.length === 0 ? '' : value.Value; + } + return value; + }, + + getList: (item, tag) => { + const actualTag = findPrivate(item, tag); + if (!actualTag) return undefined; + const value = item[actualTag]; + if (value === undefined) return undefined; + if (Array.isArray(value)) return value; + if (value.Value) return value.Value; + return [value]; + }, + + removeValue: (item, tag) => { + const actualTag = findPrivate(item, tag); + if (actualTag) delete item[actualTag]; + // TODO, remove creator tag if not used any longer + }, + + findPrivate, +}; + +Object.keys(dataDictionary).forEach(key => { + const value = dataDictionary[key]; + Tags[value.name] = key; +}); + +export default Tags; diff --git a/packages/static-wado-util/lib/parseSizeToBytes.ts b/packages/static-wado-util/lib/parseSizeToBytes.ts index de37dc10..ecd2c196 100644 --- a/packages/static-wado-util/lib/parseSizeToBytes.ts +++ b/packages/static-wado-util/lib/parseSizeToBytes.ts @@ -1,27 +1,27 @@ -/** - * Parses a size string to bytes. - * Accepts: kilobytes (10k, 10KB), megabytes (10m, 10MB), gigabytes (10g, 10GB), or terabytes (10t, 10TB). - * - * @param value - Size string (e.g. "10m", "10MB", "1.5g", "2t", "10485760") - * @returns Size in bytes - * @throws Error if value is invalid - */ -export function parseSizeToBytes(value) { - const str = String(value).trim(); - const lower = str.toLowerCase(); - const num = parseFloat(str.replace(/[kmgtb]/gi, '').trim()); - if (Number.isNaN(num) || num < 0) { - throw new Error( - `Invalid size "${value}": expected a size like 10k, 10m, 10g, 10t, 10KB, 10MB, 10GB, or 10TB` - ); - } - if (lower.endsWith('tb') || lower.endsWith('t')) - return Math.round(num * 1024 * 1024 * 1024 * 1024); - if (lower.endsWith('gb') || lower.endsWith('g')) return Math.round(num * 1024 * 1024 * 1024); - if (lower.endsWith('mb') || lower.endsWith('m')) return Math.round(num * 1024 * 1024); - if (lower.endsWith('kb') || lower.endsWith('k')) return Math.round(num * 1024); - if (/^\d+(\.\d+)?$/.test(str)) return Math.round(num); - throw new Error( - `Invalid size "${value}": expected a size like 10k, 10m, 10g, 10t, 10KB, 10MB, 10GB, or 10TB` - ); -} +/** + * Parses a size string to bytes. + * Accepts: kilobytes (10k, 10KB), megabytes (10m, 10MB), gigabytes (10g, 10GB), or terabytes (10t, 10TB). + * + * @param value - Size string (e.g. "10m", "10MB", "1.5g", "2t", "10485760") + * @returns Size in bytes + * @throws Error if value is invalid + */ +export function parseSizeToBytes(value) { + const str = String(value).trim(); + const lower = str.toLowerCase(); + const num = parseFloat(str.replace(/[kmgtb]/gi, '').trim()); + if (Number.isNaN(num) || num < 0) { + throw new Error( + `Invalid size "${value}": expected a size like 10k, 10m, 10g, 10t, 10KB, 10MB, 10GB, or 10TB` + ); + } + if (lower.endsWith('tb') || lower.endsWith('t')) + return Math.round(num * 1024 * 1024 * 1024 * 1024); + if (lower.endsWith('gb') || lower.endsWith('g')) return Math.round(num * 1024 * 1024 * 1024); + if (lower.endsWith('mb') || lower.endsWith('m')) return Math.round(num * 1024 * 1024); + if (lower.endsWith('kb') || lower.endsWith('k')) return Math.round(num * 1024); + if (/^\d+(\.\d+)?$/.test(str)) return Math.round(num); + throw new Error( + `Invalid size "${value}": expected a size like 10k, 10m, 10g, 10t, 10KB, 10MB, 10GB, or 10TB` + ); +} diff --git a/packages/static-wado-util/lib/parseTimeoutToMs.ts b/packages/static-wado-util/lib/parseTimeoutToMs.ts index 32bfce39..ec46c7e8 100644 --- a/packages/static-wado-util/lib/parseTimeoutToMs.ts +++ b/packages/static-wado-util/lib/parseTimeoutToMs.ts @@ -1,21 +1,21 @@ -/** - * Parses a timeout/duration string to milliseconds. - * Accepts: hours (10h), minutes (10m), or seconds (3600 or 3600s). - * - * @param value - Duration string (e.g. "10h", "30m", "3600", "3600s") - * @returns Timeout in milliseconds - * @throws Error if value is invalid - */ -export function parseTimeoutToMs(value) { - const str = String(value).trim().toLowerCase(); - const num = parseFloat(str.replace(/[hms]/g, '').trim()); - if (Number.isNaN(num) || num < 0) { - throw new Error( - `Invalid timeout "${value}": expected a duration like 10h, 10m, 3600, or 3600s` - ); - } - if (str.endsWith('h')) return Math.round(num * 3600 * 1000); - if (str.endsWith('m')) return Math.round(num * 60 * 1000); - if (str.endsWith('s') || /^\d+(\.\d+)?$/.test(str)) return Math.round(num * 1000); - throw new Error(`Invalid timeout "${value}": expected a duration like 10h, 10m, 3600, or 3600s`); -} +/** + * Parses a timeout/duration string to milliseconds. + * Accepts: hours (10h), minutes (10m), or seconds (3600 or 3600s). + * + * @param value - Duration string (e.g. "10h", "30m", "3600", "3600s") + * @returns Timeout in milliseconds + * @throws Error if value is invalid + */ +export function parseTimeoutToMs(value) { + const str = String(value).trim().toLowerCase(); + const num = parseFloat(str.replace(/[hms]/g, '').trim()); + if (Number.isNaN(num) || num < 0) { + throw new Error( + `Invalid timeout "${value}": expected a duration like 10h, 10m, 3600, or 3600s` + ); + } + if (str.endsWith('h')) return Math.round(num * 3600 * 1000); + if (str.endsWith('m')) return Math.round(num * 60 * 1000); + if (str.endsWith('s') || /^\d+(\.\d+)?$/.test(str)) return Math.round(num * 1000); + throw new Error(`Invalid timeout "${value}": expected a duration like 10h, 10m, 3600, or 3600s`); +} diff --git a/packages/static-wado-util/lib/sortStudies.mjs b/packages/static-wado-util/lib/sortStudies.mjs index 7f60f340..782bf59e 100644 --- a/packages/static-wado-util/lib/sortStudies.mjs +++ b/packages/static-wado-util/lib/sortStudies.mjs @@ -1,68 +1,68 @@ -import { Tags } from './dictionary/Tags.mjs'; -import { compareTo } from './compareTo.mjs'; - -const { getValue } = Tags; - -/** - * Compares two study query objects by StudyDate - * - * @param {Object} a - First study query object - * @param {Object} b - Second study query object - * @returns {number} - -1 if a < b, 0 if a === b, +1 if a > b - */ -export function compareStudyDate(a, b) { - const studyDateA = getValue(a, Tags.StudyDate); - const studyDateB = getValue(b, Tags.StudyDate); - return compareTo(studyDateA, studyDateB); -} - -/** - * Compares two study query objects by StudyTime - * - * @param {Object} a - First study query object - * @param {Object} b - Second study query object - * @returns {number} - -1 if a < b, 0 if a === b, +1 if a > b - */ -export function compareStudyTime(a, b) { - const studyTimeA = getValue(a, Tags.StudyTime); - const studyTimeB = getValue(b, Tags.StudyTime); - return compareTo(studyTimeA, studyTimeB); -} - -/** - * Compares two study query objects by StudyInstanceUID - * - * @param {Object} a - First study query object - * @param {Object} b - Second study query object - * @returns {number} - -1 if a < b, 0 if a === b, +1 if a > b - */ -export function compareStudyUID(a, b) { - const uidA = getValue(a, Tags.StudyInstanceUID); - const uidB = getValue(b, Tags.StudyInstanceUID); - return compareTo(uidA, uidB); -} - -/** - * Combined comparator function that compares studies by StudyDate, StudyTime, and StudyInstanceUID - * - * @param {Object} a - First study query object - * @param {Object} b - Second study query object - * @returns {number} - -1 if a < b, 0 if a === b, +1 if a > b - */ -export function compareStudies(a, b) { - return compareStudyDate(a, b) || compareStudyTime(a, b) || compareStudyUID(a, b); -} - -/** - * Sorts an array of study query objects by StudyDate, StudyTime, and StudyInstanceUID - * - * @param {Array} studies - Array of study query objects to sort - * @returns {Array} - Sorted array of study query objects (mutates the input array) - */ -export function sortStudies(studies) { - if (!Array.isArray(studies)) { - throw new Error('sortStudies expects an array of study query objects'); - } - - return studies.sort(compareStudies); +import { Tags } from './dictionary/Tags.mjs'; +import { compareTo } from './compareTo.mjs'; + +const { getValue } = Tags; + +/** + * Compares two study query objects by StudyDate + * + * @param {Object} a - First study query object + * @param {Object} b - Second study query object + * @returns {number} - -1 if a < b, 0 if a === b, +1 if a > b + */ +export function compareStudyDate(a, b) { + const studyDateA = getValue(a, Tags.StudyDate); + const studyDateB = getValue(b, Tags.StudyDate); + return compareTo(studyDateA, studyDateB); +} + +/** + * Compares two study query objects by StudyTime + * + * @param {Object} a - First study query object + * @param {Object} b - Second study query object + * @returns {number} - -1 if a < b, 0 if a === b, +1 if a > b + */ +export function compareStudyTime(a, b) { + const studyTimeA = getValue(a, Tags.StudyTime); + const studyTimeB = getValue(b, Tags.StudyTime); + return compareTo(studyTimeA, studyTimeB); +} + +/** + * Compares two study query objects by StudyInstanceUID + * + * @param {Object} a - First study query object + * @param {Object} b - Second study query object + * @returns {number} - -1 if a < b, 0 if a === b, +1 if a > b + */ +export function compareStudyUID(a, b) { + const uidA = getValue(a, Tags.StudyInstanceUID); + const uidB = getValue(b, Tags.StudyInstanceUID); + return compareTo(uidA, uidB); +} + +/** + * Combined comparator function that compares studies by StudyDate, StudyTime, and StudyInstanceUID + * + * @param {Object} a - First study query object + * @param {Object} b - Second study query object + * @returns {number} - -1 if a < b, 0 if a === b, +1 if a > b + */ +export function compareStudies(a, b) { + return compareStudyDate(a, b) || compareStudyTime(a, b) || compareStudyUID(a, b); +} + +/** + * Sorts an array of study query objects by StudyDate, StudyTime, and StudyInstanceUID + * + * @param {Array} studies - Array of study query objects to sort + * @returns {Array} - Sorted array of study query objects (mutates the input array) + */ +export function sortStudies(studies) { + if (!Array.isArray(studies)) { + throw new Error('sortStudies expects an array of study query objects'); + } + + return studies.sort(compareStudies); } \ No newline at end of file diff --git a/packages/static-wado-util/test/unit/asyncIterable.jest.mjs b/packages/static-wado-util/test/unit/asyncIterable.jest.mjs index a7c292ff..a5ae7b8e 100644 --- a/packages/static-wado-util/test/unit/asyncIterable.jest.mjs +++ b/packages/static-wado-util/test/unit/asyncIterable.jest.mjs @@ -1,42 +1,42 @@ -import fs from "fs"; -import must from "must"; -import asyncIteratorToBuffer from "../../lib/asyncIterableToBuffer.js"; - -describe("asyncIterableToBuffer", () => { - let dicomp10stream; - - beforeEach(async () => { - dicomp10stream = fs.createReadStream( - `${TEST_DATA_PATH}/dcm/Juno/1.3.6.1.4.1.25403.345050719074.3824.20170125113606.8`, - ); - }); - - it('copies child elements correctly', async () => { - const buffer = await asyncIteratorToBuffer(dicomp10stream); - const dest = new Uint8Array(132); - // D character in DICM prefix - must(buffer[128]).be.eql(68); - buffer.copy(dest, 0, 0, 132); - // Should have copied - - expect(dest[128]).toEqual(buffer[128]); - }); - - it('re-assembles buffers correctly', async () => { - const buffer = await asyncIteratorToBuffer(dicomp10stream); - const start = 3215 + 8; - const len = 526728 - start; - - const subBuffer = buffer.slice(start, start + len); - for (let i = 0; i < len; i++) { - const bufVal = buffer[i + start]; - const subVal = subBuffer[i]; - if (bufVal != subVal) { - console.log( - `At position ${i} relative to ${start} buffer is ${bufVal} but subVal is ${subVal}` - ); - } - expect(buffer[i + start]).toEqual(subBuffer[i]); - } - }); -}); +import fs from "fs"; +import must from "must"; +import asyncIteratorToBuffer from "../../lib/asyncIterableToBuffer.js"; + +describe("asyncIterableToBuffer", () => { + let dicomp10stream; + + beforeEach(async () => { + dicomp10stream = fs.createReadStream( + `${TEST_DATA_PATH}/dcm/Juno/1.3.6.1.4.1.25403.345050719074.3824.20170125113606.8`, + ); + }); + + it('copies child elements correctly', async () => { + const buffer = await asyncIteratorToBuffer(dicomp10stream); + const dest = new Uint8Array(132); + // D character in DICM prefix + must(buffer[128]).be.eql(68); + buffer.copy(dest, 0, 0, 132); + // Should have copied + + expect(dest[128]).toEqual(buffer[128]); + }); + + it('re-assembles buffers correctly', async () => { + const buffer = await asyncIteratorToBuffer(dicomp10stream); + const start = 3215 + 8; + const len = 526728 - start; + + const subBuffer = buffer.slice(start, start + len); + for (let i = 0; i < len; i++) { + const bufVal = buffer[i + start]; + const subVal = subBuffer[i]; + if (bufVal != subVal) { + console.log( + `At position ${i} relative to ${start} buffer is ${bufVal} but subVal is ${subVal}` + ); + } + expect(buffer[i + start]).toEqual(subBuffer[i]); + } + }); +}); diff --git a/packages/static-wado-util/tsconfig.json b/packages/static-wado-util/tsconfig.json index 58aa5ffd..7767cba4 100644 --- a/packages/static-wado-util/tsconfig.json +++ b/packages/static-wado-util/tsconfig.json @@ -1,14 +1,14 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./lib", - "module": "ESNext", - "moduleResolution": "bundler", - "allowJs": true, - "declaration": true, - "skipLibCheck": true, - "noCheck": true - }, - "include": ["./lib/**/*"] -} +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./lib", + "module": "ESNext", + "moduleResolution": "bundler", + "allowJs": true, + "declaration": true, + "skipLibCheck": true, + "noCheck": true + }, + "include": ["./lib/**/*"] +} diff --git a/packages/static-wado-webserver/lib/controllers/server/SagaBusMessaging.mjs b/packages/static-wado-webserver/lib/controllers/server/SagaBusMessaging.mjs index c5cb675c..0c8d1a6d 100644 --- a/packages/static-wado-webserver/lib/controllers/server/SagaBusMessaging.mjs +++ b/packages/static-wado-webserver/lib/controllers/server/SagaBusMessaging.mjs @@ -1,253 +1,253 @@ -// SagaBusMessaging.mjs -// ESM module (JavaScript with modules) -// Requires: @saga-bus/core -// Node >= 14 recommended. - -import { createBus } from "@saga-bus/core"; -import crypto from "node:crypto"; - -/** - * SagaBusMessaging - * - * Semantics (single process): - * - Per-key serialization: only one handler runs at a time for (operationName,id) - * - "Pre-start" dedupe: right before starting work for a key, collapse all currently-pending - * messages for that key into ONE message (keep latest by default), ack the duplicates. - * - Once the handler STARTS, that message is no longer eligible for dedupe. - * - Messages arriving while a handler is running will run AFTER it finishes (and will get their own pre-start dedupe). - * - * Notes: - * - Local-process behavior. Does not prevent duplicates across multiple servers. - * - If the handler throws, we log error + message + data and rethrow (via promise rejection) - * so Saga Bus retry/redelivery can apply. - */ -export class SagaBusMessaging { - /** - * @param {object} params - * @param {*} params.transport - saga-bus transport instance (e.g., in-memory, SQS) - * @param {*} [params.store] - optional saga store - * @param {number} [params.concurrency=20] - overall bus concurrency (per-key serialization is enforced here) - * @param {*} [params.retry] - saga-bus retry config - * @param {Array} [params.middleware=[]] - saga-bus middleware - * @param {{info:Function,warn:Function,error:Function}} [params.logger=console] - * @param {"latest"|"earliest"} [params.dedupePolicy="latest"] - which pending msg survives at pre-start dedupe - */ - constructor(params) { - if (!params?.transport) throw new Error("SagaBusMessaging requires { transport }"); - - this.logger = params.logger ?? console; - this.dedupePolicy = params.dedupePolicy ?? "latest"; - this.transport = params.transport; - - // key -> { running: boolean, pending: Array<{msg, resolve, reject}> } - this.states = new Map(); - - this.bus = createBus({ - transport: params.transport, - store: params.store, - sagas: [], - middleware: params.middleware ?? [], - concurrency: typeof params.concurrency === "number" ? params.concurrency : 20, - retry: params.retry, - logger: this.logger, - }); - } - - async start() { - await this.bus.start(); - } - - async stop() { - await this.bus.stop(); - } - - /** - * Send a message (optionally delayed). - * @param {string} operationName - * @param {string} id - * @param {object} data - JSON-compatible object - * @param {{delayMs?: number}} [options] - */ - async sendMessage(operationName, id, data, options = {}) { - if (typeof operationName !== "string" || !operationName) { - throw new Error("sendMessage(operationName, ...) requires operationName string"); - } - if (typeof id !== "string" || !id) { - throw new Error("sendMessage(..., id, ...) requires id string"); - } - if (data === null || typeof data !== "object" || Array.isArray(data)) { - throw new Error("sendMessage(..., data) requires a JSON-compatible object"); - } - - const msg = { - type: operationName, - id, - data, - messageId: this.#newMessageId(), - sentAt: Date.now(), - }; - - const delayMs = options.delayMs; - if (delayMs && delayMs > 0) { - await new Promise((resolve) => setTimeout(resolve, delayMs)); - } - - await this.bus.publish(msg); - } - - /** - * Register an async handler for an operation. - * @param {string} operationName - * @param {(msg: {type:string,id:string,data:Object,messageId:string,sentAt:number}) => Promise} handler - */ - registerHandler(operationName, handler) { - if (typeof operationName !== "string" || !operationName) { - throw new Error("registerHandler(operationName, ...) requires operationName string"); - } - if (typeof handler !== "function") { - throw new Error("registerHandler(..., handler) requires a function"); - } - - // Create handler wrapper manually (createHandler doesn't exist in @saga-bus/core) - const wrapped = async (ctx) => { - const msg = ctx.message || ctx; - // Only process messages of the correct type - if (msg.type !== operationName) { - return; - } - const key = this.#makeKey(msg.type, msg.id); - - // resolve => ack; reject/throw => retry/redelivery (at-least-once) - return this.#enqueueAndMaybeProcess(key, msg, handler, operationName); - }; - - // Set messageType property if the bus expects it - wrapped.messageType = operationName; - - // Support common saga-bus API shapes - if (typeof this.bus.registerHandler === "function") { - this.bus.registerHandler(wrapped); - return; - } - if (typeof this.bus.addHandler === "function") { - this.bus.addHandler(wrapped); - return; - } - if (Array.isArray(this.bus.handlers)) { - this.bus.handlers.push(wrapped); - return; - } - - // Try subscribing via transport directly if bus methods don't work - if (this.transport && typeof this.transport.subscribe === "function") { - this.transport.subscribe(operationName, async (message) => { - // Filter to only handle messages of the correct type - if (message.type !== operationName) { - return; - } - const key = this.#makeKey(message.type, message.id); - return this.#enqueueAndMaybeProcess(key, message, handler, operationName); - }); - return; - } - - throw new Error( - "Unable to register handler: bus does not expose registerHandler/addHandler/handlers[], " + - "and transport does not have subscribe method. Check your @saga-bus/core version API." - ); - } - - // ----------------------- - // Internals (private) - // ----------------------- - - #makeKey(operationName, id) { - return `${operationName}:${id}`; - } - - #getState(key) { - const existing = this.states.get(key); - if (existing) return existing; - const created = { running: false, pending: [] }; - this.states.set(key, created); - return created; - } - - #tryCleanupKey(key) { - const st = this.states.get(key); - if (!st) return; - if (!st.running && st.pending.length === 0) this.states.delete(key); - } - - #newMessageId() { - if (typeof crypto.randomUUID === "function") return crypto.randomUUID(); - return `${Date.now()}-${crypto.randomBytes(8).toString("hex")}`; - } - - async #enqueueAndMaybeProcess(key, msg, userHandler, operationNameForLogs) { - const state = this.#getState(key); - - return new Promise((resolve, reject) => { - state.pending.push({ msg, resolve, reject }); - // kick loop (don’t await here) - void this.#maybeRunNext(key, userHandler, operationNameForLogs); - }); - } - - async #maybeRunNext(key, userHandler, operationNameForLogs) { - const state = this.#getState(key); - - // already running => new arrivals wait - if (state.running) return; - - // nothing queued - if (state.pending.length === 0) return; - - // ---- PRE-START DEDUPE POINT ---- - // grab all currently pending for this key and keep one - const batch = state.pending; - state.pending = []; - - const keepIndex = this.dedupePolicy === "latest" ? batch.length - 1 : 0; - const keeper = batch[keepIndex]; - - // ack duplicates (deduped before handler start) - for (let i = 0; i < batch.length; i++) { - if (i === keepIndex) continue; - const d = batch[i]; - console.verbose('[SagaBusMessaging] Deduped pending message before start:', { - key, - type: d.msg.type, - id: d.msg.id, - messageId: d.msg.messageId, - keptMessageId: keeper.msg.messageId, - policy: this.dedupePolicy, - }); - d.resolve(); - } - - // start work (now NOT eligible for dedupe) - state.running = true; - - try { - await userHandler(keeper.msg); - keeper.resolve(); // ack - } catch (err) { - this.logger.error(`[SagaBusMessaging] Handler error for ${operationNameForLogs}:`, { - error: err?.stack ?? err, - message: keeper.msg, - data: keeper.msg.data, - }); - keeper.reject(err); // trigger retry/redelivery if configured - } finally { - state.running = false; - - // run next batch if any arrived while running - if (state.pending.length > 0) { - void this.#maybeRunNext(key, userHandler, operationNameForLogs); - } else { - this.#tryCleanupKey(key); - } - } - } -} +// SagaBusMessaging.mjs +// ESM module (JavaScript with modules) +// Requires: @saga-bus/core +// Node >= 14 recommended. + +import { createBus } from "@saga-bus/core"; +import crypto from "node:crypto"; + +/** + * SagaBusMessaging + * + * Semantics (single process): + * - Per-key serialization: only one handler runs at a time for (operationName,id) + * - "Pre-start" dedupe: right before starting work for a key, collapse all currently-pending + * messages for that key into ONE message (keep latest by default), ack the duplicates. + * - Once the handler STARTS, that message is no longer eligible for dedupe. + * - Messages arriving while a handler is running will run AFTER it finishes (and will get their own pre-start dedupe). + * + * Notes: + * - Local-process behavior. Does not prevent duplicates across multiple servers. + * - If the handler throws, we log error + message + data and rethrow (via promise rejection) + * so Saga Bus retry/redelivery can apply. + */ +export class SagaBusMessaging { + /** + * @param {object} params + * @param {*} params.transport - saga-bus transport instance (e.g., in-memory, SQS) + * @param {*} [params.store] - optional saga store + * @param {number} [params.concurrency=20] - overall bus concurrency (per-key serialization is enforced here) + * @param {*} [params.retry] - saga-bus retry config + * @param {Array} [params.middleware=[]] - saga-bus middleware + * @param {{info:Function,warn:Function,error:Function}} [params.logger=console] + * @param {"latest"|"earliest"} [params.dedupePolicy="latest"] - which pending msg survives at pre-start dedupe + */ + constructor(params) { + if (!params?.transport) throw new Error("SagaBusMessaging requires { transport }"); + + this.logger = params.logger ?? console; + this.dedupePolicy = params.dedupePolicy ?? "latest"; + this.transport = params.transport; + + // key -> { running: boolean, pending: Array<{msg, resolve, reject}> } + this.states = new Map(); + + this.bus = createBus({ + transport: params.transport, + store: params.store, + sagas: [], + middleware: params.middleware ?? [], + concurrency: typeof params.concurrency === "number" ? params.concurrency : 20, + retry: params.retry, + logger: this.logger, + }); + } + + async start() { + await this.bus.start(); + } + + async stop() { + await this.bus.stop(); + } + + /** + * Send a message (optionally delayed). + * @param {string} operationName + * @param {string} id + * @param {object} data - JSON-compatible object + * @param {{delayMs?: number}} [options] + */ + async sendMessage(operationName, id, data, options = {}) { + if (typeof operationName !== "string" || !operationName) { + throw new Error("sendMessage(operationName, ...) requires operationName string"); + } + if (typeof id !== "string" || !id) { + throw new Error("sendMessage(..., id, ...) requires id string"); + } + if (data === null || typeof data !== "object" || Array.isArray(data)) { + throw new Error("sendMessage(..., data) requires a JSON-compatible object"); + } + + const msg = { + type: operationName, + id, + data, + messageId: this.#newMessageId(), + sentAt: Date.now(), + }; + + const delayMs = options.delayMs; + if (delayMs && delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + + await this.bus.publish(msg); + } + + /** + * Register an async handler for an operation. + * @param {string} operationName + * @param {(msg: {type:string,id:string,data:Object,messageId:string,sentAt:number}) => Promise} handler + */ + registerHandler(operationName, handler) { + if (typeof operationName !== "string" || !operationName) { + throw new Error("registerHandler(operationName, ...) requires operationName string"); + } + if (typeof handler !== "function") { + throw new Error("registerHandler(..., handler) requires a function"); + } + + // Create handler wrapper manually (createHandler doesn't exist in @saga-bus/core) + const wrapped = async (ctx) => { + const msg = ctx.message || ctx; + // Only process messages of the correct type + if (msg.type !== operationName) { + return; + } + const key = this.#makeKey(msg.type, msg.id); + + // resolve => ack; reject/throw => retry/redelivery (at-least-once) + return this.#enqueueAndMaybeProcess(key, msg, handler, operationName); + }; + + // Set messageType property if the bus expects it + wrapped.messageType = operationName; + + // Support common saga-bus API shapes + if (typeof this.bus.registerHandler === "function") { + this.bus.registerHandler(wrapped); + return; + } + if (typeof this.bus.addHandler === "function") { + this.bus.addHandler(wrapped); + return; + } + if (Array.isArray(this.bus.handlers)) { + this.bus.handlers.push(wrapped); + return; + } + + // Try subscribing via transport directly if bus methods don't work + if (this.transport && typeof this.transport.subscribe === "function") { + this.transport.subscribe(operationName, async (message) => { + // Filter to only handle messages of the correct type + if (message.type !== operationName) { + return; + } + const key = this.#makeKey(message.type, message.id); + return this.#enqueueAndMaybeProcess(key, message, handler, operationName); + }); + return; + } + + throw new Error( + "Unable to register handler: bus does not expose registerHandler/addHandler/handlers[], " + + "and transport does not have subscribe method. Check your @saga-bus/core version API." + ); + } + + // ----------------------- + // Internals (private) + // ----------------------- + + #makeKey(operationName, id) { + return `${operationName}:${id}`; + } + + #getState(key) { + const existing = this.states.get(key); + if (existing) return existing; + const created = { running: false, pending: [] }; + this.states.set(key, created); + return created; + } + + #tryCleanupKey(key) { + const st = this.states.get(key); + if (!st) return; + if (!st.running && st.pending.length === 0) this.states.delete(key); + } + + #newMessageId() { + if (typeof crypto.randomUUID === "function") return crypto.randomUUID(); + return `${Date.now()}-${crypto.randomBytes(8).toString("hex")}`; + } + + async #enqueueAndMaybeProcess(key, msg, userHandler, operationNameForLogs) { + const state = this.#getState(key); + + return new Promise((resolve, reject) => { + state.pending.push({ msg, resolve, reject }); + // kick loop (don’t await here) + void this.#maybeRunNext(key, userHandler, operationNameForLogs); + }); + } + + async #maybeRunNext(key, userHandler, operationNameForLogs) { + const state = this.#getState(key); + + // already running => new arrivals wait + if (state.running) return; + + // nothing queued + if (state.pending.length === 0) return; + + // ---- PRE-START DEDUPE POINT ---- + // grab all currently pending for this key and keep one + const batch = state.pending; + state.pending = []; + + const keepIndex = this.dedupePolicy === "latest" ? batch.length - 1 : 0; + const keeper = batch[keepIndex]; + + // ack duplicates (deduped before handler start) + for (let i = 0; i < batch.length; i++) { + if (i === keepIndex) continue; + const d = batch[i]; + console.verbose('[SagaBusMessaging] Deduped pending message before start:', { + key, + type: d.msg.type, + id: d.msg.id, + messageId: d.msg.messageId, + keptMessageId: keeper.msg.messageId, + policy: this.dedupePolicy, + }); + d.resolve(); + } + + // start work (now NOT eligible for dedupe) + state.running = true; + + try { + await userHandler(keeper.msg); + keeper.resolve(); // ack + } catch (err) { + this.logger.error(`[SagaBusMessaging] Handler error for ${operationNameForLogs}:`, { + error: err?.stack ?? err, + message: keeper.msg, + data: keeper.msg.data, + }); + keeper.reject(err); // trigger retry/redelivery if configured + } finally { + state.running = false; + + // run next batch if any arrived while running + if (state.pending.length > 0) { + void this.#maybeRunNext(key, userHandler, operationNameForLogs); + } else { + this.#tryCleanupKey(key); + } + } + } +} diff --git a/packages/static-wado-webserver/lib/controllers/server/TrackableReadBufferStream.mjs b/packages/static-wado-webserver/lib/controllers/server/TrackableReadBufferStream.mjs index a1d8b036..e8241deb 100644 --- a/packages/static-wado-webserver/lib/controllers/server/TrackableReadBufferStream.mjs +++ b/packages/static-wado-webserver/lib/controllers/server/TrackableReadBufferStream.mjs @@ -1,263 +1,263 @@ -import { data } from 'dcmjs'; -import { recordLivelock } from '../../util/livelockRegistry.mjs'; - -const { ReadBufferStream } = data; - -/** Default: no livelock detection. Set to e.g. 15000 to log after 15s waiting. */ -const DEFAULT_LIVELOCK_DETECT_MS = 0; - -/** - * ReadBufferStream subclass that tracks pending ensureAvailable calls and - * exposes current stream size for use in multipartStream (e.g. back-pressure). - * - * Tracking is implemented here so dcmjs BufferStream.js is not modified. - * - * - currentStreamSize: bytes written to the stream so far (same as .size). - * - pendingEnsureAvailableDesiredBytes: byte counts that readers are currently - * awaiting (from ensureAvailable()). - * - maxPendingEnsureAvailableBytes: max of the above; bytes beyond the current - * offset that are being awaited by any pending ensureAvailable. - * - * Optional livelock detection: set options.livelockDetectMs (or env - * TRACKABLE_STREAM_LIVELOCK_DETECT_MS) to a positive number. If an - * ensureAvailable() promise is still pending after that many ms, a warning - * is logged with the stack trace captured at the time of the call (to help - * find where the reader is stuck). - * - * Backpressure options (for shouldPause / waitForBackPressure): - * - backpressureMaxBytes: max bytes beyond offset before pausing (default 512kb). - * - streamWritePromiseTracker: { getUnsettledCount(), limitUnsettled(max, timeoutMs) }. - * - streamWriteLimit: max unsettled stream writes before pausing. - * - backpressureWaitMs: time to wait when size is too big (ms). - * - backPressureTimeoutMs: timeout for limitUnsettled (ms). - */ -export class TrackableReadBufferStream extends ReadBufferStream { - constructor(buffer, littleEndian, options = {}) { - super(buffer, littleEndian, options); - /** @type {{ bytes: number }[]} */ - this._pendingEnsureAvailableEntries = []; - const envMs = parseInt( - typeof process !== 'undefined' && process.env.TRACKABLE_STREAM_LIVELOCK_DETECT_MS, - 10 - ); - this._livelockDetectMs = - options.livelockDetectMs ?? (Number.isFinite(envMs) ? envMs : DEFAULT_LIVELOCK_DETECT_MS); - - this._backpressureMaxBytes = options.backpressureMaxBytes ?? 128 * 1024; - this._streamWritePromiseTracker = options.streamWritePromiseTracker ?? null; - this._streamWriteLimit = options.streamWriteLimit ?? 25; - this._backpressureWaitMs = options.backpressureWaitMs ?? 1000; - this._backPressureTimeoutMs = options.backPressureTimeoutMs ?? 5000; - /** Set when the request is aborted (e.g. timeout, client disconnect). instanceFromStream uses this to throw/return as aborted. */ - this._abortedReason = null; - /** Promise rejected when setAborted() is called; used so ensureAvailable() rejects instead of resolving when request is killed. */ - this._abortPromise = new Promise((_, rej) => { - this._abortReject = rej; - }); - - // Log tracker ID for debugging - if (this._streamWritePromiseTracker) { - console.verbose( - `[TrackableReadBufferStream] created with tracker ${this._streamWritePromiseTracker.getTrackerId()}` - ); - } - } - - /** - * Mark the stream as aborted (e.g. STOW request timeout or client disconnect). - * Sets abortedReason and marks the stream complete so readers can finish and treat the result as aborted. - * @param {Error} [err] - Reason for abort - */ - setAborted(err) { - this._abortedReason = err ?? new Error('Request aborted'); - if (this._abortReject) { - this._abortReject(this._abortedReason); - this._abortReject = null; - } - this.setComplete(); - } - - /** - * If set, the request was aborted; instanceFromStream should throw/return with aborted semantics. - * @returns {Error|null} - */ - get abortedReason() { - return this._abortedReason ?? null; - } - - /** - * Returns true if the read-from-stream / write-to-buffer path should pause. - * True when bytes beyond offset exceeds the size threshold (max of backpressureMaxBytes - * and maxPendingEnsureAvailableBytes), or when stream write promise count exceeds streamWriteLimit. - * @returns {boolean} - */ - shouldPause() { - const bytesBeyondOffset = this.currentStreamSize - this.offset; - const sizeThreshold = Math.max(this._backpressureMaxBytes, this.maxPendingEnsureAvailableBytes); - const tracker = this._streamWritePromiseTracker; - if (bytesBeyondOffset > sizeThreshold) { - console.verbose( - '[TrackableReadBufferStream] shouldPause', - bytesBeyondOffset - sizeThreshold, - Math.floor(this.offset / 1024), - 'kb, tracker:', - tracker?.getTrackerId(), - 'writes:', - tracker?.getUnsettledCount(), - tracker?.getSettledCount() - ); - return true; - } - if (tracker && this._streamWriteLimit) { - const unsettled = tracker.getUnsettledCount(); - if (unsettled > this._streamWriteLimit) { - const settled = tracker.getSettledCount(); - console.verbose( - `[TrackableReadBufferStream] shouldPause: true (streamWrite unsettled=${unsettled} > limit=${this._streamWriteLimit}, settled=${settled})` - ); - return true; - } - } - return false; - } - - /** - * Waits for backpressure to ease: if size is too big, waits configured time - * or until stream write count drops (whichever first); if stream writes are - * too many, waits until limitUnsettled. If both conditions held, waits the - * configured time then waits for limitUnsettled. - * @returns {Promise} - */ - async waitForBackPressure() { - const bytesBeyondOffset = this.currentStreamSize - this.offset; - const sizeThreshold = Math.max(this._backpressureMaxBytes, this.maxPendingEnsureAvailableBytes); - const sizeTooBig = bytesBeyondOffset > sizeThreshold; - const tracker = this._streamWritePromiseTracker; - const limit = this._streamWriteLimit; - - const waitMs = this._backpressureWaitMs; - const timeoutMs = this._backPressureTimeoutMs; - const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); - - if (tracker?.getUnsettledCount() > limit) { - console.verbose( - '[TrackableReadBufferStream] waitForBackPressure: tracker', - tracker.getTrackerId(), - 'unsettled', - tracker.getUnsettledCount(), - 'settled', - tracker.getSettledCount(), - 'limit', - limit, - 'timeoutMs', - waitMs * 10 - ); - await tracker.limitUnsettled(limit, waitMs * 10); - return; - } - if (sizeTooBig) { - console.verbose('[TrackableReadBufferStream] waitForBackPressure: sizeTooBig'); - await sleep(waitMs); - } - } - - /** - * Override to track desired bytes for each pending ensureAvailable call. - * Optionally logs a livelock warning with stack if the wait exceeds livelockDetectMs. - * @param {number} [bytes=1024] - * @returns {boolean | Promise} - */ - ensureAvailable(bytes = 1024) { - if (this._abortedReason) { - return Promise.reject(this._abortedReason); - } - if (this.isAvailable(bytes)) return true; - const entry = { bytes }; - this._pendingEnsureAvailableEntries.push(entry); - let stackCapture = null; - if (this._livelockDetectMs > 0) { - const err = new Error('[TrackableReadBufferStream ensureAvailable]'); - stackCapture = err.stack || String(err); - } - const result = super.ensureAvailable(bytes); - if (result && typeof result.then === 'function') { - const raced = Promise.race([result, this._abortPromise]); - const removeEntry = () => { - const i = this._pendingEnsureAvailableEntries.indexOf(entry); - if (i !== -1) this._pendingEnsureAvailableEntries.splice(i, 1); - }; - const livelockMs = this._livelockDetectMs; - if (livelockMs > 0 && stackCapture) { - const timer = setTimeout(() => { - const idx = this._pendingEnsureAvailableEntries.indexOf(entry); - if (idx !== -1) { - console.warn( - `[TrackableReadBufferStream] Possible livelock: ensureAvailable(${bytes}) still pending after ${livelockMs}ms. Stream: offset=${this.offset} endOffset=${this.endOffset} isComplete=${this.isComplete}. Call stack at ensureAvailable:` - ); - process.stderr.write(stackCapture + '\n'); - recordLivelock({ - bytes, - offset: this.offset, - endOffset: this.endOffset, - isComplete: this.isComplete, - livelockDetectMs: livelockMs, - stack: stackCapture, - }); - } - }, livelockMs); - return raced.then( - r => { - clearTimeout(timer); - removeEntry(); - return r; - }, - e => { - clearTimeout(timer); - removeEntry(); - throw e; - } - ); - } - return raced.then( - r => { - removeEntry(); - return r; - }, - e => { - removeEntry(); - throw e; - } - ); - } - const i = this._pendingEnsureAvailableEntries.indexOf(entry); - if (i !== -1) this._pendingEnsureAvailableEntries.splice(i, 1); - return result; - } - - /** - * Current total bytes in the stream (updated as addBuffer is called). - * @returns {number} - */ - get currentStreamSize() { - return this.size; - } - - /** - * Byte counts that readers are currently awaiting (from ensureAvailable()). - * @returns {number[]} - */ - get pendingEnsureAvailableDesiredBytes() { - return this._pendingEnsureAvailableEntries.map(e => e.bytes); - } - - /** - * Number of bytes beyond the current offset that are being awaited by - * pending ensureAvailable call(s). Equal to the maximum of - * pendingEnsureAvailableDesiredBytes, or 0 if none are pending. - * @returns {number} - */ - get maxPendingEnsureAvailableBytes() { - const desired = this.pendingEnsureAvailableDesiredBytes; - return desired.length ? Math.max(...desired) : 0; - } -} +import { data } from 'dcmjs'; +import { recordLivelock } from '../../util/livelockRegistry.mjs'; + +const { ReadBufferStream } = data; + +/** Default: no livelock detection. Set to e.g. 15000 to log after 15s waiting. */ +const DEFAULT_LIVELOCK_DETECT_MS = 0; + +/** + * ReadBufferStream subclass that tracks pending ensureAvailable calls and + * exposes current stream size for use in multipartStream (e.g. back-pressure). + * + * Tracking is implemented here so dcmjs BufferStream.js is not modified. + * + * - currentStreamSize: bytes written to the stream so far (same as .size). + * - pendingEnsureAvailableDesiredBytes: byte counts that readers are currently + * awaiting (from ensureAvailable()). + * - maxPendingEnsureAvailableBytes: max of the above; bytes beyond the current + * offset that are being awaited by any pending ensureAvailable. + * + * Optional livelock detection: set options.livelockDetectMs (or env + * TRACKABLE_STREAM_LIVELOCK_DETECT_MS) to a positive number. If an + * ensureAvailable() promise is still pending after that many ms, a warning + * is logged with the stack trace captured at the time of the call (to help + * find where the reader is stuck). + * + * Backpressure options (for shouldPause / waitForBackPressure): + * - backpressureMaxBytes: max bytes beyond offset before pausing (default 512kb). + * - streamWritePromiseTracker: { getUnsettledCount(), limitUnsettled(max, timeoutMs) }. + * - streamWriteLimit: max unsettled stream writes before pausing. + * - backpressureWaitMs: time to wait when size is too big (ms). + * - backPressureTimeoutMs: timeout for limitUnsettled (ms). + */ +export class TrackableReadBufferStream extends ReadBufferStream { + constructor(buffer, littleEndian, options = {}) { + super(buffer, littleEndian, options); + /** @type {{ bytes: number }[]} */ + this._pendingEnsureAvailableEntries = []; + const envMs = parseInt( + typeof process !== 'undefined' && process.env.TRACKABLE_STREAM_LIVELOCK_DETECT_MS, + 10 + ); + this._livelockDetectMs = + options.livelockDetectMs ?? (Number.isFinite(envMs) ? envMs : DEFAULT_LIVELOCK_DETECT_MS); + + this._backpressureMaxBytes = options.backpressureMaxBytes ?? 128 * 1024; + this._streamWritePromiseTracker = options.streamWritePromiseTracker ?? null; + this._streamWriteLimit = options.streamWriteLimit ?? 25; + this._backpressureWaitMs = options.backpressureWaitMs ?? 1000; + this._backPressureTimeoutMs = options.backPressureTimeoutMs ?? 5000; + /** Set when the request is aborted (e.g. timeout, client disconnect). instanceFromStream uses this to throw/return as aborted. */ + this._abortedReason = null; + /** Promise rejected when setAborted() is called; used so ensureAvailable() rejects instead of resolving when request is killed. */ + this._abortPromise = new Promise((_, rej) => { + this._abortReject = rej; + }); + + // Log tracker ID for debugging + if (this._streamWritePromiseTracker) { + console.verbose( + `[TrackableReadBufferStream] created with tracker ${this._streamWritePromiseTracker.getTrackerId()}` + ); + } + } + + /** + * Mark the stream as aborted (e.g. STOW request timeout or client disconnect). + * Sets abortedReason and marks the stream complete so readers can finish and treat the result as aborted. + * @param {Error} [err] - Reason for abort + */ + setAborted(err) { + this._abortedReason = err ?? new Error('Request aborted'); + if (this._abortReject) { + this._abortReject(this._abortedReason); + this._abortReject = null; + } + this.setComplete(); + } + + /** + * If set, the request was aborted; instanceFromStream should throw/return with aborted semantics. + * @returns {Error|null} + */ + get abortedReason() { + return this._abortedReason ?? null; + } + + /** + * Returns true if the read-from-stream / write-to-buffer path should pause. + * True when bytes beyond offset exceeds the size threshold (max of backpressureMaxBytes + * and maxPendingEnsureAvailableBytes), or when stream write promise count exceeds streamWriteLimit. + * @returns {boolean} + */ + shouldPause() { + const bytesBeyondOffset = this.currentStreamSize - this.offset; + const sizeThreshold = Math.max(this._backpressureMaxBytes, this.maxPendingEnsureAvailableBytes); + const tracker = this._streamWritePromiseTracker; + if (bytesBeyondOffset > sizeThreshold) { + console.verbose( + '[TrackableReadBufferStream] shouldPause', + bytesBeyondOffset - sizeThreshold, + Math.floor(this.offset / 1024), + 'kb, tracker:', + tracker?.getTrackerId(), + 'writes:', + tracker?.getUnsettledCount(), + tracker?.getSettledCount() + ); + return true; + } + if (tracker && this._streamWriteLimit) { + const unsettled = tracker.getUnsettledCount(); + if (unsettled > this._streamWriteLimit) { + const settled = tracker.getSettledCount(); + console.verbose( + `[TrackableReadBufferStream] shouldPause: true (streamWrite unsettled=${unsettled} > limit=${this._streamWriteLimit}, settled=${settled})` + ); + return true; + } + } + return false; + } + + /** + * Waits for backpressure to ease: if size is too big, waits configured time + * or until stream write count drops (whichever first); if stream writes are + * too many, waits until limitUnsettled. If both conditions held, waits the + * configured time then waits for limitUnsettled. + * @returns {Promise} + */ + async waitForBackPressure() { + const bytesBeyondOffset = this.currentStreamSize - this.offset; + const sizeThreshold = Math.max(this._backpressureMaxBytes, this.maxPendingEnsureAvailableBytes); + const sizeTooBig = bytesBeyondOffset > sizeThreshold; + const tracker = this._streamWritePromiseTracker; + const limit = this._streamWriteLimit; + + const waitMs = this._backpressureWaitMs; + const timeoutMs = this._backPressureTimeoutMs; + const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); + + if (tracker?.getUnsettledCount() > limit) { + console.verbose( + '[TrackableReadBufferStream] waitForBackPressure: tracker', + tracker.getTrackerId(), + 'unsettled', + tracker.getUnsettledCount(), + 'settled', + tracker.getSettledCount(), + 'limit', + limit, + 'timeoutMs', + waitMs * 10 + ); + await tracker.limitUnsettled(limit, waitMs * 10); + return; + } + if (sizeTooBig) { + console.verbose('[TrackableReadBufferStream] waitForBackPressure: sizeTooBig'); + await sleep(waitMs); + } + } + + /** + * Override to track desired bytes for each pending ensureAvailable call. + * Optionally logs a livelock warning with stack if the wait exceeds livelockDetectMs. + * @param {number} [bytes=1024] + * @returns {boolean | Promise} + */ + ensureAvailable(bytes = 1024) { + if (this._abortedReason) { + return Promise.reject(this._abortedReason); + } + if (this.isAvailable(bytes)) return true; + const entry = { bytes }; + this._pendingEnsureAvailableEntries.push(entry); + let stackCapture = null; + if (this._livelockDetectMs > 0) { + const err = new Error('[TrackableReadBufferStream ensureAvailable]'); + stackCapture = err.stack || String(err); + } + const result = super.ensureAvailable(bytes); + if (result && typeof result.then === 'function') { + const raced = Promise.race([result, this._abortPromise]); + const removeEntry = () => { + const i = this._pendingEnsureAvailableEntries.indexOf(entry); + if (i !== -1) this._pendingEnsureAvailableEntries.splice(i, 1); + }; + const livelockMs = this._livelockDetectMs; + if (livelockMs > 0 && stackCapture) { + const timer = setTimeout(() => { + const idx = this._pendingEnsureAvailableEntries.indexOf(entry); + if (idx !== -1) { + console.warn( + `[TrackableReadBufferStream] Possible livelock: ensureAvailable(${bytes}) still pending after ${livelockMs}ms. Stream: offset=${this.offset} endOffset=${this.endOffset} isComplete=${this.isComplete}. Call stack at ensureAvailable:` + ); + process.stderr.write(stackCapture + '\n'); + recordLivelock({ + bytes, + offset: this.offset, + endOffset: this.endOffset, + isComplete: this.isComplete, + livelockDetectMs: livelockMs, + stack: stackCapture, + }); + } + }, livelockMs); + return raced.then( + r => { + clearTimeout(timer); + removeEntry(); + return r; + }, + e => { + clearTimeout(timer); + removeEntry(); + throw e; + } + ); + } + return raced.then( + r => { + removeEntry(); + return r; + }, + e => { + removeEntry(); + throw e; + } + ); + } + const i = this._pendingEnsureAvailableEntries.indexOf(entry); + if (i !== -1) this._pendingEnsureAvailableEntries.splice(i, 1); + return result; + } + + /** + * Current total bytes in the stream (updated as addBuffer is called). + * @returns {number} + */ + get currentStreamSize() { + return this.size; + } + + /** + * Byte counts that readers are currently awaiting (from ensureAvailable()). + * @returns {number[]} + */ + get pendingEnsureAvailableDesiredBytes() { + return this._pendingEnsureAvailableEntries.map(e => e.bytes); + } + + /** + * Number of bytes beyond the current offset that are being awaited by + * pending ensureAvailable call(s). Equal to the maximum of + * pendingEnsureAvailableDesiredBytes, or 0 if none are pending. + * @returns {number} + */ + get maxPendingEnsureAvailableBytes() { + const desired = this.pendingEnsureAvailableDesiredBytes; + return desired.length ? Math.max(...desired) : 0; + } +} diff --git a/packages/static-wado-webserver/lib/controllers/server/deleteStaleSummaries.mjs b/packages/static-wado-webserver/lib/controllers/server/deleteStaleSummaries.mjs index 3394968a..ccc83a67 100644 --- a/packages/static-wado-webserver/lib/controllers/server/deleteStaleSummaries.mjs +++ b/packages/static-wado-webserver/lib/controllers/server/deleteStaleSummaries.mjs @@ -1,61 +1,61 @@ -import path from 'path'; -import { FileDicomWebReader, FileDicomWebWriter } from '@radicalimaging/create-dicomweb'; - -/** - * Deletes series-level and study-level summary/index files for the given - * studies and series so they can be regenerated (e.g. by updateSeries/updateStudy - * or on-demand). Uses DicomWebReader for path construction and DicomWebWriter.delete - * for removal (FileDicomWebWriter). Supports multiple study UIDs in a single call. - * - * Per series (for each studyUID + seriesUID): - * - Series metadata: studies/{studyUID}/series/{seriesUID}/metadata[.gz] - * - Single series information: studies/{studyUID}/series/{seriesUID}/series-singleton.json[.gz] - * - * Per study (once per studyUID): - * - Study-level series index: studies/{studyUID}/series/index.json[.gz] - * - Study-level information: studies/{studyUID}/index.json[.gz] - * - * @param {string} dicomdir - DICOMweb root directory (absolute or relative) - * @param {Map} seriesMap - Map of seriesId -> info, where seriesId is "studyUID&seriesUID" - */ -export function deleteStaleSummaries(dicomdir, seriesMap) { - if (!dicomdir || !seriesMap || seriesMap.size === 0) { - return; - } - - const baseDir = path.resolve(dicomdir); - const reader = new FileDicomWebReader(baseDir); - const writer = new FileDicomWebWriter({}, { baseDir }); - - const studyUIDs = new Set(); - const seriesByStudy = new Map(); // studyUID -> Set of seriesUID - - for (const seriesId of seriesMap.keys()) { - const parts = seriesId.split('&'); - const studyUID = parts[0]; - const seriesUID = parts[1]; - if (!studyUID || !seriesUID) continue; - studyUIDs.add(studyUID); - if (!seriesByStudy.has(studyUID)) { - seriesByStudy.set(studyUID, new Set()); - } - seriesByStudy.get(studyUID).add(seriesUID); - } - - // Delete per-series files for each (studyUID, seriesUID) using reader paths and writer.delete - for (const [studyUID, seriesUIDSet] of seriesByStudy) { - for (const seriesUID of seriesUIDSet) { - const seriesRelativePath = reader.getSeriesPath(studyUID, seriesUID); - writer.delete(seriesRelativePath, 'metadata'); - writer.delete(seriesRelativePath, 'series-singleton.json'); - } - } - - // Delete study-level files for each studyUID (series index + study singleton) - for (const studyUID of studyUIDs) { - const seriesIndexRelativePath = reader.getStudyPath(studyUID, { path: 'series' }); - writer.delete(seriesIndexRelativePath, 'index.json'); - const studyRelativePath = reader.getStudyPath(studyUID); - writer.delete(studyRelativePath, 'index.json'); - } -} +import path from 'path'; +import { FileDicomWebReader, FileDicomWebWriter } from '@radicalimaging/create-dicomweb'; + +/** + * Deletes series-level and study-level summary/index files for the given + * studies and series so they can be regenerated (e.g. by updateSeries/updateStudy + * or on-demand). Uses DicomWebReader for path construction and DicomWebWriter.delete + * for removal (FileDicomWebWriter). Supports multiple study UIDs in a single call. + * + * Per series (for each studyUID + seriesUID): + * - Series metadata: studies/{studyUID}/series/{seriesUID}/metadata[.gz] + * - Single series information: studies/{studyUID}/series/{seriesUID}/series-singleton.json[.gz] + * + * Per study (once per studyUID): + * - Study-level series index: studies/{studyUID}/series/index.json[.gz] + * - Study-level information: studies/{studyUID}/index.json[.gz] + * + * @param {string} dicomdir - DICOMweb root directory (absolute or relative) + * @param {Map} seriesMap - Map of seriesId -> info, where seriesId is "studyUID&seriesUID" + */ +export function deleteStaleSummaries(dicomdir, seriesMap) { + if (!dicomdir || !seriesMap || seriesMap.size === 0) { + return; + } + + const baseDir = path.resolve(dicomdir); + const reader = new FileDicomWebReader(baseDir); + const writer = new FileDicomWebWriter({}, { baseDir }); + + const studyUIDs = new Set(); + const seriesByStudy = new Map(); // studyUID -> Set of seriesUID + + for (const seriesId of seriesMap.keys()) { + const parts = seriesId.split('&'); + const studyUID = parts[0]; + const seriesUID = parts[1]; + if (!studyUID || !seriesUID) continue; + studyUIDs.add(studyUID); + if (!seriesByStudy.has(studyUID)) { + seriesByStudy.set(studyUID, new Set()); + } + seriesByStudy.get(studyUID).add(seriesUID); + } + + // Delete per-series files for each (studyUID, seriesUID) using reader paths and writer.delete + for (const [studyUID, seriesUIDSet] of seriesByStudy) { + for (const seriesUID of seriesUIDSet) { + const seriesRelativePath = reader.getSeriesPath(studyUID, seriesUID); + writer.delete(seriesRelativePath, 'metadata'); + writer.delete(seriesRelativePath, 'series-singleton.json'); + } + } + + // Delete study-level files for each studyUID (series index + study singleton) + for (const studyUID of studyUIDs) { + const seriesIndexRelativePath = reader.getStudyPath(studyUID, { path: 'series' }); + writer.delete(seriesIndexRelativePath, 'index.json'); + const studyRelativePath = reader.getStudyPath(studyUID); + writer.delete(studyRelativePath, 'index.json'); + } +} diff --git a/packages/static-wado-webserver/lib/controllers/server/seriesMetadataQueryController.mjs b/packages/static-wado-webserver/lib/controllers/server/seriesMetadataQueryController.mjs index a5464a29..ad913877 100644 --- a/packages/static-wado-webserver/lib/controllers/server/seriesMetadataQueryController.mjs +++ b/packages/static-wado-webserver/lib/controllers/server/seriesMetadataQueryController.mjs @@ -1,40 +1,40 @@ -import fs from 'fs'; -import { handleHomeRelative } from '@radicalimaging/static-wado-util'; -import { seriesMain } from '@radicalimaging/create-dicomweb'; -import { otherJsonMap } from '../../adapters/requestAdapters.mjs'; - -/** - * seriesMetadataQueryController: for GET /studies/:studyUID/series/:seriesUID/metadata. - * When the series metadata file is missing, calls seriesMain for that series to create it - * on demand, then invokes otherJsonMap. - * - * @param {string} dir - Static files directory path (DICOMweb root) - * @param {object} params - Server params (rootDir, createIndexOnDemand) - * @returns {function} Express middleware (req, res, next) - */ -export function seriesMetadataQueryController(dir, params = {}) { - return async function seriesMetadataQueryControllerHandler(req, res, next) { - const root = handleHomeRelative(dir ?? params.rootDir); - if (!root) { - return otherJsonMap(req, res, next); - } - const createOnDemand = params.createIndexOnDemand !== false; - const studyUID = req.params.studyUID; - const seriesUID = req.params.seriesUID; - - const metadataPath = `${root}${req.staticWadoPath}.gz`; - - try { - if (createOnDemand && !fs.existsSync(metadataPath)) { - await seriesMain(studyUID, { dicomdir: root, seriesUid: seriesUID }); - } - } catch (err) { - console.error( - 'seriesMetadataQuery: failed to create series metadata:', - err?.message || err - ); - return next(err); - } - otherJsonMap(req, res, next); - }; -} +import fs from 'fs'; +import { handleHomeRelative } from '@radicalimaging/static-wado-util'; +import { seriesMain } from '@radicalimaging/create-dicomweb'; +import { otherJsonMap } from '../../adapters/requestAdapters.mjs'; + +/** + * seriesMetadataQueryController: for GET /studies/:studyUID/series/:seriesUID/metadata. + * When the series metadata file is missing, calls seriesMain for that series to create it + * on demand, then invokes otherJsonMap. + * + * @param {string} dir - Static files directory path (DICOMweb root) + * @param {object} params - Server params (rootDir, createIndexOnDemand) + * @returns {function} Express middleware (req, res, next) + */ +export function seriesMetadataQueryController(dir, params = {}) { + return async function seriesMetadataQueryControllerHandler(req, res, next) { + const root = handleHomeRelative(dir ?? params.rootDir); + if (!root) { + return otherJsonMap(req, res, next); + } + const createOnDemand = params.createIndexOnDemand !== false; + const studyUID = req.params.studyUID; + const seriesUID = req.params.seriesUID; + + const metadataPath = `${root}${req.staticWadoPath}.gz`; + + try { + if (createOnDemand && !fs.existsSync(metadataPath)) { + await seriesMain(studyUID, { dicomdir: root, seriesUid: seriesUID }); + } + } catch (err) { + console.error( + 'seriesMetadataQuery: failed to create series metadata:', + err?.message || err + ); + return next(err); + } + otherJsonMap(req, res, next); + }; +} diff --git a/packages/static-wado-webserver/lib/controllers/server/seriesQueryController.mjs b/packages/static-wado-webserver/lib/controllers/server/seriesQueryController.mjs index a3d1e6b3..80fd7175 100644 --- a/packages/static-wado-webserver/lib/controllers/server/seriesQueryController.mjs +++ b/packages/static-wado-webserver/lib/controllers/server/seriesQueryController.mjs @@ -1,47 +1,47 @@ -import fs from 'fs'; -import { handleHomeRelative } from '@radicalimaging/static-wado-util'; -import { seriesMain } from '@radicalimaging/create-dicomweb'; -import { seriesSingleMap, getDicomKey } from '../../adapters/requestAdapters.mjs'; - -/** - * seriesQueryController: for GET /studies/:studyUID/series. If SeriesInstanceUID - * is in the query, calls seriesMain for that series when its index is missing; - * otherwise ensures the study-level series index exists via seriesMain, then - * invokes seriesSingleMap. - * - * @param {string} dir - Static files directory path (DICOMweb root) - * @param {object} params - Server params (rootDir, createIndexOnDemand) - * @returns {function} Express middleware (req, res, next) - */ -export function seriesQueryController(dir, params = {}) { - return async function seriesQueryControllerHandler(req, res, next) { - const root = handleHomeRelative(dir ?? params.rootDir); - if (!root) { - return seriesSingleMap(req, res, next); - } - const createOnDemand = params.createIndexOnDemand !== false; - const studyUID = req.params.studyUID; - const seriesUIDFromQuery = getDicomKey('0020000e', 'seriesinstanceuid', req.query); - - try { - if (seriesUIDFromQuery) { - const singleSeriesPath = `${root}${req.staticWadoPath}/${seriesUIDFromQuery}/series-singleton.json.gz`; - if (createOnDemand && !fs.existsSync(singleSeriesPath)) { - await seriesMain(studyUID, { dicomdir: root, seriesUid: seriesUIDFromQuery }); - } - } else { - const studySeriesIndexPath = `${root}${req.staticWadoPath}/index.json.gz`; - if (createOnDemand && !fs.existsSync(studySeriesIndexPath)) { - await seriesMain(studyUID, { dicomdir: root }); - } - } - } catch (err) { - console.error( - 'indexOnDemand: failed to create series index:', - err?.message || err - ); - return next(err); - } - seriesSingleMap(req, res, next); - }; -} +import fs from 'fs'; +import { handleHomeRelative } from '@radicalimaging/static-wado-util'; +import { seriesMain } from '@radicalimaging/create-dicomweb'; +import { seriesSingleMap, getDicomKey } from '../../adapters/requestAdapters.mjs'; + +/** + * seriesQueryController: for GET /studies/:studyUID/series. If SeriesInstanceUID + * is in the query, calls seriesMain for that series when its index is missing; + * otherwise ensures the study-level series index exists via seriesMain, then + * invokes seriesSingleMap. + * + * @param {string} dir - Static files directory path (DICOMweb root) + * @param {object} params - Server params (rootDir, createIndexOnDemand) + * @returns {function} Express middleware (req, res, next) + */ +export function seriesQueryController(dir, params = {}) { + return async function seriesQueryControllerHandler(req, res, next) { + const root = handleHomeRelative(dir ?? params.rootDir); + if (!root) { + return seriesSingleMap(req, res, next); + } + const createOnDemand = params.createIndexOnDemand !== false; + const studyUID = req.params.studyUID; + const seriesUIDFromQuery = getDicomKey('0020000e', 'seriesinstanceuid', req.query); + + try { + if (seriesUIDFromQuery) { + const singleSeriesPath = `${root}${req.staticWadoPath}/${seriesUIDFromQuery}/series-singleton.json.gz`; + if (createOnDemand && !fs.existsSync(singleSeriesPath)) { + await seriesMain(studyUID, { dicomdir: root, seriesUid: seriesUIDFromQuery }); + } + } else { + const studySeriesIndexPath = `${root}${req.staticWadoPath}/index.json.gz`; + if (createOnDemand && !fs.existsSync(studySeriesIndexPath)) { + await seriesMain(studyUID, { dicomdir: root }); + } + } + } catch (err) { + console.error( + 'indexOnDemand: failed to create series index:', + err?.message || err + ); + return next(err); + } + seriesSingleMap(req, res, next); + }; +} diff --git a/packages/static-wado-webserver/lib/controllers/server/statusController.mjs b/packages/static-wado-webserver/lib/controllers/server/statusController.mjs index 2b9585f4..174ed3b0 100644 --- a/packages/static-wado-webserver/lib/controllers/server/statusController.mjs +++ b/packages/static-wado-webserver/lib/controllers/server/statusController.mjs @@ -1,56 +1,56 @@ -import { StatusMonitor } from '@radicalimaging/static-wado-util'; -import { getLivelockConfig, getLivelockReports } from '../../util/livelockRegistry.mjs'; - -/** - * Build status payload (used by GET /status and by --show-status console dump). - * @param {{ detailed?: boolean, includeLivelock?: boolean }} options - * @returns {object} - */ -export function getStatusPayload(options = {}) { - const { detailed = false, includeLivelock = false } = options; - const summary = StatusMonitor.getSummary(); - const ongoingJobs = StatusMonitor.getOngoingJobs(); - const ongoingMax = - ongoingJobs.length > 0 - ? { - longestSecondsSinceProgress: Math.max( - ...ongoingJobs.map(j => j.data.secondsSinceProgress ?? 0) - ), - longestSecondsSinceStarted: Math.max( - ...ongoingJobs.map(j => j.data.secondsSinceStarted ?? 0) - ), - } - : null; - - return { - ...summary, - ...(ongoingMax ? ongoingMax : {}), - ...(detailed ? { ongoingJobs } : {}), - ...(includeLivelock - ? { - livelock: { - ...getLivelockConfig(), - reports: getLivelockReports(), - }, - } - : {}), - }; -} - -/** - * GET /status (under server path, e.g. /dicomweb/status). - * Returns JSON summary of job counts by type. - * Query: ?detailed - include all ongoing jobs with per-job data (pretty-printed). - * Query: ?livelock=true - include livelock config and reports in response (does not enable/disable detection). - * - * @param {object} req - * @param {object} res - */ -export function statusController(req, res) { - const detailed = req.query?.detailed !== undefined && req.query?.detailed !== ''; - const includeLivelock = req.query?.livelock === 'true' || req.query?.livelock === '1'; - const payload = getStatusPayload({ detailed, includeLivelock }); - - res.setHeader('Content-Type', 'application/json'); - res.send(JSON.stringify(payload, null, 2)); -} +import { StatusMonitor } from '@radicalimaging/static-wado-util'; +import { getLivelockConfig, getLivelockReports } from '../../util/livelockRegistry.mjs'; + +/** + * Build status payload (used by GET /status and by --show-status console dump). + * @param {{ detailed?: boolean, includeLivelock?: boolean }} options + * @returns {object} + */ +export function getStatusPayload(options = {}) { + const { detailed = false, includeLivelock = false } = options; + const summary = StatusMonitor.getSummary(); + const ongoingJobs = StatusMonitor.getOngoingJobs(); + const ongoingMax = + ongoingJobs.length > 0 + ? { + longestSecondsSinceProgress: Math.max( + ...ongoingJobs.map(j => j.data.secondsSinceProgress ?? 0) + ), + longestSecondsSinceStarted: Math.max( + ...ongoingJobs.map(j => j.data.secondsSinceStarted ?? 0) + ), + } + : null; + + return { + ...summary, + ...(ongoingMax ? ongoingMax : {}), + ...(detailed ? { ongoingJobs } : {}), + ...(includeLivelock + ? { + livelock: { + ...getLivelockConfig(), + reports: getLivelockReports(), + }, + } + : {}), + }; +} + +/** + * GET /status (under server path, e.g. /dicomweb/status). + * Returns JSON summary of job counts by type. + * Query: ?detailed - include all ongoing jobs with per-job data (pretty-printed). + * Query: ?livelock=true - include livelock config and reports in response (does not enable/disable detection). + * + * @param {object} req + * @param {object} res + */ +export function statusController(req, res) { + const detailed = req.query?.detailed !== undefined && req.query?.detailed !== ''; + const includeLivelock = req.query?.livelock === 'true' || req.query?.livelock === '1'; + const payload = getStatusPayload({ detailed, includeLivelock }); + + res.setHeader('Content-Type', 'application/json'); + res.send(JSON.stringify(payload, null, 2)); +} diff --git a/packages/static-wado-webserver/lib/services/util/serverSpawn.mjs b/packages/static-wado-webserver/lib/services/util/serverSpawn.mjs index 98fab069..b13944a2 100644 --- a/packages/static-wado-webserver/lib/services/util/serverSpawn.mjs +++ b/packages/static-wado-webserver/lib/services/util/serverSpawn.mjs @@ -1,143 +1,143 @@ -import path from 'path'; -import childProcess from 'child_process'; -import { - extractMultipart, - uint8ArrayToString, - execSpawn, - handleHomeRelative, -} from '@radicalimaging/static-wado-util'; - -const queue = []; -const runners = []; - -export function ensureRunners(count = Math.max(3, runners.length)) { - for (let i = 0; i < count; i++) { - if (!queue.length) { - console.verbose('Nothing in queue'); - return; - } - if (!runners[i] || runners[i].terminated) { - if (runners[i]) { - console.noQuiet('Recreating runner', i); - } - runners[i] = createRunner(); - } - if (runners[i].available) { - runners[i].run(); - } else { - console.verbose('Runner', i, 'not available'); - } - } -} - -const executablePath = process.argv[1]; -const bunExecPath = path.join( - path.dirname(executablePath), - '..', - '..', - 'static-wado-creator', - 'bin', - 'mkdicomweb.mjs' -); - -const cmd = ['bun', 'run', bunExecPath, 'server', '--quiet']; - -let count = 0; - -export function createRunner() { - const child = childProcess.spawn(cmd.join(' '), { - shell: true, - stdio: ['overlapped', 'overlapped', 'inherit'], - }); - const runner = { - available: true, - inputData: [], - child, - json: null, - count: count++, - terminated: false, - processing: false, - run: function () { - if (!queue.length) { - console.verbose('Queue empty, returning'); - return; - } - this.available = false; - this.processing = queue.splice(0, 1)[0]; - this.processing.startTime = performance.now(); - const { cmdLine } = runner.processing; - const cmd = Array.isArray(cmdLine) ? cmdLine.join(' ') : cmdLine; - console.verbose('Starting to process', cmd); - this.processing.cmd = cmd; - this.child.stdin.write(`${cmd}\n`); - }, - }; - - child.stdout.on('data', data => { - if (!runner.processing) { - return; - } - runner.inputData.push(data); - console.verbose(`${runner.count}.${runner.inputData.length}>`, String(data)); - const resultStr = runner.inputData.join(''); - if (resultStr.indexOf('mkdicomweb server -->') !== -1) { - runner.processing.endTime = performance.now(); - try { - let json = null; - if (runner.processing.options?.parseResults !== false) { - const jsonResponse = extractMultipart('multipart/related', resultStr); - const jsonStr = uint8ArrayToString(jsonResponse.pixelData).trim(); - json = JSON.parse(jsonStr); - runner.json = json; - } - runner.processing.resolve(json); - } catch (e) { - runner.processing.reject(new Error(`Unable to find JSON results in ${resultStr}`)); - } - const { queueTime, startTime, endTime, cmd } = runner.processing; - console.noQuiet( - 'Task done queue time', - startTime - queueTime, - 'exec time', - endTime - startTime, - cmd - ); - runner.inputData = []; - runner.processing = null; - runner.available = true; - - if (queue.length && !runner.terminated) { - runner.run(); - } - } - }); - child.on('close', code => { - runner.terminated = true; - console.noQuiet('Runner terminated'); - if (runner.processing) { - console.warn( - 'Runner terminated processing', - runner.processing.cmdLine, - runner.inputData.join('\n') - ); - runner.processing.reject(new Error('Unknown failure ' + runner.inputData.join(''))); - ensureRunners(); - } - }); - - return runner; -} - -export function mkdicomwebSpawn(cmdLine, options = { runners: 3, parseResults: true }) { - const promise = new Promise((resolve, reject) => { - queue.push({ - resolve, - reject, - cmdLine, - options, - queueTime: performance.now(), - }); - }); - ensureRunners(options?.runners ?? 3); - return promise; -} +import path from 'path'; +import childProcess from 'child_process'; +import { + extractMultipart, + uint8ArrayToString, + execSpawn, + handleHomeRelative, +} from '@radicalimaging/static-wado-util'; + +const queue = []; +const runners = []; + +export function ensureRunners(count = Math.max(3, runners.length)) { + for (let i = 0; i < count; i++) { + if (!queue.length) { + console.verbose('Nothing in queue'); + return; + } + if (!runners[i] || runners[i].terminated) { + if (runners[i]) { + console.noQuiet('Recreating runner', i); + } + runners[i] = createRunner(); + } + if (runners[i].available) { + runners[i].run(); + } else { + console.verbose('Runner', i, 'not available'); + } + } +} + +const executablePath = process.argv[1]; +const bunExecPath = path.join( + path.dirname(executablePath), + '..', + '..', + 'static-wado-creator', + 'bin', + 'mkdicomweb.mjs' +); + +const cmd = ['bun', 'run', bunExecPath, 'server', '--quiet']; + +let count = 0; + +export function createRunner() { + const child = childProcess.spawn(cmd.join(' '), { + shell: true, + stdio: ['overlapped', 'overlapped', 'inherit'], + }); + const runner = { + available: true, + inputData: [], + child, + json: null, + count: count++, + terminated: false, + processing: false, + run: function () { + if (!queue.length) { + console.verbose('Queue empty, returning'); + return; + } + this.available = false; + this.processing = queue.splice(0, 1)[0]; + this.processing.startTime = performance.now(); + const { cmdLine } = runner.processing; + const cmd = Array.isArray(cmdLine) ? cmdLine.join(' ') : cmdLine; + console.verbose('Starting to process', cmd); + this.processing.cmd = cmd; + this.child.stdin.write(`${cmd}\n`); + }, + }; + + child.stdout.on('data', data => { + if (!runner.processing) { + return; + } + runner.inputData.push(data); + console.verbose(`${runner.count}.${runner.inputData.length}>`, String(data)); + const resultStr = runner.inputData.join(''); + if (resultStr.indexOf('mkdicomweb server -->') !== -1) { + runner.processing.endTime = performance.now(); + try { + let json = null; + if (runner.processing.options?.parseResults !== false) { + const jsonResponse = extractMultipart('multipart/related', resultStr); + const jsonStr = uint8ArrayToString(jsonResponse.pixelData).trim(); + json = JSON.parse(jsonStr); + runner.json = json; + } + runner.processing.resolve(json); + } catch (e) { + runner.processing.reject(new Error(`Unable to find JSON results in ${resultStr}`)); + } + const { queueTime, startTime, endTime, cmd } = runner.processing; + console.noQuiet( + 'Task done queue time', + startTime - queueTime, + 'exec time', + endTime - startTime, + cmd + ); + runner.inputData = []; + runner.processing = null; + runner.available = true; + + if (queue.length && !runner.terminated) { + runner.run(); + } + } + }); + child.on('close', code => { + runner.terminated = true; + console.noQuiet('Runner terminated'); + if (runner.processing) { + console.warn( + 'Runner terminated processing', + runner.processing.cmdLine, + runner.inputData.join('\n') + ); + runner.processing.reject(new Error('Unknown failure ' + runner.inputData.join(''))); + ensureRunners(); + } + }); + + return runner; +} + +export function mkdicomwebSpawn(cmdLine, options = { runners: 3, parseResults: true }) { + const promise = new Promise((resolve, reject) => { + queue.push({ + resolve, + reject, + cmdLine, + options, + queueTime: performance.now(), + }); + }); + ensureRunners(options?.runners ?? 3); + return promise; +} diff --git a/packages/static-wado-webserver/lib/util/asyncStackDump.mjs b/packages/static-wado-webserver/lib/util/asyncStackDump.mjs index 0612fdbb..2ca88454 100644 --- a/packages/static-wado-webserver/lib/util/asyncStackDump.mjs +++ b/packages/static-wado-webserver/lib/util/asyncStackDump.mjs @@ -1,189 +1,189 @@ -/** - * Async stack dump utility for diagnosing livelock/hangs in Bun (or Node). - * - * Use when STOW uploads with AsyncDicomReader appear to livelock: get stack traces - * of what is currently running so you can see which promise/loop is stuck. - * - * Setup: require or import this module early in your server process (e.g. in - * program/index.mjs or the main entry before starting the server). - * - * Usage: - * 1. Interrupt now (single dump): - * - Unix: kill -USR2 - * - Windows (Bun): SIGUSR2 may not be available; use STACK_DUMP_SAMPLE_MS (see below) - * or call dumpCurrentStack() from a timer or HTTP debug endpoint. - * - * 2. Sample stacks over ~2 seconds (shows what keeps running during livelock): - * Set env before starting the server: - * - STACK_DUMP_SAMPLE_MS=200 (dump every 200ms) - * - STACK_DUMP_SAMPLE_DURATION_MS=2000 (optional, default 2000) - * Then when livelock occurs, send SIGUSR2 once; the sampler will run and - * print multiple stacks so you can see the repeating call path. - * - * 3. Programmatic: import { dumpCurrentStack, installStackDumpOnSignal } from '...'; - * installStackDumpOnSignal(); // then kill -USR2 - * dumpCurrentStack(); // or call from a debug route - */ - -const isWindows = typeof process !== "undefined" && process.platform === "win32"; - -/** - * Writes the current JavaScript stack trace to stderr. - * Safe to call from a signal handler (sync, minimal work). - * - * @param {string} [label] - Optional label (e.g. "SIGUSR2" or "sample 3") - */ -export function dumpCurrentStack(label = "stack") { - const err = new Error(`[asyncStackDump] ${label}`); - const stack = err.stack || String(err); - process.stderr.write(stack + "\n"); - try { - const asyncId = - typeof require !== "undefined" - ? require("node:async_hooks")?.executionAsyncId?.() - : undefined; - if (asyncId !== undefined) { - process.stderr.write(`[asyncStackDump] executionAsyncId: ${asyncId}\n`); - } - } catch (_) { - // ignore if async_hooks not available - } -} - -/** - * Installs a handler for SIGUSR2 that dumps the current stack once. - * On Windows, SIGUSR2 is often not available; the handler is only registered - * if the signal exists. - * - * @returns {() => void} - Uninstall function - */ -export function installStackDumpOnSignal() { - const handler = () => { - dumpCurrentStack("SIGUSR2"); - }; - - if (typeof process.on === "function") { - try { - process.on("SIGUSR2", handler); - process.stderr.write( - "[asyncStackDump] SIGUSR2 handler installed. Use: kill -USR2 \n" - ); - } catch (e) { - process.stderr.write( - `[asyncStackDump] Could not install SIGUSR2 (e.g. on Windows): ${e?.message}\n` - ); - } - } - - return () => { - try { - process.removeListener?.("SIGUSR2", handler); - } catch (_) {} - }; -} - -/** - * Runs a sampler that dumps the current stack at a fixed interval for a duration. - * Use when livelock is suspected: the repeated dumps will show what code path - * is running repeatedly (the livelock loop). - * - * @param {object} [opts] - * @param {number} [opts.intervalMs=200] - Dump every N ms - * @param {number} [opts.durationMs=2000] - Run for N ms then stop - * @param {boolean} [opts.runOnce=false] - If true, run one dump and return (no interval) - */ -export function runStackSampler(opts = {}) { - const intervalMs = opts.intervalMs ?? 200; - const durationMs = opts.durationMs ?? 2000; - const runOnce = opts.runOnce ?? false; - - if (runOnce) { - dumpCurrentStack("once"); - return; - } - - let count = 0; - const id = setInterval(() => { - count += 1; - dumpCurrentStack(`sample ${count}`); - }, intervalMs); - - const stop = setTimeout(() => { - clearInterval(id); - process.stderr.write( - `[asyncStackDump] Sampler stopped after ${durationMs}ms (${count} dumps)\n` - ); - }, durationMs); - - process.stderr.write( - `[asyncStackDump] Sampler started: every ${intervalMs}ms for ${durationMs}ms\n` - ); - - return () => { - clearInterval(id); - clearTimeout(stop); - }; -} - -/** - * Installs SIGUSR2 handler that runs the stack sampler (multiple dumps over time) - * instead of a single dump. Useful when a single snapshot isn't enough to see the - * spinning code path. - */ -export function installStackSamplerOnSignal() { - const sampleMs = parseInt( - process.env.STACK_DUMP_SAMPLE_MS ?? "0", - 10 - ); - const durationMs = parseInt( - process.env.STACK_DUMP_SAMPLE_DURATION_MS ?? "2000", - 10 - ); - - const handler = () => { - if (sampleMs > 0) { - runStackSampler({ intervalMs: sampleMs, durationMs }); - } else { - dumpCurrentStack("SIGUSR2"); - } - }; - - if (typeof process.on === "function") { - try { - process.on("SIGUSR2", handler); - process.stderr.write( - "[asyncStackDump] SIGUSR2 handler installed. Use: kill -USR2 \n" - ); - if (sampleMs > 0) { - process.stderr.write( - `[asyncStackDump] On SIGUSR2 will run sampler: every ${sampleMs}ms for ${durationMs}ms\n` - ); - } - } catch (e) { - process.stderr.write( - `[asyncStackDump] Could not install SIGUSR2: ${e?.message}\n` - ); - } - } - - return () => { - try { - process.removeListener?.("SIGUSR2", handler); - } catch (_) {} - }; -} - -/** - * Call once at process startup to enable stack dumps on SIGUSR2. - * If STACK_DUMP_SAMPLE_MS is set, SIGUSR2 will run a multi-dump sampler. - * If STACK_DUMP_ENABLED=1, installs the handler; otherwise does nothing. - * - * @returns {() => void|undefined} - Uninstall function, or undefined if not enabled - */ -export function installFromEnv() { - const enabled = - process.env.STACK_DUMP_ENABLED === "1" || - process.env.STACK_DUMP_SAMPLE_MS !== undefined; - if (!enabled) return undefined; - return installStackSamplerOnSignal(); -} +/** + * Async stack dump utility for diagnosing livelock/hangs in Bun (or Node). + * + * Use when STOW uploads with AsyncDicomReader appear to livelock: get stack traces + * of what is currently running so you can see which promise/loop is stuck. + * + * Setup: require or import this module early in your server process (e.g. in + * program/index.mjs or the main entry before starting the server). + * + * Usage: + * 1. Interrupt now (single dump): + * - Unix: kill -USR2 + * - Windows (Bun): SIGUSR2 may not be available; use STACK_DUMP_SAMPLE_MS (see below) + * or call dumpCurrentStack() from a timer or HTTP debug endpoint. + * + * 2. Sample stacks over ~2 seconds (shows what keeps running during livelock): + * Set env before starting the server: + * - STACK_DUMP_SAMPLE_MS=200 (dump every 200ms) + * - STACK_DUMP_SAMPLE_DURATION_MS=2000 (optional, default 2000) + * Then when livelock occurs, send SIGUSR2 once; the sampler will run and + * print multiple stacks so you can see the repeating call path. + * + * 3. Programmatic: import { dumpCurrentStack, installStackDumpOnSignal } from '...'; + * installStackDumpOnSignal(); // then kill -USR2 + * dumpCurrentStack(); // or call from a debug route + */ + +const isWindows = typeof process !== "undefined" && process.platform === "win32"; + +/** + * Writes the current JavaScript stack trace to stderr. + * Safe to call from a signal handler (sync, minimal work). + * + * @param {string} [label] - Optional label (e.g. "SIGUSR2" or "sample 3") + */ +export function dumpCurrentStack(label = "stack") { + const err = new Error(`[asyncStackDump] ${label}`); + const stack = err.stack || String(err); + process.stderr.write(stack + "\n"); + try { + const asyncId = + typeof require !== "undefined" + ? require("node:async_hooks")?.executionAsyncId?.() + : undefined; + if (asyncId !== undefined) { + process.stderr.write(`[asyncStackDump] executionAsyncId: ${asyncId}\n`); + } + } catch (_) { + // ignore if async_hooks not available + } +} + +/** + * Installs a handler for SIGUSR2 that dumps the current stack once. + * On Windows, SIGUSR2 is often not available; the handler is only registered + * if the signal exists. + * + * @returns {() => void} - Uninstall function + */ +export function installStackDumpOnSignal() { + const handler = () => { + dumpCurrentStack("SIGUSR2"); + }; + + if (typeof process.on === "function") { + try { + process.on("SIGUSR2", handler); + process.stderr.write( + "[asyncStackDump] SIGUSR2 handler installed. Use: kill -USR2 \n" + ); + } catch (e) { + process.stderr.write( + `[asyncStackDump] Could not install SIGUSR2 (e.g. on Windows): ${e?.message}\n` + ); + } + } + + return () => { + try { + process.removeListener?.("SIGUSR2", handler); + } catch (_) {} + }; +} + +/** + * Runs a sampler that dumps the current stack at a fixed interval for a duration. + * Use when livelock is suspected: the repeated dumps will show what code path + * is running repeatedly (the livelock loop). + * + * @param {object} [opts] + * @param {number} [opts.intervalMs=200] - Dump every N ms + * @param {number} [opts.durationMs=2000] - Run for N ms then stop + * @param {boolean} [opts.runOnce=false] - If true, run one dump and return (no interval) + */ +export function runStackSampler(opts = {}) { + const intervalMs = opts.intervalMs ?? 200; + const durationMs = opts.durationMs ?? 2000; + const runOnce = opts.runOnce ?? false; + + if (runOnce) { + dumpCurrentStack("once"); + return; + } + + let count = 0; + const id = setInterval(() => { + count += 1; + dumpCurrentStack(`sample ${count}`); + }, intervalMs); + + const stop = setTimeout(() => { + clearInterval(id); + process.stderr.write( + `[asyncStackDump] Sampler stopped after ${durationMs}ms (${count} dumps)\n` + ); + }, durationMs); + + process.stderr.write( + `[asyncStackDump] Sampler started: every ${intervalMs}ms for ${durationMs}ms\n` + ); + + return () => { + clearInterval(id); + clearTimeout(stop); + }; +} + +/** + * Installs SIGUSR2 handler that runs the stack sampler (multiple dumps over time) + * instead of a single dump. Useful when a single snapshot isn't enough to see the + * spinning code path. + */ +export function installStackSamplerOnSignal() { + const sampleMs = parseInt( + process.env.STACK_DUMP_SAMPLE_MS ?? "0", + 10 + ); + const durationMs = parseInt( + process.env.STACK_DUMP_SAMPLE_DURATION_MS ?? "2000", + 10 + ); + + const handler = () => { + if (sampleMs > 0) { + runStackSampler({ intervalMs: sampleMs, durationMs }); + } else { + dumpCurrentStack("SIGUSR2"); + } + }; + + if (typeof process.on === "function") { + try { + process.on("SIGUSR2", handler); + process.stderr.write( + "[asyncStackDump] SIGUSR2 handler installed. Use: kill -USR2 \n" + ); + if (sampleMs > 0) { + process.stderr.write( + `[asyncStackDump] On SIGUSR2 will run sampler: every ${sampleMs}ms for ${durationMs}ms\n` + ); + } + } catch (e) { + process.stderr.write( + `[asyncStackDump] Could not install SIGUSR2: ${e?.message}\n` + ); + } + } + + return () => { + try { + process.removeListener?.("SIGUSR2", handler); + } catch (_) {} + }; +} + +/** + * Call once at process startup to enable stack dumps on SIGUSR2. + * If STACK_DUMP_SAMPLE_MS is set, SIGUSR2 will run a multi-dump sampler. + * If STACK_DUMP_ENABLED=1, installs the handler; otherwise does nothing. + * + * @returns {() => void|undefined} - Uninstall function, or undefined if not enabled + */ +export function installFromEnv() { + const enabled = + process.env.STACK_DUMP_ENABLED === "1" || + process.env.STACK_DUMP_SAMPLE_MS !== undefined; + if (!enabled) return undefined; + return installStackSamplerOnSignal(); +} diff --git a/packages/static-wado-webserver/lib/util/livelockRegistry.mjs b/packages/static-wado-webserver/lib/util/livelockRegistry.mjs index 0d0fabb6..8095c06b 100644 --- a/packages/static-wado-webserver/lib/util/livelockRegistry.mjs +++ b/packages/static-wado-webserver/lib/util/livelockRegistry.mjs @@ -1,87 +1,87 @@ -/** - * Registry of livelock detections from TrackableReadBufferStream. - * When ?livelock=true on GET /status, the response includes config and reports (read-only). - * Detection is enabled at server startup via --livelock-detect. - */ - -const MAX_REPORTS = 50; -const DEFAULT_LIVELOCK_DETECT_MS = 15000; - -const reports = []; -/** When true, TrackableReadBufferStream uses livelock detection (env or default ms). Set via --livelock-detect at startup. */ -let detectionEnabled = false; - -/** - * Get the livelock detect ms from env, or default when detection is enabled. - * @returns {number} - 0 if detection is disabled, else TRACKABLE_STREAM_LIVELOCK_DETECT_MS or DEFAULT_LIVELOCK_DETECT_MS - */ -function getEnvLivelockDetectMs() { - const envMs = parseInt( - typeof process !== 'undefined' && process.env.TRACKABLE_STREAM_LIVELOCK_DETECT_MS, - 10 - ); - return Number.isFinite(envMs) ? envMs : DEFAULT_LIVELOCK_DETECT_MS; -} - -/** - * Enable or disable livelock detection. Called at startup when --livelock-detect is passed. - * When enabling, threshold is TRACKABLE_STREAM_LIVELOCK_DETECT_MS env or 15000 ms. - * @param {boolean} enabled - */ -export function setLivelockEnabled(enabled) { - detectionEnabled = !!enabled; -} - -/** - * Returns the ms threshold to pass to TrackableReadBufferStream: 0 if detection is disabled, - * otherwise env TRACKABLE_STREAM_LIVELOCK_DETECT_MS or 15000. - * @returns {number} - */ -export function getLivelockDetectMs() { - if (!detectionEnabled) return 0; - return getEnvLivelockDetectMs(); -} - -/** - * Record a livelock detection. Called from TrackableReadBufferStream when - * ensureAvailable() is still pending after livelockDetectMs. - * - * @param {object} detail - * @param {number} detail.bytes - ensureAvailable(bytes) argument - * @param {number} detail.offset - stream offset at detection time - * @param {number} detail.endOffset - stream endOffset - * @param {boolean} detail.isComplete - stream isComplete - * @param {number} detail.livelockDetectMs - configured threshold ms - * @param {string} detail.stack - stack trace at ensureAvailable call - */ -export function recordLivelock(detail) { - const entry = { - ...detail, - detectedAt: Date.now(), - }; - reports.push(entry); - if (reports.length > MAX_REPORTS) { - reports.shift(); - } -} - -/** - * Get recent livelock reports (newest last). Does not clear them. - * @returns {Array} - */ -export function getLivelockReports() { - return [...reports]; -} - -/** - * Get livelock config for status response: enabled flag and ms threshold in use. - * @returns {{ enabled: boolean, livelockDetectMs: number }} - */ -export function getLivelockConfig() { - const enabled = detectionEnabled; - const livelockDetectMs = enabled ? getEnvLivelockDetectMs() : 0; - return { - enabled, - livelockDetectMs, - }; -} +/** + * Registry of livelock detections from TrackableReadBufferStream. + * When ?livelock=true on GET /status, the response includes config and reports (read-only). + * Detection is enabled at server startup via --livelock-detect. + */ + +const MAX_REPORTS = 50; +const DEFAULT_LIVELOCK_DETECT_MS = 15000; + +const reports = []; +/** When true, TrackableReadBufferStream uses livelock detection (env or default ms). Set via --livelock-detect at startup. */ +let detectionEnabled = false; + +/** + * Get the livelock detect ms from env, or default when detection is enabled. + * @returns {number} - 0 if detection is disabled, else TRACKABLE_STREAM_LIVELOCK_DETECT_MS or DEFAULT_LIVELOCK_DETECT_MS + */ +function getEnvLivelockDetectMs() { + const envMs = parseInt( + typeof process !== 'undefined' && process.env.TRACKABLE_STREAM_LIVELOCK_DETECT_MS, + 10 + ); + return Number.isFinite(envMs) ? envMs : DEFAULT_LIVELOCK_DETECT_MS; +} + +/** + * Enable or disable livelock detection. Called at startup when --livelock-detect is passed. + * When enabling, threshold is TRACKABLE_STREAM_LIVELOCK_DETECT_MS env or 15000 ms. + * @param {boolean} enabled + */ +export function setLivelockEnabled(enabled) { + detectionEnabled = !!enabled; +} + +/** + * Returns the ms threshold to pass to TrackableReadBufferStream: 0 if detection is disabled, + * otherwise env TRACKABLE_STREAM_LIVELOCK_DETECT_MS or 15000. + * @returns {number} + */ +export function getLivelockDetectMs() { + if (!detectionEnabled) return 0; + return getEnvLivelockDetectMs(); +} + +/** + * Record a livelock detection. Called from TrackableReadBufferStream when + * ensureAvailable() is still pending after livelockDetectMs. + * + * @param {object} detail + * @param {number} detail.bytes - ensureAvailable(bytes) argument + * @param {number} detail.offset - stream offset at detection time + * @param {number} detail.endOffset - stream endOffset + * @param {boolean} detail.isComplete - stream isComplete + * @param {number} detail.livelockDetectMs - configured threshold ms + * @param {string} detail.stack - stack trace at ensureAvailable call + */ +export function recordLivelock(detail) { + const entry = { + ...detail, + detectedAt: Date.now(), + }; + reports.push(entry); + if (reports.length > MAX_REPORTS) { + reports.shift(); + } +} + +/** + * Get recent livelock reports (newest last). Does not clear them. + * @returns {Array} + */ +export function getLivelockReports() { + return [...reports]; +} + +/** + * Get livelock config for status response: enabled flag and ms threshold in use. + * @returns {{ enabled: boolean, livelockDetectMs: number }} + */ +export function getLivelockConfig() { + const enabled = detectionEnabled; + const livelockDetectMs = enabled ? getEnvLivelockDetectMs() : 0; + return { + enabled, + livelockDetectMs, + }; +} diff --git a/packages/static-wado-webserver/lib/util/stowResponseBuilder.mjs b/packages/static-wado-webserver/lib/util/stowResponseBuilder.mjs index 2ddd2b88..8e399356 100644 --- a/packages/static-wado-webserver/lib/util/stowResponseBuilder.mjs +++ b/packages/static-wado-webserver/lib/util/stowResponseBuilder.mjs @@ -1,216 +1,216 @@ -const DEFAULT_FAILURE_REASON = 0xc000; -const DEFAULT_STUDY_PATH_TEMPLATE = '/api/studies/{studyInstanceUID}'; - -function withTrailingSlashRemoved(value) { - return `${value || ''}`.replace(/\/+$/, ''); -} - -function buildStudyRetrieveUrl(studyInstanceUID, options = {}) { - if (!studyInstanceUID) { - return null; - } - - const rootUrl = withTrailingSlashRemoved(options.rootUrl || ''); - const pathTemplate = options.studyPathTemplate || DEFAULT_STUDY_PATH_TEMPLATE; - const path = pathTemplate.replace('{studyInstanceUID}', studyInstanceUID); - - if (!rootUrl) { - return path; - } - - return `${rootUrl}${path.startsWith('/') ? '' : '/'}${path}`; -} - -function resolveRetrieveUrl(studyInstanceUID, options = {}, instance = null) { - if (typeof options.retrieveUrlBuilder === 'function') { - return options.retrieveUrlBuilder(studyInstanceUID, instance); - } - return buildStudyRetrieveUrl(studyInstanceUID, options); -} - -function mapSuccessSequenceItem(instance, options = {}) { - const item = {}; - if (instance?.sopClassUID) { - item['00081150'] = { - vr: 'UI', - Value: [instance.sopClassUID], - }; - } - if (instance?.sopInstanceUID) { - item['00081155'] = { - vr: 'UI', - Value: [instance.sopInstanceUID], - }; - } - - const retrieveUrl = resolveRetrieveUrl(instance?.studyInstanceUID, options, instance); - if (retrieveUrl) { - item['00081190'] = { - vr: 'UR', - Value: [retrieveUrl], - }; - } - - return item; -} - -function mapFailedSequenceItem(instance, options = {}) { - const item = {}; - if (instance?.sopClassUID) { - item['00081150'] = { - vr: 'UI', - Value: [instance.sopClassUID], - }; - } - if (instance?.sopInstanceUID) { - item['00081155'] = { - vr: 'UI', - Value: [instance.sopInstanceUID], - }; - } - item['00081197'] = { - vr: 'US', - Value: [instance?.failureReason ?? options.defaultFailureReason ?? DEFAULT_FAILURE_REASON], - }; - - const errorComment = - instance?.errorComment || - (Array.isArray(instance?.failureDetails) ? instance.failureDetails.join(', ') : null); - if (errorComment) { - item['00000902'] = { - vr: 'LO', - Value: [errorComment], - }; - } - - return item; -} - -/** - * Build a STOW-RS dataset from authorized/failed instance lists. - * Options can override status codes and RetrieveURL generation: - * - rootUrl, studyPathTemplate - * - retrieveUrlBuilder(studyInstanceUID, instance) - * - successStatusCode, partialSuccessStatusCode, failureStatusCode - * - defaultFailureReason - */ -export function createDatasetResponseFromInstances( - authorizedInstances = [], - failedInstances = [], - options = {} -) { - const response = {}; - - const successItems = authorizedInstances.map(instance => mapSuccessSequenceItem(instance, options)); - if (successItems.length > 0) { - response['00081199'] = { - vr: 'SQ', - Value: successItems, - }; - } - - const failedItems = failedInstances.map(instance => mapFailedSequenceItem(instance, options)); - if (failedItems.length > 0) { - response['00081198'] = { - vr: 'SQ', - Value: failedItems, - }; - } - - if (successItems.length > 0) { - const firstStudyInstanceUID = authorizedInstances[0]?.studyInstanceUID; - const retrieveUrl = resolveRetrieveUrl(firstStudyInstanceUID, options, authorizedInstances[0]); - if (retrieveUrl) { - response['00081190'] = { - vr: 'UR', - Value: [retrieveUrl], - }; - } - } - - let statusCode = options.successStatusCode ?? 200; - if (failedItems.length > 0 && successItems.length > 0) { - statusCode = options.partialSuccessStatusCode ?? 202; - } else if (failedItems.length > 0 && successItems.length === 0) { - statusCode = options.failureStatusCode ?? 409; - } - - return { statusCode, dataset: response }; -} - -function getSOPClassUID(information) { - return information?.sopClassUid || null; -} - -function getSOPInstanceUID(information) { - return information?.sopInstanceUid || null; -} - -export function createDatasetResponseFromUploadedFiles(files = [], options = {}) { - const response = {}; - const successItems = []; - const failedItems = []; - - for (const file of files) { - const information = file.result?.information; - const hasInformation = !!information; - const streamErrors = file.result?.streamErrors || []; - const hasStreamErrors = streamErrors.length > 0; - const isValidSuccess = file.ok && hasInformation && !hasStreamErrors; - const sopClassUID = hasInformation ? getSOPClassUID(information) : null; - const sopInstanceUID = hasInformation ? getSOPInstanceUID(information) : null; - - const item = {}; - const contentLocation = file.fieldname || file.headers?.['content-location']; - if (contentLocation) { - item['00091001'] = { - vr: 'LO', - Value: [contentLocation], - }; - } - if (sopClassUID) { - item['00081150'] = { - vr: 'UI', - Value: [sopClassUID], - }; - } - if (sopInstanceUID) { - item['00081155'] = { - vr: 'UI', - Value: [sopInstanceUID], - }; - } - - if (isValidSuccess) { - successItems.push(item); - } else { - if (hasStreamErrors) { - const logger = options.logger ?? console; - logger.error( - `[STOW] Instance ${sopInstanceUID || 'unknown'} failed due to stream errors:`, - streamErrors.map(e => `${e.streamKey}: ${e.error?.message || e.error}`).join(', ') - ); - } - item['00081197'] = { - vr: 'US', - Value: [options.defaultFailureReason ?? DEFAULT_FAILURE_REASON], - }; - failedItems.push(item); - } - } - - if (successItems.length > 0) { - response['00081199'] = { - vr: 'SQ', - Value: successItems, - }; - } - if (failedItems.length > 0) { - response['00081198'] = { - vr: 'SQ', - Value: failedItems, - }; - } - - return response; -} +const DEFAULT_FAILURE_REASON = 0xc000; +const DEFAULT_STUDY_PATH_TEMPLATE = '/api/studies/{studyInstanceUID}'; + +function withTrailingSlashRemoved(value) { + return `${value || ''}`.replace(/\/+$/, ''); +} + +function buildStudyRetrieveUrl(studyInstanceUID, options = {}) { + if (!studyInstanceUID) { + return null; + } + + const rootUrl = withTrailingSlashRemoved(options.rootUrl || ''); + const pathTemplate = options.studyPathTemplate || DEFAULT_STUDY_PATH_TEMPLATE; + const path = pathTemplate.replace('{studyInstanceUID}', studyInstanceUID); + + if (!rootUrl) { + return path; + } + + return `${rootUrl}${path.startsWith('/') ? '' : '/'}${path}`; +} + +function resolveRetrieveUrl(studyInstanceUID, options = {}, instance = null) { + if (typeof options.retrieveUrlBuilder === 'function') { + return options.retrieveUrlBuilder(studyInstanceUID, instance); + } + return buildStudyRetrieveUrl(studyInstanceUID, options); +} + +function mapSuccessSequenceItem(instance, options = {}) { + const item = {}; + if (instance?.sopClassUID) { + item['00081150'] = { + vr: 'UI', + Value: [instance.sopClassUID], + }; + } + if (instance?.sopInstanceUID) { + item['00081155'] = { + vr: 'UI', + Value: [instance.sopInstanceUID], + }; + } + + const retrieveUrl = resolveRetrieveUrl(instance?.studyInstanceUID, options, instance); + if (retrieveUrl) { + item['00081190'] = { + vr: 'UR', + Value: [retrieveUrl], + }; + } + + return item; +} + +function mapFailedSequenceItem(instance, options = {}) { + const item = {}; + if (instance?.sopClassUID) { + item['00081150'] = { + vr: 'UI', + Value: [instance.sopClassUID], + }; + } + if (instance?.sopInstanceUID) { + item['00081155'] = { + vr: 'UI', + Value: [instance.sopInstanceUID], + }; + } + item['00081197'] = { + vr: 'US', + Value: [instance?.failureReason ?? options.defaultFailureReason ?? DEFAULT_FAILURE_REASON], + }; + + const errorComment = + instance?.errorComment || + (Array.isArray(instance?.failureDetails) ? instance.failureDetails.join(', ') : null); + if (errorComment) { + item['00000902'] = { + vr: 'LO', + Value: [errorComment], + }; + } + + return item; +} + +/** + * Build a STOW-RS dataset from authorized/failed instance lists. + * Options can override status codes and RetrieveURL generation: + * - rootUrl, studyPathTemplate + * - retrieveUrlBuilder(studyInstanceUID, instance) + * - successStatusCode, partialSuccessStatusCode, failureStatusCode + * - defaultFailureReason + */ +export function createDatasetResponseFromInstances( + authorizedInstances = [], + failedInstances = [], + options = {} +) { + const response = {}; + + const successItems = authorizedInstances.map(instance => mapSuccessSequenceItem(instance, options)); + if (successItems.length > 0) { + response['00081199'] = { + vr: 'SQ', + Value: successItems, + }; + } + + const failedItems = failedInstances.map(instance => mapFailedSequenceItem(instance, options)); + if (failedItems.length > 0) { + response['00081198'] = { + vr: 'SQ', + Value: failedItems, + }; + } + + if (successItems.length > 0) { + const firstStudyInstanceUID = authorizedInstances[0]?.studyInstanceUID; + const retrieveUrl = resolveRetrieveUrl(firstStudyInstanceUID, options, authorizedInstances[0]); + if (retrieveUrl) { + response['00081190'] = { + vr: 'UR', + Value: [retrieveUrl], + }; + } + } + + let statusCode = options.successStatusCode ?? 200; + if (failedItems.length > 0 && successItems.length > 0) { + statusCode = options.partialSuccessStatusCode ?? 202; + } else if (failedItems.length > 0 && successItems.length === 0) { + statusCode = options.failureStatusCode ?? 409; + } + + return { statusCode, dataset: response }; +} + +function getSOPClassUID(information) { + return information?.sopClassUid || null; +} + +function getSOPInstanceUID(information) { + return information?.sopInstanceUid || null; +} + +export function createDatasetResponseFromUploadedFiles(files = [], options = {}) { + const response = {}; + const successItems = []; + const failedItems = []; + + for (const file of files) { + const information = file.result?.information; + const hasInformation = !!information; + const streamErrors = file.result?.streamErrors || []; + const hasStreamErrors = streamErrors.length > 0; + const isValidSuccess = file.ok && hasInformation && !hasStreamErrors; + const sopClassUID = hasInformation ? getSOPClassUID(information) : null; + const sopInstanceUID = hasInformation ? getSOPInstanceUID(information) : null; + + const item = {}; + const contentLocation = file.fieldname || file.headers?.['content-location']; + if (contentLocation) { + item['00091001'] = { + vr: 'LO', + Value: [contentLocation], + }; + } + if (sopClassUID) { + item['00081150'] = { + vr: 'UI', + Value: [sopClassUID], + }; + } + if (sopInstanceUID) { + item['00081155'] = { + vr: 'UI', + Value: [sopInstanceUID], + }; + } + + if (isValidSuccess) { + successItems.push(item); + } else { + if (hasStreamErrors) { + const logger = options.logger ?? console; + logger.error( + `[STOW] Instance ${sopInstanceUID || 'unknown'} failed due to stream errors:`, + streamErrors.map(e => `${e.streamKey}: ${e.error?.message || e.error}`).join(', ') + ); + } + item['00081197'] = { + vr: 'US', + Value: [options.defaultFailureReason ?? DEFAULT_FAILURE_REASON], + }; + failedItems.push(item); + } + } + + if (successItems.length > 0) { + response['00081199'] = { + vr: 'SQ', + Value: successItems, + }; + } + if (failedItems.length > 0) { + response['00081198'] = { + vr: 'SQ', + Value: failedItems, + }; + } + + return response; +} diff --git a/packages/static-wado-webserver/lib/vendor/dicer-compat/Dicer.mjs b/packages/static-wado-webserver/lib/vendor/dicer-compat/Dicer.mjs index baf082f9..f7f09497 100644 --- a/packages/static-wado-webserver/lib/vendor/dicer-compat/Dicer.mjs +++ b/packages/static-wado-webserver/lib/vendor/dicer-compat/Dicer.mjs @@ -1,217 +1,217 @@ -import { Writable } from 'node:stream'; -import StreamSearch from 'streamsearch'; -import PartStream from './PartStream.mjs'; -import HeaderParser from './HeaderParser.mjs'; - -const DASH = 45; -const B_ONEDASH = Buffer.from('-'); -const B_CRLF = Buffer.from('\r\n'); -const EMPTY_FN = () => {}; - -export default class Dicer extends Writable { - constructor(cfg) { - super(cfg); - - if (!cfg || (!cfg.headerFirst && typeof cfg.boundary !== 'string')) - throw new TypeError('Boundary required'); - - if (typeof cfg.boundary === 'string') this.setBoundary(cfg.boundary); - else this._bparser = undefined; - - this._headerFirst = cfg.headerFirst; - - this._dashes = 0; - this._parts = 0; - this._finished = false; - this._realFinish = false; - this._isPreamble = true; - this._justMatched = false; - this._firstWrite = true; - this._inHeader = true; - this._part = undefined; - this._cb = undefined; - this._ignoreData = false; - this._partOpts = typeof cfg.partHwm === 'number' ? { highWaterMark: cfg.partHwm } : {}; - this._pause = false; - - this._hparser = new HeaderParser(cfg); - this._hparser.on('header', header => { - this._inHeader = false; - this._part.emit('header', header); - }); - this._hparser.on('error', err => { - if (this._part && !this._ignoreData) { - this._part.emit('error', err); - this._part.push(null); - } - }); - } - - emit(ev) { - if (ev !== 'finish' || this._realFinish) { - Writable.prototype.emit.apply(this, arguments); - return; - } - - if (this._finished) return; - - process.nextTick(() => { - this.emit('error', new Error('Unexpected end of multipart data')); - - if (this._part && !this._ignoreData) { - const type = this._isPreamble ? 'Preamble' : 'Part'; - this._part.emit( - 'error', - new Error( - `${type} terminated early due to unexpected end of multipart data` - ) - ); - this._part.push(null); - process.nextTick(() => { - this._realFinish = true; - this.emit('finish'); - this._realFinish = false; - }); - return; - } - - this._realFinish = true; - this.emit('finish'); - this._realFinish = false; - }); - } - - _write(data, encoding, cb) { - if (!this._hparser && !this._bparser) return cb(); - - if (this._headerFirst && this._isPreamble) { - if (!this._part) { - this._part = new PartStream(this._partOpts); - if (this._events.preamble) this.emit('preamble', this._part); - else ignore(this); - } - const r = this._hparser.push(data); - if (!this._inHeader && r !== undefined && r < data.length) data = data.slice(r); - else return cb(); - } - - if (this._firstWrite) { - this._bparser.push(B_CRLF); - this._firstWrite = false; - } - - this._bparser.push(data); - - if (this._pause) this._cb = cb; - else cb(); - } - - reset() { - this._part = undefined; - this._bparser = undefined; - this._hparser = undefined; - } - - setBoundary(boundary) { - this._bparser = new StreamSearch(`\r\n--${boundary}`, onInfo.bind(this)); - } -} - -function onInfo(isMatch, data, start, end) { - let buf; - let i = 0; - let r; - let ev; - let shouldWriteMore = true; - - if (!this._part && this._justMatched && data) { - while (this._dashes < 2 && start + i < end) { - if (data[start + i] === DASH) { - ++i; - ++this._dashes; - } else { - if (this._dashes) buf = B_ONEDASH; - this._dashes = 0; - break; - } - } - if (this._dashes === 2) { - if (start + i < end && this._events.trailer) - this.emit('trailer', data.slice(start + i, end)); - this.reset(); - this._finished = true; - if (this._parts === 0) { - this._realFinish = true; - this.emit('finish'); - this._realFinish = false; - } - } - if (this._dashes) return; - } - if (this._justMatched) this._justMatched = false; - if (!this._part) { - this._part = new PartStream(this._partOpts); - this._part._read = () => { - unpause(this); - }; - ev = this._isPreamble ? 'preamble' : 'part'; - if (this._events[ev]) this.emit(ev, this._part); - else ignore(this); - if (!this._isPreamble) this._inHeader = true; - } - if (data && start < end && !this._ignoreData) { - if (this._isPreamble || !this._inHeader) { - if (buf) shouldWriteMore = this._part.push(buf); - shouldWriteMore = this._part.push(data.slice(start, end)); - if (!shouldWriteMore) this._pause = true; - } else if (!this._isPreamble && this._inHeader) { - if (buf) this._hparser.push(buf); - r = this._hparser.push(data.slice(start, end)); - if (!this._inHeader && r !== undefined && r < end) - onInfo.call(this, false, data, start + r, end); - } - } - if (isMatch) { - this._hparser.reset(); - if (this._isPreamble) { - this._isPreamble = false; - } else { - ++this._parts; - this._part.on('end', () => { - if (--this._parts === 0) { - if (this._finished) { - this._realFinish = true; - this.emit('finish'); - this._realFinish = false; - } else { - unpause(this); - } - } - }); - } - this._part.push(null); - this._part = undefined; - this._ignoreData = false; - this._justMatched = true; - this._dashes = 0; - } -} - -function ignore(self) { - if (self._part && !self._ignoreData) { - self._ignoreData = true; - self._part.on('error', EMPTY_FN); - self._part.resume(); - } -} - -function unpause(self) { - if (!self._pause) return; - - self._pause = false; - if (self._cb) { - const cb = self._cb; - self._cb = undefined; - cb(); - } -} +import { Writable } from 'node:stream'; +import StreamSearch from 'streamsearch'; +import PartStream from './PartStream.mjs'; +import HeaderParser from './HeaderParser.mjs'; + +const DASH = 45; +const B_ONEDASH = Buffer.from('-'); +const B_CRLF = Buffer.from('\r\n'); +const EMPTY_FN = () => {}; + +export default class Dicer extends Writable { + constructor(cfg) { + super(cfg); + + if (!cfg || (!cfg.headerFirst && typeof cfg.boundary !== 'string')) + throw new TypeError('Boundary required'); + + if (typeof cfg.boundary === 'string') this.setBoundary(cfg.boundary); + else this._bparser = undefined; + + this._headerFirst = cfg.headerFirst; + + this._dashes = 0; + this._parts = 0; + this._finished = false; + this._realFinish = false; + this._isPreamble = true; + this._justMatched = false; + this._firstWrite = true; + this._inHeader = true; + this._part = undefined; + this._cb = undefined; + this._ignoreData = false; + this._partOpts = typeof cfg.partHwm === 'number' ? { highWaterMark: cfg.partHwm } : {}; + this._pause = false; + + this._hparser = new HeaderParser(cfg); + this._hparser.on('header', header => { + this._inHeader = false; + this._part.emit('header', header); + }); + this._hparser.on('error', err => { + if (this._part && !this._ignoreData) { + this._part.emit('error', err); + this._part.push(null); + } + }); + } + + emit(ev) { + if (ev !== 'finish' || this._realFinish) { + Writable.prototype.emit.apply(this, arguments); + return; + } + + if (this._finished) return; + + process.nextTick(() => { + this.emit('error', new Error('Unexpected end of multipart data')); + + if (this._part && !this._ignoreData) { + const type = this._isPreamble ? 'Preamble' : 'Part'; + this._part.emit( + 'error', + new Error( + `${type} terminated early due to unexpected end of multipart data` + ) + ); + this._part.push(null); + process.nextTick(() => { + this._realFinish = true; + this.emit('finish'); + this._realFinish = false; + }); + return; + } + + this._realFinish = true; + this.emit('finish'); + this._realFinish = false; + }); + } + + _write(data, encoding, cb) { + if (!this._hparser && !this._bparser) return cb(); + + if (this._headerFirst && this._isPreamble) { + if (!this._part) { + this._part = new PartStream(this._partOpts); + if (this._events.preamble) this.emit('preamble', this._part); + else ignore(this); + } + const r = this._hparser.push(data); + if (!this._inHeader && r !== undefined && r < data.length) data = data.slice(r); + else return cb(); + } + + if (this._firstWrite) { + this._bparser.push(B_CRLF); + this._firstWrite = false; + } + + this._bparser.push(data); + + if (this._pause) this._cb = cb; + else cb(); + } + + reset() { + this._part = undefined; + this._bparser = undefined; + this._hparser = undefined; + } + + setBoundary(boundary) { + this._bparser = new StreamSearch(`\r\n--${boundary}`, onInfo.bind(this)); + } +} + +function onInfo(isMatch, data, start, end) { + let buf; + let i = 0; + let r; + let ev; + let shouldWriteMore = true; + + if (!this._part && this._justMatched && data) { + while (this._dashes < 2 && start + i < end) { + if (data[start + i] === DASH) { + ++i; + ++this._dashes; + } else { + if (this._dashes) buf = B_ONEDASH; + this._dashes = 0; + break; + } + } + if (this._dashes === 2) { + if (start + i < end && this._events.trailer) + this.emit('trailer', data.slice(start + i, end)); + this.reset(); + this._finished = true; + if (this._parts === 0) { + this._realFinish = true; + this.emit('finish'); + this._realFinish = false; + } + } + if (this._dashes) return; + } + if (this._justMatched) this._justMatched = false; + if (!this._part) { + this._part = new PartStream(this._partOpts); + this._part._read = () => { + unpause(this); + }; + ev = this._isPreamble ? 'preamble' : 'part'; + if (this._events[ev]) this.emit(ev, this._part); + else ignore(this); + if (!this._isPreamble) this._inHeader = true; + } + if (data && start < end && !this._ignoreData) { + if (this._isPreamble || !this._inHeader) { + if (buf) shouldWriteMore = this._part.push(buf); + shouldWriteMore = this._part.push(data.slice(start, end)); + if (!shouldWriteMore) this._pause = true; + } else if (!this._isPreamble && this._inHeader) { + if (buf) this._hparser.push(buf); + r = this._hparser.push(data.slice(start, end)); + if (!this._inHeader && r !== undefined && r < end) + onInfo.call(this, false, data, start + r, end); + } + } + if (isMatch) { + this._hparser.reset(); + if (this._isPreamble) { + this._isPreamble = false; + } else { + ++this._parts; + this._part.on('end', () => { + if (--this._parts === 0) { + if (this._finished) { + this._realFinish = true; + this.emit('finish'); + this._realFinish = false; + } else { + unpause(this); + } + } + }); + } + this._part.push(null); + this._part = undefined; + this._ignoreData = false; + this._justMatched = true; + this._dashes = 0; + } +} + +function ignore(self) { + if (self._part && !self._ignoreData) { + self._ignoreData = true; + self._part.on('error', EMPTY_FN); + self._part.resume(); + } +} + +function unpause(self) { + if (!self._pause) return; + + self._pause = false; + if (self._cb) { + const cb = self._cb; + self._cb = undefined; + cb(); + } +} diff --git a/packages/static-wado-webserver/lib/vendor/dicer-compat/HeaderParser.mjs b/packages/static-wado-webserver/lib/vendor/dicer-compat/HeaderParser.mjs index 275ab524..e74fc02c 100644 --- a/packages/static-wado-webserver/lib/vendor/dicer-compat/HeaderParser.mjs +++ b/packages/static-wado-webserver/lib/vendor/dicer-compat/HeaderParser.mjs @@ -1,106 +1,106 @@ -import { EventEmitter } from 'node:events'; -import StreamSearch from 'streamsearch'; - -const B_DCRLF = Buffer.from('\r\n\r\n'); -const RE_CRLF = /\r\n/g; -// eslint-disable-next-line no-control-regex -const RE_HDR = /^([^:]+):[ \t]?([\x00-\xFF]+)?$/; -const MAX_HEADER_PAIRS = 2000; // From node's http.js -// Tighter than legacy dicer (80 KiB); matches busboy's multipart header cap -const MAX_HEADER_SIZE = 16 * 1024; - -export default class HeaderParser extends EventEmitter { - constructor(cfg) { - super(); - - this.nread = 0; - this.maxed = false; - this.npairs = 0; - this.maxHeaderPairs = - cfg && typeof cfg.maxHeaderPairs === 'number' ? cfg.maxHeaderPairs : MAX_HEADER_PAIRS; - this.buffer = ''; - this.header = {}; - this.finished = false; - this.ss = new StreamSearch(B_DCRLF, (isMatch, data, start, end) => { - if (data && !this.maxed) { - if (this.nread + (end - start) > MAX_HEADER_SIZE) { - end = MAX_HEADER_SIZE - this.nread; - this.nread = MAX_HEADER_SIZE; - } else { - this.nread += end - start; - } - - if (this.nread === MAX_HEADER_SIZE) this.maxed = true; - - this.buffer += data.toString('latin1', start, end); - } - if (isMatch) this._finish(); - }); - } - - push(data) { - const r = this.ss.push(data); - if (this.finished) return r; - } - - reset() { - this.finished = false; - this.buffer = ''; - this.header = {}; - this.ss.reset(); - } - - _finish() { - let hadError = false; - if (this.buffer) hadError = !parseHeader(this); - this.ss.matches = this.ss.maxMatches; - const header = this.header; - this.header = {}; - this.buffer = ''; - this.finished = true; - this.nread = this.npairs = 0; - this.maxed = false; - if (!hadError) this.emit('header', header); - } -} - -function parseHeader(self) { - if (self.npairs === self.maxHeaderPairs) return true; - - const lines = self.buffer.split(RE_CRLF); - const len = lines.length; - let m; - let h; - let modded = false; - - for (let i = 0; i < len; ++i) { - if (lines[i].length === 0) continue; - - if (lines[i][0] === '\t' || lines[i][0] === ' ') { - if (!h) { - self.emit('error', new Error('Unexpected folded header value')); - return false; - } - self.header[h][self.header[h].length - 1] += lines[i]; - } else { - m = RE_HDR.exec(lines[i]); - if (m) { - h = m[1].toLowerCase(); - if (m[2]) { - if (self.header[h] === undefined) self.header[h] = [m[2]]; - else self.header[h].push(m[2]); - } else { - self.header[h] = ['']; - } - if (++self.npairs === self.maxHeaderPairs) break; - } else { - self.buffer = lines[i]; - modded = true; - break; - } - } - } - if (!modded) self.buffer = ''; - - return true; -} +import { EventEmitter } from 'node:events'; +import StreamSearch from 'streamsearch'; + +const B_DCRLF = Buffer.from('\r\n\r\n'); +const RE_CRLF = /\r\n/g; +// eslint-disable-next-line no-control-regex +const RE_HDR = /^([^:]+):[ \t]?([\x00-\xFF]+)?$/; +const MAX_HEADER_PAIRS = 2000; // From node's http.js +// Tighter than legacy dicer (80 KiB); matches busboy's multipart header cap +const MAX_HEADER_SIZE = 16 * 1024; + +export default class HeaderParser extends EventEmitter { + constructor(cfg) { + super(); + + this.nread = 0; + this.maxed = false; + this.npairs = 0; + this.maxHeaderPairs = + cfg && typeof cfg.maxHeaderPairs === 'number' ? cfg.maxHeaderPairs : MAX_HEADER_PAIRS; + this.buffer = ''; + this.header = {}; + this.finished = false; + this.ss = new StreamSearch(B_DCRLF, (isMatch, data, start, end) => { + if (data && !this.maxed) { + if (this.nread + (end - start) > MAX_HEADER_SIZE) { + end = MAX_HEADER_SIZE - this.nread; + this.nread = MAX_HEADER_SIZE; + } else { + this.nread += end - start; + } + + if (this.nread === MAX_HEADER_SIZE) this.maxed = true; + + this.buffer += data.toString('latin1', start, end); + } + if (isMatch) this._finish(); + }); + } + + push(data) { + const r = this.ss.push(data); + if (this.finished) return r; + } + + reset() { + this.finished = false; + this.buffer = ''; + this.header = {}; + this.ss.reset(); + } + + _finish() { + let hadError = false; + if (this.buffer) hadError = !parseHeader(this); + this.ss.matches = this.ss.maxMatches; + const header = this.header; + this.header = {}; + this.buffer = ''; + this.finished = true; + this.nread = this.npairs = 0; + this.maxed = false; + if (!hadError) this.emit('header', header); + } +} + +function parseHeader(self) { + if (self.npairs === self.maxHeaderPairs) return true; + + const lines = self.buffer.split(RE_CRLF); + const len = lines.length; + let m; + let h; + let modded = false; + + for (let i = 0; i < len; ++i) { + if (lines[i].length === 0) continue; + + if (lines[i][0] === '\t' || lines[i][0] === ' ') { + if (!h) { + self.emit('error', new Error('Unexpected folded header value')); + return false; + } + self.header[h][self.header[h].length - 1] += lines[i]; + } else { + m = RE_HDR.exec(lines[i]); + if (m) { + h = m[1].toLowerCase(); + if (m[2]) { + if (self.header[h] === undefined) self.header[h] = [m[2]]; + else self.header[h].push(m[2]); + } else { + self.header[h] = ['']; + } + if (++self.npairs === self.maxHeaderPairs) break; + } else { + self.buffer = lines[i]; + modded = true; + break; + } + } + } + if (!modded) self.buffer = ''; + + return true; +} diff --git a/packages/static-wado-webserver/lib/vendor/dicer-compat/LICENSE.dicer.md b/packages/static-wado-webserver/lib/vendor/dicer-compat/LICENSE.dicer.md index a0960c35..a1edc855 100644 --- a/packages/static-wado-webserver/lib/vendor/dicer-compat/LICENSE.dicer.md +++ b/packages/static-wado-webserver/lib/vendor/dicer-compat/LICENSE.dicer.md @@ -1,7 +1,7 @@ -Vendored from dicer (MIT), https://github.com/mscdex/dicer - -Copyright Brian White. See upstream LICENSE in the dicer repository. - -This copy is inlined to avoid the unmaintained `dicer` npm package (GHSA-wm7h-9275-46v2) -while preserving streaming multipart parsing behavior required for STOW-RS -(`multipart/related`), which differs from `busboy`'s `multipart/form-data` parser. +Vendored from dicer (MIT), https://github.com/mscdex/dicer + +Copyright Brian White. See upstream LICENSE in the dicer repository. + +This copy is inlined to avoid the unmaintained `dicer` npm package (GHSA-wm7h-9275-46v2) +while preserving streaming multipart parsing behavior required for STOW-RS +(`multipart/related`), which differs from `busboy`'s `multipart/form-data` parser. diff --git a/packages/static-wado-webserver/lib/vendor/dicer-compat/PartStream.mjs b/packages/static-wado-webserver/lib/vendor/dicer-compat/PartStream.mjs index 7786fa7e..0d0baee2 100644 --- a/packages/static-wado-webserver/lib/vendor/dicer-compat/PartStream.mjs +++ b/packages/static-wado-webserver/lib/vendor/dicer-compat/PartStream.mjs @@ -1,5 +1,5 @@ -import { Readable } from 'node:stream'; - -export default class PartStream extends Readable { - _read(n) {} -} +import { Readable } from 'node:stream'; + +export default class PartStream extends Readable { + _read(n) {} +} diff --git a/scripts/check-pinned-versions.sh b/scripts/check-pinned-versions.sh index 5e26fc70..cb7e6552 100644 --- a/scripts/check-pinned-versions.sh +++ b/scripts/check-pinned-versions.sh @@ -1,45 +1,45 @@ -#!/usr/bin/env bash -# Fails if any package.json dependency version is not pinned (no ^ ~ * >= ranges). -# Allows workspace:, file:, npm:, and http(s): specifiers. -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" - -is_pinned() { - local version="$1" - [[ -z "$version" ]] && return 1 - [[ "$version" =~ ^(workspace:|file:|npm:) ]] && return 0 - [[ "$version" =~ ^https?:// ]] && return 0 - [[ "$version" =~ [\^~*] ]] && return 1 - [[ "$version" == *">="* ]] && return 1 - [[ "$version" == *"<="* ]] && return 1 - return 0 -} - -check_dep_block() { - local pkg="$1" - local block="$2" - local entry name version - - while IFS= read -r entry; do - [[ -z "$entry" ]] && continue - name="${entry%%$'\t'*}" - version="${entry#*$'\t'}" - if ! is_pinned "$version"; then - echo "Unpinned dependency in ${pkg} ${block}: ${name} -> ${version}" >&2 - return 1 - fi - done < <(jq -r --arg b "$block" '.[$b] // {} | to_entries[] | "\(.key)\t\(.value)"' "$pkg" 2>/dev/null || true) -} - -failed=0 -while IFS= read -r -d '' pkg; do - for block in dependencies devDependencies optionalDependencies peerDependencies overrides; do - if ! check_dep_block "$pkg" "$block"; then - failed=1 - fi - done -done < <(find "$REPO_ROOT" -name package.json -not -path '*/node_modules/*' -print0) - -exit "$failed" +#!/usr/bin/env bash +# Fails if any package.json dependency version is not pinned (no ^ ~ * >= ranges). +# Allows workspace:, file:, npm:, and http(s): specifiers. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +is_pinned() { + local version="$1" + [[ -z "$version" ]] && return 1 + [[ "$version" =~ ^(workspace:|file:|npm:) ]] && return 0 + [[ "$version" =~ ^https?:// ]] && return 0 + [[ "$version" =~ [\^~*] ]] && return 1 + [[ "$version" == *">="* ]] && return 1 + [[ "$version" == *"<="* ]] && return 1 + return 0 +} + +check_dep_block() { + local pkg="$1" + local block="$2" + local entry name version + + while IFS= read -r entry; do + [[ -z "$entry" ]] && continue + name="${entry%%$'\t'*}" + version="${entry#*$'\t'}" + if ! is_pinned "$version"; then + echo "Unpinned dependency in ${pkg} ${block}: ${name} -> ${version}" >&2 + return 1 + fi + done < <(jq -r --arg b "$block" '.[$b] // {} | to_entries[] | "\(.key)\t\(.value)"' "$pkg" 2>/dev/null || true) +} + +failed=0 +while IFS= read -r -d '' pkg; do + for block in dependencies devDependencies optionalDependencies peerDependencies overrides; do + if ! check_dep_block "$pkg" "$block"; then + failed=1 + fi + done +done < <(find "$REPO_ROOT" -name package.json -not -path '*/node_modules/*' -print0) + +exit "$failed" diff --git a/scripts/ci-supply-chain.sh b/scripts/ci-supply-chain.sh index 2ab83aa8..0716e8dd 100644 --- a/scripts/ci-supply-chain.sh +++ b/scripts/ci-supply-chain.sh @@ -1,56 +1,56 @@ -#!/usr/bin/env bash -# CI supply-chain checks: pinned versions, frozen lockfile sync, audit only when bun.lock changed. -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -cd "$REPO_ROOT" - -lockfile_changed_in_range() { - local range="$1" - [[ -z "$range" ]] && return 1 - git diff --name-only "$range" -- bun.lock | grep -q . -} - -should_run_bun_audit() { - if [[ "${FORCE_BUN_AUDIT:-}" == "1" ]]; then - return 0 - fi - - if [[ "${GITHUB_EVENT_NAME:-}" == "pull_request" && -n "${GITHUB_BASE_REF:-}" ]]; then - git fetch --no-tags --depth=1 origin "$GITHUB_BASE_REF" 2>/dev/null || true - lockfile_changed_in_range "origin/${GITHUB_BASE_REF}...HEAD" - return $? - fi - - if [[ "${GITHUB_EVENT_NAME:-}" == "push" ]]; then - if git rev-parse HEAD~1 >/dev/null 2>&1; then - lockfile_changed_in_range 'HEAD~1..HEAD' - return $? - fi - return 0 - fi - - lockfile_changed_in_range HEAD && return 0 - lockfile_changed_in_range --cached && return 0 - return 1 -} - -echo 'Checking pinned dependency versions...' -bash "$SCRIPT_DIR/check-pinned-versions.sh" - -echo 'Installing with frozen lockfile...' -bun install --frozen-lockfile - -echo 'Verifying bun.lock matches package.json (no drift after install)...' -if ! git diff --exit-code bun.lock; then - echo 'bun.lock is out of sync. Run: bun run install:update-lockfile' >&2 - exit 1 -fi - -if should_run_bun_audit; then - echo 'bun.lock changed in this change set; running bun audit (high/critical)...' - bun audit --audit-level=high -else - echo 'bun.lock unchanged; skipping bun audit.' -fi +#!/usr/bin/env bash +# CI supply-chain checks: pinned versions, frozen lockfile sync, audit only when bun.lock changed. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$REPO_ROOT" + +lockfile_changed_in_range() { + local range="$1" + [[ -z "$range" ]] && return 1 + git diff --name-only "$range" -- bun.lock | grep -q . +} + +should_run_bun_audit() { + if [[ "${FORCE_BUN_AUDIT:-}" == "1" ]]; then + return 0 + fi + + if [[ "${GITHUB_EVENT_NAME:-}" == "pull_request" && -n "${GITHUB_BASE_REF:-}" ]]; then + git fetch --no-tags --depth=1 origin "$GITHUB_BASE_REF" 2>/dev/null || true + lockfile_changed_in_range "origin/${GITHUB_BASE_REF}...HEAD" + return $? + fi + + if [[ "${GITHUB_EVENT_NAME:-}" == "push" ]]; then + if git rev-parse HEAD~1 >/dev/null 2>&1; then + lockfile_changed_in_range 'HEAD~1..HEAD' + return $? + fi + return 0 + fi + + lockfile_changed_in_range HEAD && return 0 + lockfile_changed_in_range --cached && return 0 + return 1 +} + +echo 'Checking pinned dependency versions...' +bash "$SCRIPT_DIR/check-pinned-versions.sh" + +echo 'Installing with frozen lockfile...' +bun install --frozen-lockfile + +echo 'Verifying bun.lock matches package.json (no drift after install)...' +if ! git diff --exit-code bun.lock; then + echo 'bun.lock is out of sync. Run: bun run install:update-lockfile' >&2 + exit 1 +fi + +if should_run_bun_audit; then + echo 'bun.lock changed in this change set; running bun audit (high/critical)...' + bun audit --audit-level=high +else + echo 'bun.lock unchanged; skipping bun audit.' +fi From 0b71d635a2aba47e4a312370e4eb8da128ad216b Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Fri, 15 May 2026 09:30:34 -0400 Subject: [PATCH 3/9] Minimum release age --- .gitattributes | 1 + bunfig.toml | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..dfdb8b77 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/bunfig.toml b/bunfig.toml index 623a5c6c..429cd777 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -3,5 +3,5 @@ frozenLockfile = true exact = true saveTextLockfile = true -# Optional supply-chain knob (pnpm-style “minimum release age”), in seconds: -# minimumReleaseAge = 86400 +# Supply-chain knob: skip package versions published in the last 2 days (seconds). +minimumReleaseAge = 172800 From e1beb03f81b3ba0a8f229c875469f84260e06eec Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Fri, 15 May 2026 09:39:00 -0400 Subject: [PATCH 4/9] fix: pinned check --- scripts/check-pinned-versions.sh | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/scripts/check-pinned-versions.sh b/scripts/check-pinned-versions.sh index cb7e6552..f11b06ed 100644 --- a/scripts/check-pinned-versions.sh +++ b/scripts/check-pinned-versions.sh @@ -9,11 +9,17 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" is_pinned() { local version="$1" [[ -z "$version" ]] && return 1 - [[ "$version" =~ ^(workspace:|file:|npm:) ]] && return 0 - [[ "$version" =~ ^https?:// ]] && return 0 - [[ "$version" =~ [\^~*] ]] && return 1 - [[ "$version" == *">="* ]] && return 1 - [[ "$version" == *"<="* ]] && return 1 + case "$version" in + workspace:*|file:*|npm:*) return 0 ;; + http://*|https://*) return 0 ;; + esac + case "$version" in + ^*) return 1 ;; + *'~'*) return 1 ;; + *'*'*) return 1 ;; + *'>='*) return 1 ;; + *'<='*) return 1 ;; + esac return 0 } @@ -21,16 +27,19 @@ check_dep_block() { local pkg="$1" local block="$2" local entry name version + local block_failed=0 - while IFS= read -r entry; do + while IFS= read -r entry || [[ -n "${entry:-}" ]]; do [[ -z "$entry" ]] && continue name="${entry%%$'\t'*}" version="${entry#*$'\t'}" if ! is_pinned "$version"; then echo "Unpinned dependency in ${pkg} ${block}: ${name} -> ${version}" >&2 - return 1 + block_failed=1 fi - done < <(jq -r --arg b "$block" '.[$b] // {} | to_entries[] | "\(.key)\t\(.value)"' "$pkg" 2>/dev/null || true) + done < <(jq -r --arg b "$block" '.[$b] // {} | to_entries[] | "\(.key)\t\(.value|tostring)"' "$pkg" 2>/dev/null || true) + + [[ "$block_failed" -eq 0 ]] } failed=0 From 77d2a548d83d171d07e9af2a5e66058d890d9226 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Fri, 15 May 2026 09:48:33 -0400 Subject: [PATCH 5/9] fix: Init submodules to allow testdata to be available --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e446e4c..ddf74c6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + submodules: true - uses: oven-sh/setup-bun@v2 with: @@ -26,6 +27,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + submodules: true - uses: oven-sh/setup-bun@v2 with: From f676f48364952865a5ec379f3fd7363006eb4760 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Tue, 19 May 2026 08:51:46 -0400 Subject: [PATCH 6/9] Fix merge issues --- bun.lock | 18 +- packages/create-dicomweb/package.json | 6 +- packages/healthlake/package.json | 8 +- packages/s3-deploy/package.json | 4 +- packages/static-wado-creator/package.json | 4 +- packages/static-wado-deploy/package.json | 6 +- packages/static-wado-plugins/package.json | 2 +- packages/static-wado-scp/package.json | 6 +- .../controllers/server/multipartStream.mjs | 2 +- .../lib/vendor/dicer-compat/LICENSE.dicer.md | 7 - .../lib/vendor/dicer/Dicer.js | 214 ++++++++++++++++++ .../Dicer.mjs => dicer/Dicer.ts} | 112 +++++---- .../lib/vendor/dicer/HeaderParser.js | 112 +++++++++ .../HeaderParser.ts} | 71 +++--- .../lib/vendor/dicer/LICENSE | 31 +++ .../lib/vendor/dicer/LICENSE.dicer.md | 5 + .../PartStream.mjs => dicer/PartStream.js} | 3 +- .../lib/vendor/dicer/PartStream.ts | 5 + .../lib/vendor/dicer/index.js | 4 + .../lib/vendor/dicer/index.ts | 5 + .../lib/vendor/dicer/streamsearch.d.ts | 16 ++ .../lib/vendor/dicer/types.js | 1 + .../lib/vendor/dicer/types.ts | 14 ++ packages/static-wado-webserver/package.json | 11 +- .../static-wado-webserver/tsconfig.dicer.json | 12 + 25 files changed, 559 insertions(+), 120 deletions(-) delete mode 100644 packages/static-wado-webserver/lib/vendor/dicer-compat/LICENSE.dicer.md create mode 100644 packages/static-wado-webserver/lib/vendor/dicer/Dicer.js rename packages/static-wado-webserver/lib/vendor/{dicer-compat/Dicer.mjs => dicer/Dicer.ts} (64%) create mode 100644 packages/static-wado-webserver/lib/vendor/dicer/HeaderParser.js rename packages/static-wado-webserver/lib/vendor/{dicer-compat/HeaderParser.mjs => dicer/HeaderParser.ts} (61%) create mode 100644 packages/static-wado-webserver/lib/vendor/dicer/LICENSE create mode 100644 packages/static-wado-webserver/lib/vendor/dicer/LICENSE.dicer.md rename packages/static-wado-webserver/lib/vendor/{dicer-compat/PartStream.mjs => dicer/PartStream.js} (81%) create mode 100644 packages/static-wado-webserver/lib/vendor/dicer/PartStream.ts create mode 100644 packages/static-wado-webserver/lib/vendor/dicer/index.js create mode 100644 packages/static-wado-webserver/lib/vendor/dicer/index.ts create mode 100644 packages/static-wado-webserver/lib/vendor/dicer/streamsearch.d.ts create mode 100644 packages/static-wado-webserver/lib/vendor/dicer/types.js create mode 100644 packages/static-wado-webserver/lib/vendor/dicer/types.ts create mode 100644 packages/static-wado-webserver/tsconfig.dicer.json diff --git a/bun.lock b/bun.lock index ad1fdaee..f1b30ee1 100644 --- a/bun.lock +++ b/bun.lock @@ -46,9 +46,9 @@ "createdicomweb": "bin/createdicomweb.mjs", }, "dependencies": { - "@radicalimaging/cs3d": "workspace:*", - "@radicalimaging/static-wado-creator": "workspace:*", - "@radicalimaging/static-wado-util": "workspace:*", + "@radicalimaging/cs3d": ">=1.7.6", + "@radicalimaging/static-wado-creator": ">=1.7.6", + "@radicalimaging/static-wado-util": ">=1.7.6", "adm-zip": "0.5.16", "canvas": "3.1.0", "commander": "12.1.0", @@ -124,8 +124,8 @@ "mkdicomweb": "bin/mkdicomweb.mjs", }, "dependencies": { - "@radicalimaging/cs3d": "workspace:*", - "@radicalimaging/static-wado-util": "workspace:*", + "@radicalimaging/cs3d": ">=1.7.6", + "@radicalimaging/static-wado-util": ">=1.7.6", "canvas": "3.1.0", "config-point": "0.8.0", "dcmjs": "0.50.1", @@ -160,7 +160,7 @@ "name": "@radicalimaging/static-wado-plugins", "version": "1.7.6", "dependencies": { - "@radicalimaging/static-wado-util": "workspace:*", + "@radicalimaging/static-wado-util": ">=1.7.6", "config-point": "0.8.0", "dcmjs-dimse": "0.1.31", "http-proxy-middleware": "2.0.9", @@ -217,9 +217,9 @@ }, "dependencies": { "@cornerstonejs/dicom-codec": "1.0.9", - "@radicalimaging/create-dicomweb": "workspace:*", - "@radicalimaging/static-wado-plugins": "workspace:*", - "@radicalimaging/static-wado-util": "workspace:*", + "@radicalimaging/create-dicomweb": ">=1.7.6", + "@radicalimaging/static-wado-plugins": ">=1.7.6", + "@radicalimaging/static-wado-util": ">=1.7.6", "@saga-bus/core": "0.2.2", "config-point": "0.8.0", "content-type": "1.0.5", diff --git a/packages/create-dicomweb/package.json b/packages/create-dicomweb/package.json index f6ef7dcf..bf802475 100644 --- a/packages/create-dicomweb/package.json +++ b/packages/create-dicomweb/package.json @@ -50,9 +50,9 @@ "verbose": true }, "dependencies": { - "@radicalimaging/cs3d": "workspace:*", - "@radicalimaging/static-wado-creator": "workspace:*", - "@radicalimaging/static-wado-util": "workspace:*", + "@radicalimaging/cs3d": ">=1.7.6", + "@radicalimaging/static-wado-creator": ">=1.7.6", + "@radicalimaging/static-wado-util": ">=1.7.6", "adm-zip": "0.5.16", "canvas": "3.1.0", "commander": "12.1.0", diff --git a/packages/healthlake/package.json b/packages/healthlake/package.json index b2617e8f..d994f534 100644 --- a/packages/healthlake/package.json +++ b/packages/healthlake/package.json @@ -55,10 +55,10 @@ "verbose": true }, "dependencies": { - "@radicalimaging/s3-deploy": "workspace:*", - "@radicalimaging/static-wado-deploy": "workspace:*", - "@radicalimaging/static-wado-plugins": "workspace:*", - "@radicalimaging/static-wado-util": "workspace:*", + "@radicalimaging/s3-deploy": ">=1.7.6", + "@radicalimaging/static-wado-deploy": ">=1.7.6", + "@radicalimaging/static-wado-plugins": ">=1.7.6", + "@radicalimaging/static-wado-util": ">=1.7.6", "config-point": "0.8.0", "cross-env": "7.0.3", "dcmjs": "0.50.1", diff --git a/packages/s3-deploy/package.json b/packages/s3-deploy/package.json index 8a1b1a3c..79bc247a 100644 --- a/packages/s3-deploy/package.json +++ b/packages/s3-deploy/package.json @@ -84,8 +84,8 @@ "dependencies": { "@aws-sdk/client-s3": "3.975.0", "@aws-sdk/lib-storage": "3.975.0", - "@radicalimaging/static-wado-plugins": "workspace:*", - "@radicalimaging/static-wado-util": "workspace:*", + "@radicalimaging/static-wado-plugins": ">=1.7.6", + "@radicalimaging/static-wado-util": ">=1.7.6", "aws-cdk-lib": "2.251.0", "config-point": "0.8.0", "constructs": "10.4.5", diff --git a/packages/static-wado-creator/package.json b/packages/static-wado-creator/package.json index dd64ba8a..c35976f2 100644 --- a/packages/static-wado-creator/package.json +++ b/packages/static-wado-creator/package.json @@ -66,8 +66,8 @@ "verbose": true }, "dependencies": { - "@radicalimaging/cs3d": "workspace:*", - "@radicalimaging/static-wado-util": "workspace:*", + "@radicalimaging/cs3d": ">=1.7.6", + "@radicalimaging/static-wado-util": ">=1.7.6", "canvas": "3.1.0", "config-point": "0.8.0", "dcmjs": "0.50.1", diff --git a/packages/static-wado-deploy/package.json b/packages/static-wado-deploy/package.json index 9d99d1ba..620dc49b 100644 --- a/packages/static-wado-deploy/package.json +++ b/packages/static-wado-deploy/package.json @@ -53,9 +53,9 @@ "verbose": true }, "dependencies": { - "@radicalimaging/s3-deploy": "workspace:*", - "@radicalimaging/static-wado-plugins": "workspace:*", - "@radicalimaging/static-wado-util": "workspace:*", + "@radicalimaging/s3-deploy": ">=1.7.6", + "@radicalimaging/static-wado-plugins": ">=1.7.6", + "@radicalimaging/static-wado-util": ">=1.7.6", "config-point": "0.8.0", "node-object-hash": "3.1.1" }, diff --git a/packages/static-wado-plugins/package.json b/packages/static-wado-plugins/package.json index 02e6fb22..25701f39 100644 --- a/packages/static-wado-plugins/package.json +++ b/packages/static-wado-plugins/package.json @@ -69,7 +69,7 @@ "verbose": true }, "dependencies": { - "@radicalimaging/static-wado-util": "workspace:*", + "@radicalimaging/static-wado-util": ">=1.7.6", "config-point": "0.8.0", "dcmjs-dimse": "0.1.31", "http-proxy-middleware": "2.0.9" diff --git a/packages/static-wado-scp/package.json b/packages/static-wado-scp/package.json index 8631dfe2..714d5224 100644 --- a/packages/static-wado-scp/package.json +++ b/packages/static-wado-scp/package.json @@ -63,9 +63,9 @@ "publishJS": "yarn publish --no-git-tag-version" }, "dependencies": { - "@radicalimaging/static-wado-creator": "workspace:*", - "@radicalimaging/static-wado-plugins": "workspace:*", - "@radicalimaging/static-wado-util": "workspace:*", + "@radicalimaging/static-wado-creator": ">=1.7.6", + "@radicalimaging/static-wado-plugins": ">=1.7.6", + "@radicalimaging/static-wado-util": ">=1.7.6", "config-point": "0.8.0", "dcmjs": "0.50.1", "dcmjs-dimse": "0.1.31" diff --git a/packages/static-wado-webserver/lib/controllers/server/multipartStream.mjs b/packages/static-wado-webserver/lib/controllers/server/multipartStream.mjs index 5f36fbcb..df654d15 100644 --- a/packages/static-wado-webserver/lib/controllers/server/multipartStream.mjs +++ b/packages/static-wado-webserver/lib/controllers/server/multipartStream.mjs @@ -1,4 +1,4 @@ -import Dicer from '../../vendor/dicer-compat/Dicer.mjs'; +import Dicer from '../../vendor/dicer/Dicer.js'; import { randomUUID } from 'node:crypto'; import { data } from 'dcmjs'; import { parse as parseContentType } from 'content-type'; diff --git a/packages/static-wado-webserver/lib/vendor/dicer-compat/LICENSE.dicer.md b/packages/static-wado-webserver/lib/vendor/dicer-compat/LICENSE.dicer.md deleted file mode 100644 index a1edc855..00000000 --- a/packages/static-wado-webserver/lib/vendor/dicer-compat/LICENSE.dicer.md +++ /dev/null @@ -1,7 +0,0 @@ -Vendored from dicer (MIT), https://github.com/mscdex/dicer - -Copyright Brian White. See upstream LICENSE in the dicer repository. - -This copy is inlined to avoid the unmaintained `dicer` npm package (GHSA-wm7h-9275-46v2) -while preserving streaming multipart parsing behavior required for STOW-RS -(`multipart/related`), which differs from `busboy`'s `multipart/form-data` parser. diff --git a/packages/static-wado-webserver/lib/vendor/dicer/Dicer.js b/packages/static-wado-webserver/lib/vendor/dicer/Dicer.js new file mode 100644 index 00000000..94726193 --- /dev/null +++ b/packages/static-wado-webserver/lib/vendor/dicer/Dicer.js @@ -0,0 +1,214 @@ +import { Writable } from 'node:stream'; +import StreamSearch from 'streamsearch'; +import PartStream from './PartStream.js'; +import HeaderParser from './HeaderParser.js'; +const DASH = 45; +const B_ONEDASH = Buffer.from('-'); +const B_CRLF = Buffer.from('\r\n'); +const EMPTY_FN = () => { }; +export default class Dicer extends Writable { + constructor(cfg) { + super(cfg); + this._dashes = 0; + this._parts = 0; + this._finished = false; + this._realFinish = false; + this._isPreamble = true; + this._justMatched = false; + this._firstWrite = true; + this._inHeader = true; + this._ignoreData = false; + this._partOpts = {}; + this._pause = false; + if (!cfg || (!cfg.headerFirst && typeof cfg.boundary !== 'string')) + throw new TypeError('Boundary required'); + if (typeof cfg.boundary === 'string') + this.setBoundary(cfg.boundary); + else + this._bparser = undefined; + this._headerFirst = cfg.headerFirst; + this._partOpts = typeof cfg.partHwm === 'number' ? { highWaterMark: cfg.partHwm } : {}; + this._hparser = new HeaderParser(cfg); + this._hparser.on('header', (header) => { + this._inHeader = false; + this._part?.emit('header', header); + }); + this._hparser.on('error', (err) => { + if (this._part && !this._ignoreData) { + this._part.emit('error', err); + this._part.push(null); + } + this.emit('error', err); + }); + } + emit(event, ...args) { + if (event !== 'finish' || this._realFinish) { + return super.emit(event, ...args); + } + if (this._finished) + return false; + process.nextTick(() => { + this.emit('error', new Error('Unexpected end of multipart data')); + if (this._part && !this._ignoreData) { + const type = this._isPreamble ? 'Preamble' : 'Part'; + this._part.emit('error', new Error(`${type} terminated early due to unexpected end of multipart data`)); + this._part.push(null); + process.nextTick(() => { + this._realFinish = true; + this.emit('finish'); + this._realFinish = false; + }); + return; + } + this._realFinish = true; + this.emit('finish'); + this._realFinish = false; + }); + return false; + } + _write(data, _encoding, cb) { + if (!this._hparser || !this._bparser) + return cb(); + if (this._headerFirst && this._isPreamble) { + if (!this._part) { + this._part = new PartStream(this._partOpts); + if (this.listenerCount('preamble') > 0) + this.emit('preamble', this._part); + else + ignore(this); + } + const r = this._hparser.push(data); + if (!this._inHeader && r !== undefined && r < data.length) + data = data.subarray(r); + else + return cb(); + } + if (this._firstWrite) { + this._bparser.push(B_CRLF); + this._firstWrite = false; + } + this._bparser.push(data); + if (this._pause) + this._cb = cb; + else + cb(); + } + reset() { + this._part = undefined; + this._bparser = undefined; + this._hparser = undefined; + } + setBoundary(boundary) { + this._bparser = new StreamSearch(`\r\n--${boundary}`, onInfo.bind(this)); + } +} +function onInfo(isMatch, data, start, end) { + let buf; + let i = 0; + let r; + let ev; + let shouldWriteMore = true; + if (!this._part && this._justMatched && data) { + while (this._dashes < 2 && start + i < end) { + if (data[start + i] === DASH) { + ++i; + ++this._dashes; + } + else { + if (this._dashes) + buf = B_ONEDASH; + this._dashes = 0; + break; + } + } + if (this._dashes === 2) { + if (start + i < end && this.listenerCount('trailer') > 0) + this.emit('trailer', data.subarray(start + i, end)); + this.reset(); + this._finished = true; + if (this._parts === 0) { + this._realFinish = true; + this.emit('finish'); + this._realFinish = false; + } + } + if (this._dashes) + return; + } + if (this._justMatched) + this._justMatched = false; + if (!this._part) { + this._part = new PartStream(this._partOpts); + this._part._read = () => { + unpause(this); + }; + ev = this._isPreamble ? 'preamble' : 'part'; + if (this.listenerCount(ev) > 0) + this.emit(ev, this._part); + else + ignore(this); + if (!this._isPreamble) + this._inHeader = true; + } + const part = this._part; + const hparser = this._hparser; + if (data && start < end && !this._ignoreData && part && hparser) { + if (this._isPreamble || !this._inHeader) { + if (buf) + shouldWriteMore = part.push(buf) !== false; + shouldWriteMore = part.push(data.subarray(start, end)) !== false; + if (!shouldWriteMore) + this._pause = true; + } + else if (!this._isPreamble && this._inHeader) { + if (buf) + hparser.push(buf); + r = hparser.push(data.subarray(start, end)); + if (!this._inHeader && r !== undefined && r < end) + onInfo.call(this, false, data, start + r, end); + } + } + if (isMatch && part && hparser) { + hparser.reset(); + if (this._isPreamble) { + this._isPreamble = false; + } + else { + ++this._parts; + part.on('end', () => { + if (--this._parts === 0) { + if (this._finished) { + this._realFinish = true; + this.emit('finish'); + this._realFinish = false; + } + else { + unpause(this); + } + } + }); + } + part.push(null); + this._part = undefined; + this._ignoreData = false; + this._justMatched = true; + this._dashes = 0; + } +} +function ignore(self) { + if (self._part && !self._ignoreData) { + self._ignoreData = true; + self._part.on('error', EMPTY_FN); + self._part.resume(); + } +} +function unpause(self) { + if (!self._pause) + return; + self._pause = false; + if (self._cb) { + const cb = self._cb; + self._cb = undefined; + cb(); + } +} diff --git a/packages/static-wado-webserver/lib/vendor/dicer-compat/Dicer.mjs b/packages/static-wado-webserver/lib/vendor/dicer/Dicer.ts similarity index 64% rename from packages/static-wado-webserver/lib/vendor/dicer-compat/Dicer.mjs rename to packages/static-wado-webserver/lib/vendor/dicer/Dicer.ts index f7f09497..f3dc4b17 100644 --- a/packages/static-wado-webserver/lib/vendor/dicer-compat/Dicer.mjs +++ b/packages/static-wado-webserver/lib/vendor/dicer/Dicer.ts @@ -1,7 +1,8 @@ -import { Writable } from 'node:stream'; +import { Writable, type ReadableOptions } from 'node:stream'; import StreamSearch from 'streamsearch'; -import PartStream from './PartStream.mjs'; -import HeaderParser from './HeaderParser.mjs'; +import PartStream from './PartStream.js'; +import HeaderParser from './HeaderParser.js'; +import type { DicerConfig, MultipartHeaders } from './types.js'; const DASH = 45; const B_ONEDASH = Buffer.from('-'); @@ -9,7 +10,24 @@ const B_CRLF = Buffer.from('\r\n'); const EMPTY_FN = () => {}; export default class Dicer extends Writable { - constructor(cfg) { + _bparser?: StreamSearch; + _hparser?: HeaderParser; + _headerFirst?: boolean; + _dashes = 0; + _parts = 0; + _finished = false; + _realFinish = false; + _isPreamble = true; + _justMatched = false; + _firstWrite = true; + _inHeader = true; + _part?: PartStream; + _cb?: (error?: Error | null) => void; + _ignoreData = false; + _partOpts: ReadableOptions = {}; + _pause = false; + + constructor(cfg?: DicerConfig) { super(cfg); if (!cfg || (!cfg.headerFirst && typeof cfg.boundary !== 'string')) @@ -20,40 +38,28 @@ export default class Dicer extends Writable { this._headerFirst = cfg.headerFirst; - this._dashes = 0; - this._parts = 0; - this._finished = false; - this._realFinish = false; - this._isPreamble = true; - this._justMatched = false; - this._firstWrite = true; - this._inHeader = true; - this._part = undefined; - this._cb = undefined; - this._ignoreData = false; this._partOpts = typeof cfg.partHwm === 'number' ? { highWaterMark: cfg.partHwm } : {}; - this._pause = false; this._hparser = new HeaderParser(cfg); - this._hparser.on('header', header => { + this._hparser.on('header', (header: MultipartHeaders) => { this._inHeader = false; - this._part.emit('header', header); + this._part?.emit('header', header); }); - this._hparser.on('error', err => { + this._hparser.on('error', (err: Error) => { if (this._part && !this._ignoreData) { this._part.emit('error', err); this._part.push(null); } + this.emit('error', err); }); } - emit(ev) { - if (ev !== 'finish' || this._realFinish) { - Writable.prototype.emit.apply(this, arguments); - return; + emit(event: string | symbol, ...args: unknown[]): boolean { + if (event !== 'finish' || this._realFinish) { + return super.emit(event, ...args); } - if (this._finished) return; + if (this._finished) return false; process.nextTick(() => { this.emit('error', new Error('Unexpected end of multipart data')); @@ -79,19 +85,21 @@ export default class Dicer extends Writable { this.emit('finish'); this._realFinish = false; }); + + return false; } - _write(data, encoding, cb) { - if (!this._hparser && !this._bparser) return cb(); + _write(data: Buffer, _encoding: BufferEncoding, cb: (error?: Error | null) => void): void { + if (!this._hparser || !this._bparser) return cb(); if (this._headerFirst && this._isPreamble) { if (!this._part) { this._part = new PartStream(this._partOpts); - if (this._events.preamble) this.emit('preamble', this._part); + if (this.listenerCount('preamble') > 0) this.emit('preamble', this._part); else ignore(this); } const r = this._hparser.push(data); - if (!this._inHeader && r !== undefined && r < data.length) data = data.slice(r); + if (!this._inHeader && r !== undefined && r < data.length) data = data.subarray(r); else return cb(); } @@ -106,22 +114,28 @@ export default class Dicer extends Writable { else cb(); } - reset() { + reset(): void { this._part = undefined; this._bparser = undefined; this._hparser = undefined; } - setBoundary(boundary) { + setBoundary(boundary: string): void { this._bparser = new StreamSearch(`\r\n--${boundary}`, onInfo.bind(this)); } } -function onInfo(isMatch, data, start, end) { - let buf; +function onInfo( + this: Dicer, + isMatch: boolean, + data: Buffer | false, + start: number, + end: number +): void { + let buf: Buffer | undefined; let i = 0; - let r; - let ev; + let r: number | undefined; + let ev: 'preamble' | 'part'; let shouldWriteMore = true; if (!this._part && this._justMatched && data) { @@ -136,8 +150,8 @@ function onInfo(isMatch, data, start, end) { } } if (this._dashes === 2) { - if (start + i < end && this._events.trailer) - this.emit('trailer', data.slice(start + i, end)); + if (start + i < end && this.listenerCount('trailer') > 0) + this.emit('trailer', data.subarray(start + i, end)); this.reset(); this._finished = true; if (this._parts === 0) { @@ -155,29 +169,31 @@ function onInfo(isMatch, data, start, end) { unpause(this); }; ev = this._isPreamble ? 'preamble' : 'part'; - if (this._events[ev]) this.emit(ev, this._part); + if (this.listenerCount(ev) > 0) this.emit(ev, this._part); else ignore(this); if (!this._isPreamble) this._inHeader = true; } - if (data && start < end && !this._ignoreData) { + const part = this._part; + const hparser = this._hparser; + if (data && start < end && !this._ignoreData && part && hparser) { if (this._isPreamble || !this._inHeader) { - if (buf) shouldWriteMore = this._part.push(buf); - shouldWriteMore = this._part.push(data.slice(start, end)); + if (buf) shouldWriteMore = part.push(buf) !== false; + shouldWriteMore = part.push(data.subarray(start, end)) !== false; if (!shouldWriteMore) this._pause = true; } else if (!this._isPreamble && this._inHeader) { - if (buf) this._hparser.push(buf); - r = this._hparser.push(data.slice(start, end)); + if (buf) hparser.push(buf); + r = hparser.push(data.subarray(start, end)); if (!this._inHeader && r !== undefined && r < end) onInfo.call(this, false, data, start + r, end); } } - if (isMatch) { - this._hparser.reset(); + if (isMatch && part && hparser) { + hparser.reset(); if (this._isPreamble) { this._isPreamble = false; } else { ++this._parts; - this._part.on('end', () => { + part.on('end', () => { if (--this._parts === 0) { if (this._finished) { this._realFinish = true; @@ -189,7 +205,7 @@ function onInfo(isMatch, data, start, end) { } }); } - this._part.push(null); + part.push(null); this._part = undefined; this._ignoreData = false; this._justMatched = true; @@ -197,7 +213,7 @@ function onInfo(isMatch, data, start, end) { } } -function ignore(self) { +function ignore(self: Dicer): void { if (self._part && !self._ignoreData) { self._ignoreData = true; self._part.on('error', EMPTY_FN); @@ -205,7 +221,7 @@ function ignore(self) { } } -function unpause(self) { +function unpause(self: Dicer): void { if (!self._pause) return; self._pause = false; diff --git a/packages/static-wado-webserver/lib/vendor/dicer/HeaderParser.js b/packages/static-wado-webserver/lib/vendor/dicer/HeaderParser.js new file mode 100644 index 00000000..4a7bc0ad --- /dev/null +++ b/packages/static-wado-webserver/lib/vendor/dicer/HeaderParser.js @@ -0,0 +1,112 @@ +import { EventEmitter } from 'node:events'; +import StreamSearch from 'streamsearch'; +const B_DCRLF = Buffer.from('\r\n\r\n'); +const RE_CRLF = /\r\n/g; +const RE_HDR = /^([^:]+):[ \t]?([\x00-\xFF]+)?$/; +const MAX_HEADER_PAIRS = 2000; +const MAX_HEADER_SIZE = 64 * 1024; +export default class HeaderParser extends EventEmitter { + constructor(cfg) { + super(); + this.nread = 0; + this.npairs = 0; + this.buffer = ''; + this.header = {}; + this.finished = false; + this.maxHeaderPairs = + cfg && typeof cfg.maxHeaderPairs === 'number' ? cfg.maxHeaderPairs : MAX_HEADER_PAIRS; + this.ss = new StreamSearch(B_DCRLF, (isMatch, data, start, end) => { + if (this.finished) + return; + if (data) { + const chunkLen = end - start; + if (this.nread + chunkLen > MAX_HEADER_SIZE) { + this._abortOversized(); + return; + } + this.nread += chunkLen; + this.buffer += data.toString('latin1', start, end); + } + if (isMatch) + this._finish(); + }); + } + _abortOversized() { + if (this.finished) + return; + this.finished = true; + this.emit('error', new Error(`Multipart part header exceeds maximum size of ${MAX_HEADER_SIZE} bytes`)); + } + push(data) { + const r = this.ss.push(data); + if (this.finished) + return r; + } + reset() { + this.finished = false; + this.buffer = ''; + this.header = {}; + this.ss.reset(); + } + _finish() { + let hadError = false; + if (this.buffer) + hadError = !parseHeader(this); + this.ss.matches = this.ss.maxMatches; + const header = this.header; + this.header = {}; + this.buffer = ''; + this.finished = true; + this.nread = this.npairs = 0; + if (!hadError) + this.emit('header', header); + } +} +function parseHeader(self) { + if (self.npairs === self.maxHeaderPairs) + return true; + const lines = self.buffer.split(RE_CRLF); + const len = lines.length; + let m; + let h; + let modded = false; + for (let i = 0; i < len; ++i) { + const line = lines[i]; + if (line.length === 0) + continue; + if (line[0] === '\t' || line[0] === ' ') { + if (!h) { + self.emit('error', new Error('Unexpected folded header value')); + return false; + } + const values = self.header[h]; + if (values) + values[values.length - 1] += line; + } + else { + m = RE_HDR.exec(line); + if (m) { + h = m[1].toLowerCase(); + if (m[2]) { + if (self.header[h] === undefined) + self.header[h] = [m[2]]; + else + self.header[h].push(m[2]); + } + else { + self.header[h] = ['']; + } + if (++self.npairs === self.maxHeaderPairs) + break; + } + else { + self.buffer = line; + modded = true; + break; + } + } + } + if (!modded) + self.buffer = ''; + return true; +} diff --git a/packages/static-wado-webserver/lib/vendor/dicer-compat/HeaderParser.mjs b/packages/static-wado-webserver/lib/vendor/dicer/HeaderParser.ts similarity index 61% rename from packages/static-wado-webserver/lib/vendor/dicer-compat/HeaderParser.mjs rename to packages/static-wado-webserver/lib/vendor/dicer/HeaderParser.ts index e74fc02c..22aeb144 100644 --- a/packages/static-wado-webserver/lib/vendor/dicer-compat/HeaderParser.mjs +++ b/packages/static-wado-webserver/lib/vendor/dicer/HeaderParser.ts @@ -1,56 +1,66 @@ import { EventEmitter } from 'node:events'; import StreamSearch from 'streamsearch'; +import type { HeaderParserConfig, MultipartHeaders } from './types.js'; const B_DCRLF = Buffer.from('\r\n\r\n'); const RE_CRLF = /\r\n/g; // eslint-disable-next-line no-control-regex const RE_HDR = /^([^:]+):[ \t]?([\x00-\xFF]+)?$/; const MAX_HEADER_PAIRS = 2000; // From node's http.js -// Tighter than legacy dicer (80 KiB); matches busboy's multipart header cap -const MAX_HEADER_SIZE = 16 * 1024; +const MAX_HEADER_SIZE = 64 * 1024; export default class HeaderParser extends EventEmitter { - constructor(cfg) { + nread = 0; + npairs = 0; + maxHeaderPairs: number; + buffer = ''; + header: MultipartHeaders = {}; + finished = false; + ss: StreamSearch; + + constructor(cfg?: HeaderParserConfig) { super(); - this.nread = 0; - this.maxed = false; - this.npairs = 0; this.maxHeaderPairs = cfg && typeof cfg.maxHeaderPairs === 'number' ? cfg.maxHeaderPairs : MAX_HEADER_PAIRS; - this.buffer = ''; - this.header = {}; - this.finished = false; this.ss = new StreamSearch(B_DCRLF, (isMatch, data, start, end) => { - if (data && !this.maxed) { - if (this.nread + (end - start) > MAX_HEADER_SIZE) { - end = MAX_HEADER_SIZE - this.nread; - this.nread = MAX_HEADER_SIZE; - } else { - this.nread += end - start; - } - - if (this.nread === MAX_HEADER_SIZE) this.maxed = true; + if (this.finished) return; + if (data) { + const chunkLen = end - start; + if (this.nread + chunkLen > MAX_HEADER_SIZE) { + this._abortOversized(); + return; + } + this.nread += chunkLen; this.buffer += data.toString('latin1', start, end); } if (isMatch) this._finish(); }); } - push(data) { + _abortOversized(): void { + if (this.finished) return; + this.finished = true; + this.emit( + 'error', + new Error(`Multipart part header exceeds maximum size of ${MAX_HEADER_SIZE} bytes`) + ); + } + + push(data: Buffer): number | undefined { const r = this.ss.push(data); if (this.finished) return r; } - reset() { + reset(): void { this.finished = false; this.buffer = ''; this.header = {}; this.ss.reset(); } - _finish() { + _finish(): void { let hadError = false; if (this.buffer) hadError = !parseHeader(this); this.ss.matches = this.ss.maxMatches; @@ -59,31 +69,32 @@ export default class HeaderParser extends EventEmitter { this.buffer = ''; this.finished = true; this.nread = this.npairs = 0; - this.maxed = false; if (!hadError) this.emit('header', header); } } -function parseHeader(self) { +function parseHeader(self: HeaderParser): boolean { if (self.npairs === self.maxHeaderPairs) return true; const lines = self.buffer.split(RE_CRLF); const len = lines.length; - let m; - let h; + let m: RegExpExecArray | null; + let h: string | undefined; let modded = false; for (let i = 0; i < len; ++i) { - if (lines[i].length === 0) continue; + const line = lines[i]; + if (line.length === 0) continue; - if (lines[i][0] === '\t' || lines[i][0] === ' ') { + if (line[0] === '\t' || line[0] === ' ') { if (!h) { self.emit('error', new Error('Unexpected folded header value')); return false; } - self.header[h][self.header[h].length - 1] += lines[i]; + const values = self.header[h]; + if (values) values[values.length - 1] += line; } else { - m = RE_HDR.exec(lines[i]); + m = RE_HDR.exec(line); if (m) { h = m[1].toLowerCase(); if (m[2]) { @@ -94,7 +105,7 @@ function parseHeader(self) { } if (++self.npairs === self.maxHeaderPairs) break; } else { - self.buffer = lines[i]; + self.buffer = line; modded = true; break; } diff --git a/packages/static-wado-webserver/lib/vendor/dicer/LICENSE b/packages/static-wado-webserver/lib/vendor/dicer/LICENSE new file mode 100644 index 00000000..f261aaec --- /dev/null +++ b/packages/static-wado-webserver/lib/vendor/dicer/LICENSE @@ -0,0 +1,31 @@ +DICER COMPONENT LICENSE + +The MIT License below applies only to the dicer multipart parser files +in this directory (the "Dicer Component"). It does not apply to +@radicalimaging/static-wado-webserver, the static-dicomweb project, or +any other software outside this directory. + +This component is derived from dicer by Brian White +(https://github.com/mscdex/dicer). + +--- + +Copyright Brian White. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +IN THE SOFTWARE. diff --git a/packages/static-wado-webserver/lib/vendor/dicer/LICENSE.dicer.md b/packages/static-wado-webserver/lib/vendor/dicer/LICENSE.dicer.md new file mode 100644 index 00000000..2885bf24 --- /dev/null +++ b/packages/static-wado-webserver/lib/vendor/dicer/LICENSE.dicer.md @@ -0,0 +1,5 @@ +# Dicer component license + +The MIT license in [LICENSE](./LICENSE) applies **only** to the dicer multipart parser in this directory. It does **not** apply to `@radicalimaging/static-wado-webserver`, the static-dicomweb monorepo, or any other bundled software. + +Maintained fork of [dicer](https://github.com/mscdex/dicer) by Brian White. Standalone package source: `z:\src\dicer` (releases TBD). diff --git a/packages/static-wado-webserver/lib/vendor/dicer-compat/PartStream.mjs b/packages/static-wado-webserver/lib/vendor/dicer/PartStream.js similarity index 81% rename from packages/static-wado-webserver/lib/vendor/dicer-compat/PartStream.mjs rename to packages/static-wado-webserver/lib/vendor/dicer/PartStream.js index 0d0baee2..1416cf45 100644 --- a/packages/static-wado-webserver/lib/vendor/dicer-compat/PartStream.mjs +++ b/packages/static-wado-webserver/lib/vendor/dicer/PartStream.js @@ -1,5 +1,4 @@ import { Readable } from 'node:stream'; - export default class PartStream extends Readable { - _read(n) {} + _read(_size) { } } diff --git a/packages/static-wado-webserver/lib/vendor/dicer/PartStream.ts b/packages/static-wado-webserver/lib/vendor/dicer/PartStream.ts new file mode 100644 index 00000000..6c627080 --- /dev/null +++ b/packages/static-wado-webserver/lib/vendor/dicer/PartStream.ts @@ -0,0 +1,5 @@ +import { Readable } from 'node:stream'; + +export default class PartStream extends Readable { + _read(_size: number): void {} +} diff --git a/packages/static-wado-webserver/lib/vendor/dicer/index.js b/packages/static-wado-webserver/lib/vendor/dicer/index.js new file mode 100644 index 00000000..00c0aa7c --- /dev/null +++ b/packages/static-wado-webserver/lib/vendor/dicer/index.js @@ -0,0 +1,4 @@ +export { default } from './Dicer.js'; +export { default as Dicer } from './Dicer.js'; +export { default as HeaderParser } from './HeaderParser.js'; +export { default as PartStream } from './PartStream.js'; diff --git a/packages/static-wado-webserver/lib/vendor/dicer/index.ts b/packages/static-wado-webserver/lib/vendor/dicer/index.ts new file mode 100644 index 00000000..e0260e54 --- /dev/null +++ b/packages/static-wado-webserver/lib/vendor/dicer/index.ts @@ -0,0 +1,5 @@ +export { default } from './Dicer.js'; +export { default as Dicer } from './Dicer.js'; +export { default as HeaderParser } from './HeaderParser.js'; +export { default as PartStream } from './PartStream.js'; +export type { DicerConfig, HeaderParserConfig, MultipartHeaders } from './types.js'; diff --git a/packages/static-wado-webserver/lib/vendor/dicer/streamsearch.d.ts b/packages/static-wado-webserver/lib/vendor/dicer/streamsearch.d.ts new file mode 100644 index 00000000..020f2715 --- /dev/null +++ b/packages/static-wado-webserver/lib/vendor/dicer/streamsearch.d.ts @@ -0,0 +1,16 @@ +declare module 'streamsearch' { + type StreamSearchCallback = ( + isMatch: boolean, + data: Buffer | false, + start: number, + end: number + ) => void; + + export default class StreamSearch { + constructor(needle: string | Buffer, cb: StreamSearchCallback); + push(data: Buffer): number | undefined; + reset(): void; + matches: number; + maxMatches: number; + } +} diff --git a/packages/static-wado-webserver/lib/vendor/dicer/types.js b/packages/static-wado-webserver/lib/vendor/dicer/types.js new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/packages/static-wado-webserver/lib/vendor/dicer/types.js @@ -0,0 +1 @@ +export {}; diff --git a/packages/static-wado-webserver/lib/vendor/dicer/types.ts b/packages/static-wado-webserver/lib/vendor/dicer/types.ts new file mode 100644 index 00000000..ef82b402 --- /dev/null +++ b/packages/static-wado-webserver/lib/vendor/dicer/types.ts @@ -0,0 +1,14 @@ +import type { WritableOptions } from 'node:stream'; + +/** Parsed multipart part headers (lowercase names, array values). */ +export type MultipartHeaders = Record; + +export interface HeaderParserConfig { + maxHeaderPairs?: number; +} + +export interface DicerConfig extends WritableOptions, HeaderParserConfig { + boundary?: string; + headerFirst?: boolean; + partHwm?: number; +} diff --git a/packages/static-wado-webserver/package.json b/packages/static-wado-webserver/package.json index 522a6fde..50b3cacc 100644 --- a/packages/static-wado-webserver/package.json +++ b/packages/static-wado-webserver/package.json @@ -56,8 +56,9 @@ "unlink:exec": "bun unlink || echo Already unlinked", "link:exec": "bun run unlink:exec && bun link", "build": "bun run build:esm", - "build:esm": "bun -e \"import { mkdirSync, cpSync } from 'node:fs'; mkdirSync('dist', { recursive: true }); cpSync('lib', 'dist', { recursive: true });\"", - "clean": "rimraf dist", + "build:dicer": "tsc --project ./tsconfig.dicer.json", + "build:esm": "bun run build:dicer && bun -e \"import { mkdirSync, cpSync } from 'node:fs'; mkdirSync('dist', { recursive: true }); cpSync('lib', 'dist', { recursive: true });\"", + "clean": "rimraf dist lib/vendor/dicer/*.js", "lint": "npx eslint --color \"**/*.{js,mjs,cjs}\"", "lint:fix": "npx eslint --fix --color \"**/*.{js,mjs,cjs}\"", "pack:js": "npm pack --", @@ -65,9 +66,9 @@ }, "dependencies": { "@cornerstonejs/dicom-codec": "1.0.9", - "@radicalimaging/create-dicomweb": "workspace:*", - "@radicalimaging/static-wado-plugins": "workspace:*", - "@radicalimaging/static-wado-util": "workspace:*", + "@radicalimaging/create-dicomweb": ">=1.7.6", + "@radicalimaging/static-wado-plugins": ">=1.7.6", + "@radicalimaging/static-wado-util": ">=1.7.6", "@saga-bus/core": "0.2.2", "config-point": "0.8.0", "content-type": "1.0.5", diff --git a/packages/static-wado-webserver/tsconfig.dicer.json b/packages/static-wado-webserver/tsconfig.dicer.json new file mode 100644 index 00000000..953bf287 --- /dev/null +++ b/packages/static-wado-webserver/tsconfig.dicer.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./lib/vendor/dicer", + "rootDir": "./lib/vendor/dicer", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": false, + "skipLibCheck": true + }, + "include": ["./lib/vendor/dicer/**/*.ts"] +} From d9a51958bc9ff51d02c7d5d7d467c93b56879b54 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Tue, 19 May 2026 09:18:23 -0400 Subject: [PATCH 7/9] Move to typescript dist output for dicer --- .../lib/vendor/dicer/Dicer.js | 214 ------------------ .../lib/vendor/dicer/HeaderParser.js | 112 --------- .../lib/vendor/dicer/LICENSE.dicer.md | 2 +- .../lib/vendor/dicer/PartStream.js | 4 - .../lib/vendor/dicer/index.js | 4 - .../lib/vendor/dicer/types.js | 1 - packages/static-wado-webserver/package.json | 9 +- .../{tsconfig.dicer.json => tsconfig.json} | 10 +- 8 files changed, 11 insertions(+), 345 deletions(-) delete mode 100644 packages/static-wado-webserver/lib/vendor/dicer/Dicer.js delete mode 100644 packages/static-wado-webserver/lib/vendor/dicer/HeaderParser.js delete mode 100644 packages/static-wado-webserver/lib/vendor/dicer/PartStream.js delete mode 100644 packages/static-wado-webserver/lib/vendor/dicer/index.js delete mode 100644 packages/static-wado-webserver/lib/vendor/dicer/types.js rename packages/static-wado-webserver/{tsconfig.dicer.json => tsconfig.json} (52%) diff --git a/packages/static-wado-webserver/lib/vendor/dicer/Dicer.js b/packages/static-wado-webserver/lib/vendor/dicer/Dicer.js deleted file mode 100644 index 94726193..00000000 --- a/packages/static-wado-webserver/lib/vendor/dicer/Dicer.js +++ /dev/null @@ -1,214 +0,0 @@ -import { Writable } from 'node:stream'; -import StreamSearch from 'streamsearch'; -import PartStream from './PartStream.js'; -import HeaderParser from './HeaderParser.js'; -const DASH = 45; -const B_ONEDASH = Buffer.from('-'); -const B_CRLF = Buffer.from('\r\n'); -const EMPTY_FN = () => { }; -export default class Dicer extends Writable { - constructor(cfg) { - super(cfg); - this._dashes = 0; - this._parts = 0; - this._finished = false; - this._realFinish = false; - this._isPreamble = true; - this._justMatched = false; - this._firstWrite = true; - this._inHeader = true; - this._ignoreData = false; - this._partOpts = {}; - this._pause = false; - if (!cfg || (!cfg.headerFirst && typeof cfg.boundary !== 'string')) - throw new TypeError('Boundary required'); - if (typeof cfg.boundary === 'string') - this.setBoundary(cfg.boundary); - else - this._bparser = undefined; - this._headerFirst = cfg.headerFirst; - this._partOpts = typeof cfg.partHwm === 'number' ? { highWaterMark: cfg.partHwm } : {}; - this._hparser = new HeaderParser(cfg); - this._hparser.on('header', (header) => { - this._inHeader = false; - this._part?.emit('header', header); - }); - this._hparser.on('error', (err) => { - if (this._part && !this._ignoreData) { - this._part.emit('error', err); - this._part.push(null); - } - this.emit('error', err); - }); - } - emit(event, ...args) { - if (event !== 'finish' || this._realFinish) { - return super.emit(event, ...args); - } - if (this._finished) - return false; - process.nextTick(() => { - this.emit('error', new Error('Unexpected end of multipart data')); - if (this._part && !this._ignoreData) { - const type = this._isPreamble ? 'Preamble' : 'Part'; - this._part.emit('error', new Error(`${type} terminated early due to unexpected end of multipart data`)); - this._part.push(null); - process.nextTick(() => { - this._realFinish = true; - this.emit('finish'); - this._realFinish = false; - }); - return; - } - this._realFinish = true; - this.emit('finish'); - this._realFinish = false; - }); - return false; - } - _write(data, _encoding, cb) { - if (!this._hparser || !this._bparser) - return cb(); - if (this._headerFirst && this._isPreamble) { - if (!this._part) { - this._part = new PartStream(this._partOpts); - if (this.listenerCount('preamble') > 0) - this.emit('preamble', this._part); - else - ignore(this); - } - const r = this._hparser.push(data); - if (!this._inHeader && r !== undefined && r < data.length) - data = data.subarray(r); - else - return cb(); - } - if (this._firstWrite) { - this._bparser.push(B_CRLF); - this._firstWrite = false; - } - this._bparser.push(data); - if (this._pause) - this._cb = cb; - else - cb(); - } - reset() { - this._part = undefined; - this._bparser = undefined; - this._hparser = undefined; - } - setBoundary(boundary) { - this._bparser = new StreamSearch(`\r\n--${boundary}`, onInfo.bind(this)); - } -} -function onInfo(isMatch, data, start, end) { - let buf; - let i = 0; - let r; - let ev; - let shouldWriteMore = true; - if (!this._part && this._justMatched && data) { - while (this._dashes < 2 && start + i < end) { - if (data[start + i] === DASH) { - ++i; - ++this._dashes; - } - else { - if (this._dashes) - buf = B_ONEDASH; - this._dashes = 0; - break; - } - } - if (this._dashes === 2) { - if (start + i < end && this.listenerCount('trailer') > 0) - this.emit('trailer', data.subarray(start + i, end)); - this.reset(); - this._finished = true; - if (this._parts === 0) { - this._realFinish = true; - this.emit('finish'); - this._realFinish = false; - } - } - if (this._dashes) - return; - } - if (this._justMatched) - this._justMatched = false; - if (!this._part) { - this._part = new PartStream(this._partOpts); - this._part._read = () => { - unpause(this); - }; - ev = this._isPreamble ? 'preamble' : 'part'; - if (this.listenerCount(ev) > 0) - this.emit(ev, this._part); - else - ignore(this); - if (!this._isPreamble) - this._inHeader = true; - } - const part = this._part; - const hparser = this._hparser; - if (data && start < end && !this._ignoreData && part && hparser) { - if (this._isPreamble || !this._inHeader) { - if (buf) - shouldWriteMore = part.push(buf) !== false; - shouldWriteMore = part.push(data.subarray(start, end)) !== false; - if (!shouldWriteMore) - this._pause = true; - } - else if (!this._isPreamble && this._inHeader) { - if (buf) - hparser.push(buf); - r = hparser.push(data.subarray(start, end)); - if (!this._inHeader && r !== undefined && r < end) - onInfo.call(this, false, data, start + r, end); - } - } - if (isMatch && part && hparser) { - hparser.reset(); - if (this._isPreamble) { - this._isPreamble = false; - } - else { - ++this._parts; - part.on('end', () => { - if (--this._parts === 0) { - if (this._finished) { - this._realFinish = true; - this.emit('finish'); - this._realFinish = false; - } - else { - unpause(this); - } - } - }); - } - part.push(null); - this._part = undefined; - this._ignoreData = false; - this._justMatched = true; - this._dashes = 0; - } -} -function ignore(self) { - if (self._part && !self._ignoreData) { - self._ignoreData = true; - self._part.on('error', EMPTY_FN); - self._part.resume(); - } -} -function unpause(self) { - if (!self._pause) - return; - self._pause = false; - if (self._cb) { - const cb = self._cb; - self._cb = undefined; - cb(); - } -} diff --git a/packages/static-wado-webserver/lib/vendor/dicer/HeaderParser.js b/packages/static-wado-webserver/lib/vendor/dicer/HeaderParser.js deleted file mode 100644 index 4a7bc0ad..00000000 --- a/packages/static-wado-webserver/lib/vendor/dicer/HeaderParser.js +++ /dev/null @@ -1,112 +0,0 @@ -import { EventEmitter } from 'node:events'; -import StreamSearch from 'streamsearch'; -const B_DCRLF = Buffer.from('\r\n\r\n'); -const RE_CRLF = /\r\n/g; -const RE_HDR = /^([^:]+):[ \t]?([\x00-\xFF]+)?$/; -const MAX_HEADER_PAIRS = 2000; -const MAX_HEADER_SIZE = 64 * 1024; -export default class HeaderParser extends EventEmitter { - constructor(cfg) { - super(); - this.nread = 0; - this.npairs = 0; - this.buffer = ''; - this.header = {}; - this.finished = false; - this.maxHeaderPairs = - cfg && typeof cfg.maxHeaderPairs === 'number' ? cfg.maxHeaderPairs : MAX_HEADER_PAIRS; - this.ss = new StreamSearch(B_DCRLF, (isMatch, data, start, end) => { - if (this.finished) - return; - if (data) { - const chunkLen = end - start; - if (this.nread + chunkLen > MAX_HEADER_SIZE) { - this._abortOversized(); - return; - } - this.nread += chunkLen; - this.buffer += data.toString('latin1', start, end); - } - if (isMatch) - this._finish(); - }); - } - _abortOversized() { - if (this.finished) - return; - this.finished = true; - this.emit('error', new Error(`Multipart part header exceeds maximum size of ${MAX_HEADER_SIZE} bytes`)); - } - push(data) { - const r = this.ss.push(data); - if (this.finished) - return r; - } - reset() { - this.finished = false; - this.buffer = ''; - this.header = {}; - this.ss.reset(); - } - _finish() { - let hadError = false; - if (this.buffer) - hadError = !parseHeader(this); - this.ss.matches = this.ss.maxMatches; - const header = this.header; - this.header = {}; - this.buffer = ''; - this.finished = true; - this.nread = this.npairs = 0; - if (!hadError) - this.emit('header', header); - } -} -function parseHeader(self) { - if (self.npairs === self.maxHeaderPairs) - return true; - const lines = self.buffer.split(RE_CRLF); - const len = lines.length; - let m; - let h; - let modded = false; - for (let i = 0; i < len; ++i) { - const line = lines[i]; - if (line.length === 0) - continue; - if (line[0] === '\t' || line[0] === ' ') { - if (!h) { - self.emit('error', new Error('Unexpected folded header value')); - return false; - } - const values = self.header[h]; - if (values) - values[values.length - 1] += line; - } - else { - m = RE_HDR.exec(line); - if (m) { - h = m[1].toLowerCase(); - if (m[2]) { - if (self.header[h] === undefined) - self.header[h] = [m[2]]; - else - self.header[h].push(m[2]); - } - else { - self.header[h] = ['']; - } - if (++self.npairs === self.maxHeaderPairs) - break; - } - else { - self.buffer = line; - modded = true; - break; - } - } - } - if (!modded) - self.buffer = ''; - return true; -} diff --git a/packages/static-wado-webserver/lib/vendor/dicer/LICENSE.dicer.md b/packages/static-wado-webserver/lib/vendor/dicer/LICENSE.dicer.md index 2885bf24..474d499a 100644 --- a/packages/static-wado-webserver/lib/vendor/dicer/LICENSE.dicer.md +++ b/packages/static-wado-webserver/lib/vendor/dicer/LICENSE.dicer.md @@ -2,4 +2,4 @@ The MIT license in [LICENSE](./LICENSE) applies **only** to the dicer multipart parser in this directory. It does **not** apply to `@radicalimaging/static-wado-webserver`, the static-dicomweb monorepo, or any other bundled software. -Maintained fork of [dicer](https://github.com/mscdex/dicer) by Brian White. Standalone package source: `z:\src\dicer` (releases TBD). +Maintained fork of [dicer](https://github.com/mscdex/dicer) by Brian White. TypeScript sources live in this directory; `bun run build` (`tsc` on `lib/`) emits compiled dicer to `dist/vendor/dicer/`. Standalone package: `z:\src\dicer` (releases TBD). diff --git a/packages/static-wado-webserver/lib/vendor/dicer/PartStream.js b/packages/static-wado-webserver/lib/vendor/dicer/PartStream.js deleted file mode 100644 index 1416cf45..00000000 --- a/packages/static-wado-webserver/lib/vendor/dicer/PartStream.js +++ /dev/null @@ -1,4 +0,0 @@ -import { Readable } from 'node:stream'; -export default class PartStream extends Readable { - _read(_size) { } -} diff --git a/packages/static-wado-webserver/lib/vendor/dicer/index.js b/packages/static-wado-webserver/lib/vendor/dicer/index.js deleted file mode 100644 index 00c0aa7c..00000000 --- a/packages/static-wado-webserver/lib/vendor/dicer/index.js +++ /dev/null @@ -1,4 +0,0 @@ -export { default } from './Dicer.js'; -export { default as Dicer } from './Dicer.js'; -export { default as HeaderParser } from './HeaderParser.js'; -export { default as PartStream } from './PartStream.js'; diff --git a/packages/static-wado-webserver/lib/vendor/dicer/types.js b/packages/static-wado-webserver/lib/vendor/dicer/types.js deleted file mode 100644 index cb0ff5c3..00000000 --- a/packages/static-wado-webserver/lib/vendor/dicer/types.js +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/packages/static-wado-webserver/package.json b/packages/static-wado-webserver/package.json index 50b3cacc..12e96727 100644 --- a/packages/static-wado-webserver/package.json +++ b/packages/static-wado-webserver/package.json @@ -56,11 +56,10 @@ "unlink:exec": "bun unlink || echo Already unlinked", "link:exec": "bun run unlink:exec && bun link", "build": "bun run build:esm", - "build:dicer": "tsc --project ./tsconfig.dicer.json", - "build:esm": "bun run build:dicer && bun -e \"import { mkdirSync, cpSync } from 'node:fs'; mkdirSync('dist', { recursive: true }); cpSync('lib', 'dist', { recursive: true });\"", - "clean": "rimraf dist lib/vendor/dicer/*.js", - "lint": "npx eslint --color \"**/*.{js,mjs,cjs}\"", - "lint:fix": "npx eslint --fix --color \"**/*.{js,mjs,cjs}\"", + "build:esm": "tsc --project ./tsconfig.json", + "clean": "rimraf dist", + "lint": "npx eslint --color \"**/*.{js,mjs,cjs,ts}\"", + "lint:fix": "npx eslint --fix --color \"**/*.{js,mjs,cjs,ts}\"", "pack:js": "npm pack --", "publishJS": "yarn publish" }, diff --git a/packages/static-wado-webserver/tsconfig.dicer.json b/packages/static-wado-webserver/tsconfig.json similarity index 52% rename from packages/static-wado-webserver/tsconfig.dicer.json rename to packages/static-wado-webserver/tsconfig.json index 953bf287..60e4f24d 100644 --- a/packages/static-wado-webserver/tsconfig.dicer.json +++ b/packages/static-wado-webserver/tsconfig.json @@ -1,12 +1,14 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "outDir": "./lib/vendor/dicer", - "rootDir": "./lib/vendor/dicer", + "outDir": "./dist", + "rootDir": "./lib", "module": "ESNext", "moduleResolution": "bundler", + "allowJs": true, "declaration": false, - "skipLibCheck": true + "skipLibCheck": true, + "noCheck": true }, - "include": ["./lib/vendor/dicer/**/*.ts"] + "include": ["./lib/**/*"] } From ff1ea6c621bc6278e8493fb2a1fada6e4b1d6cb6 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Tue, 19 May 2026 09:26:05 -0400 Subject: [PATCH 8/9] Allow >= for workspace packages --- scripts/check-pinned-versions.sh | 35 ++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/scripts/check-pinned-versions.sh b/scripts/check-pinned-versions.sh index f11b06ed..4ad6f6e4 100644 --- a/scripts/check-pinned-versions.sh +++ b/scripts/check-pinned-versions.sh @@ -1,18 +1,47 @@ #!/usr/bin/env bash # Fails if any package.json dependency version is not pinned (no ^ ~ * >= ranges). # Allows workspace:, file:, npm:, and http(s): specifiers. +# Allows >=X.Y.Z when X.Y.Z matches a monorepo workspace package version. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +declare -A WORKSPACE_VERSIONS=() + +load_workspace_versions() { + local pkg name version + while IFS= read -r -d '' pkg; do + name="$(jq -r '.name // empty' "$pkg" 2>/dev/null || true)" + version="$(jq -r '.version // empty' "$pkg" 2>/dev/null || true)" + [[ -z "$name" || -z "$version" ]] && continue + WORKSPACE_VERSIONS["$name"]="$version" + done < <(find "$REPO_ROOT/packages" -mindepth 2 -maxdepth 2 -name package.json -print0 2>/dev/null || true) +} + +is_workspace_gte() { + local dep_name="$1" + local version="$2" + local ws_version min_version + + ws_version="${WORKSPACE_VERSIONS[$dep_name]:-}" + [[ -z "$ws_version" ]] && return 1 + [[ "$version" =~ ^\>=([0-9]+\.[0-9]+\.[0-9]+(-[[:alnum:].-]+)?(\+[[:alnum:].-]+)?)$ ]] || return 1 + min_version="${BASH_REMATCH[1]}" + [[ "$min_version" == "$ws_version" ]] +} + is_pinned() { - local version="$1" + local dep_name="${1:-}" + local version="$2" [[ -z "$version" ]] && return 1 case "$version" in workspace:*|file:*|npm:*) return 0 ;; http://*|https://*) return 0 ;; esac + if [[ -n "$dep_name" ]] && is_workspace_gte "$dep_name" "$version"; then + return 0 + fi case "$version" in ^*) return 1 ;; *'~'*) return 1 ;; @@ -33,7 +62,7 @@ check_dep_block() { [[ -z "$entry" ]] && continue name="${entry%%$'\t'*}" version="${entry#*$'\t'}" - if ! is_pinned "$version"; then + if ! is_pinned "$name" "$version"; then echo "Unpinned dependency in ${pkg} ${block}: ${name} -> ${version}" >&2 block_failed=1 fi @@ -42,6 +71,8 @@ check_dep_block() { [[ "$block_failed" -eq 0 ]] } +load_workspace_versions + failed=0 while IFS= read -r -d '' pkg; do for block in dependencies devDependencies optionalDependencies peerDependencies overrides; do From 12e7ea104b38ec7bf369303de25f3a4e3bc07404 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Tue, 19 May 2026 09:34:44 -0400 Subject: [PATCH 9/9] test --- packages/static-wado-webserver/jest.config.js | 5 +++++ packages/static-wado-webserver/package.json | 1 + 2 files changed, 6 insertions(+) diff --git a/packages/static-wado-webserver/jest.config.js b/packages/static-wado-webserver/jest.config.js index 7aca0185..0df36f70 100644 --- a/packages/static-wado-webserver/jest.config.js +++ b/packages/static-wado-webserver/jest.config.js @@ -2,6 +2,11 @@ const baseConfig = require('../../.config/jest/jest.config.cjs'); module.exports = { ...baseConfig, + moduleNameMapper: { + ...baseConfig.moduleNameMapper, + // Vendored dicer: compiled to dist/ by tsc (see pretest); lib/ keeps TypeScript only + '^\\.\\./\\.\\./vendor/dicer/(.*)\\.js$': '/dist/vendor/dicer/$1.js', + }, moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx', 'mjs'], transformIgnorePatterns: [ // Transform all ESM modules in node_modules, including Bun's .bun directory structure diff --git a/packages/static-wado-webserver/package.json b/packages/static-wado-webserver/package.json index 12e96727..2b515d9d 100644 --- a/packages/static-wado-webserver/package.json +++ b/packages/static-wado-webserver/package.json @@ -52,6 +52,7 @@ "monitordicomwebserver": "bin/monitordicomwebserver.mjs" }, "scripts": { + "pretest": "bun run build:esm", "test": "jest --config ./jest.config.js", "unlink:exec": "bun unlink || echo Already unlinked", "link:exec": "bun run unlink:exec && bun link",