Checkpoint framework artifacts before perm cleanup

master
dev 5 months ago
parent c5dd0e337c
commit 6e29d73572

523
package-lock.json generated

@ -29,6 +29,7 @@
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.17.0",
"globals": "^17.4.0",
"jsdom": "^29.1.0",
"playwright": "^1.59.1",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.5.1",
@ -41,6 +42,57 @@
"vitest-browser-svelte": "^2.1.0"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@csstools/css-calc": "^3.2.0",
"@csstools/css-color-parser": "^4.1.0",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/generational-cache": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@blazediff/core": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz",
@ -48,6 +100,159 @@
"dev": true,
"license": "MIT"
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz",
"integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz",
"integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.0.2",
"@csstools/css-calc": "^3.2.0"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz",
"integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@ -231,6 +436,24 @@
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@exodus/bytes": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz",
"integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
@ -1382,6 +1605,16 @@
"node": "18 || 20 || >=22"
}
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
@ -1462,6 +1695,20 @@
"node": ">= 8"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@ -1481,6 +1728,20 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -1499,6 +1760,13 @@
}
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@ -1541,6 +1809,19 @@
"integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==",
"license": "MIT"
},
"node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-module-lexer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
@ -1950,6 +2231,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -1993,6 +2287,13 @@
"node": ">=0.10.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/is-reference": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
@ -2009,6 +2310,47 @@
"dev": true,
"license": "ISC"
},
"node_modules/jsdom": {
"version": "29.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.0.tgz",
"integrity": "sha512-YNUc7fB9QuvSSQWfrH0xF+TyABkxUwx8sswgIDaCrw4Hol8BghdZDkITtZheRJeMtzWlnTfsM3bBBusRvpO1wg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.1.11",
"@asamuzakjp/dom-selector": "^7.1.1",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
"@exodus/bytes": "^1.15.0",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.3.5",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.1",
"undici": "^7.25.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.1",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@ -2364,6 +2706,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lru-cache": {
"version": "11.3.5",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz",
"integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
@ -2382,6 +2734,13 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
@ -2512,6 +2871,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@ -2799,6 +3171,16 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rolldown": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
@ -2874,6 +3256,19 @@
"node": ">=6"
}
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@ -3093,6 +3488,13 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tabbable": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
@ -3143,6 +3545,26 @@
"node": ">=14.0.0"
}
},
"node_modules/tldts": {
"version": "7.0.28",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz",
"integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.0.28"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.0.28",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz",
"integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==",
"dev": true,
"license": "MIT"
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
@ -3153,6 +3575,32 @@
"node": ">=6"
}
},
"node_modules/tough-cookie": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@ -3225,6 +3673,16 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/undici": {
"version": "7.25.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@ -3469,12 +3927,60 @@
"vitest": "^4.0.0"
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/web-vitals": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.2.0.tgz",
"integrity": "sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==",
"license": "Apache-2.0"
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -3540,6 +4046,23 @@
}
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

@ -3,7 +3,10 @@
"private": true,
"version": "0.0.1",
"type": "module",
"sideEffects": ["**/*.css", "**/*.svelte"],
"sideEffects": [
"**/*.css",
"**/*.svelte"
],
"scripts": {
"dev": "vite dev",
"build": "vite build",
@ -11,6 +14,7 @@
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"dev:conn-chat": "node scripts/conn-chat-server.mjs",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"test:unit": "vitest",
@ -28,6 +32,7 @@
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.17.0",
"globals": "^17.4.0",
"jsdom": "^29.1.0",
"playwright": "^1.59.1",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.5.1",
@ -40,13 +45,13 @@
"vitest-browser-svelte": "^2.1.0"
},
"dependencies": {
"@sentry/browser": "^10.50.0",
"web-vitals": "^5.2.0",
"@floating-ui/core": "^1.7.1",
"@floating-ui/dom": "^1.7.1",
"@sentry/browser": "^10.50.0",
"csstype": "^3.2.3",
"esm-env": "^1.1.2",
"runed": "0.37.1",
"tabbable": "^6.2.0"
"tabbable": "^6.2.0",
"web-vitals": "^5.2.0"
}
}

@ -0,0 +1,312 @@
import { createHash, randomUUID } from 'node:crypto';
import { createServer } from 'node:http';
const HOST = process.env.CONN_CHAT_HOST ?? '127.0.0.1';
const PORT = Number(process.env.CONN_CHAT_PORT ?? 8787);
const WEBSOCKET_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const HEADER_UPGRADE = 'upgrade';
const HEADER_WEBSOCKET_KEY = 'sec-websocket-key';
const UPGRADE_WEBSOCKET = 'websocket';
const OPCODE_TEXT = 0x1;
const OPCODE_CLOSE = 0x8;
const OPCODE_PING = 0x9;
const OPCODE_PONG = 0xa;
const CLOSE_CODE_NORMAL = 1000;
const CLOSE_CODE_PROTOCOL_ERROR = 1002;
const FRAME_TYPE_AUTH = 'conn.auth';
const FRAME_TYPE_JOIN = 'conn.join';
const FRAME_TYPE_LEAVE = 'conn.leave';
const FRAME_TYPE_PING = 'conn.ping';
const FRAME_TYPE_PONG = 'conn.pong';
const SERVER_REPLY_EVENT = 'server.reply';
const CHAT_EVENT_MESSAGE = 'chat.message';
const CHAT_EVENT_SYSTEM = 'chat.system';
const CHAT_EVENT_TYPING = 'chat.typing';
const CHAT_EVENT_WHOAMI = 'chat.whoami';
const DEMO_EVENT_DISCONNECT = 'demo.disconnect';
const BOT_NAME = 'Ava Bot';
const DEFAULT_ROOM = 'room:lobby';
const DEFAULT_USER = 'Anon';
const BOT_REPLY_DELAY_MS = 450;
const FORCED_CLOSE_DELAY_MS = 80;
const clients = new Map();
function makeAcceptKey(key) {
return createHash('sha1').update(`${key}${WEBSOCKET_GUID}`).digest('base64');
}
function encodeFrame(opcode, payload = Buffer.alloc(0)) {
const data = Buffer.isBuffer(payload) ? payload : Buffer.from(String(payload));
const len = data.length;
let header;
if (len < 126) {
header = Buffer.alloc(2);
header[1] = len;
} else if (len <= 0xffff) {
header = Buffer.alloc(4);
header[1] = 126;
header.writeUInt16BE(len, 2);
} else {
header = Buffer.alloc(10);
header[1] = 127;
header.writeBigUInt64BE(BigInt(len), 2);
}
header[0] = 0x80 | opcode;
return Buffer.concat([header, data]);
}
function sendText(socket, text) {
if (socket.destroyed) return;
socket.write(encodeFrame(OPCODE_TEXT, text));
}
function sendJson(socket, frame) {
sendText(socket, JSON.stringify(frame));
}
function closeSocket(socket, code = CLOSE_CODE_NORMAL, reason = '') {
if (socket.destroyed) return;
const body = Buffer.alloc(2 + Buffer.byteLength(reason));
body.writeUInt16BE(code, 0);
body.write(reason, 2);
socket.write(encodeFrame(OPCODE_CLOSE, body));
socket.end();
}
function createMessage(user, text, kind) {
return {
id: randomUUID(),
user,
text,
at: Date.now(),
kind
};
}
function reply(socket, frame, payload, error) {
if (typeof frame.id !== 'string') return;
sendJson(socket, {
type: SERVER_REPLY_EVENT,
replyTo: frame.id,
payload,
error
});
}
function broadcast(room, frame) {
for (const [socket, client] of clients) {
if (client.room !== room) continue;
sendJson(socket, frame);
}
}
function broadcastSystem(room, text) {
broadcast(room, {
topic: room,
type: CHAT_EVENT_SYSTEM,
payload: createMessage('Sistema', text, 'system')
});
}
function parseFrames(client) {
const out = [];
let offset = 0;
const buffer = client.buffer;
while (offset + 2 <= buffer.length) {
const first = buffer[offset];
const second = buffer[offset + 1];
const opcode = first & 0x0f;
const masked = (second & 0x80) !== 0;
let len = second & 0x7f;
let cursor = offset + 2;
if (len === 126) {
if (cursor + 2 > buffer.length) break;
len = buffer.readUInt16BE(cursor);
cursor += 2;
} else if (len === 127) {
if (cursor + 8 > buffer.length) break;
const longLen = buffer.readBigUInt64BE(cursor);
if (longLen > BigInt(Number.MAX_SAFE_INTEGER)) {
throw new Error('websocket frame too large');
}
len = Number(longLen);
cursor += 8;
}
const maskOffset = cursor;
if (masked) cursor += 4;
if (cursor + len > buffer.length) break;
const payload = Buffer.from(buffer.subarray(cursor, cursor + len));
if (masked) {
const mask = buffer.subarray(maskOffset, maskOffset + 4);
for (let i = 0; i < payload.length; i += 1) payload[i] ^= mask[i % 4];
}
out.push({ opcode, payload });
offset = cursor + len;
}
client.buffer = buffer.subarray(offset);
return out;
}
function handleFrame(socket, rawFrame) {
const client = clients.get(socket);
if (client === undefined) return;
if (rawFrame.opcode === OPCODE_CLOSE) {
closeSocket(socket);
return;
}
if (rawFrame.opcode === OPCODE_PING) {
socket.write(encodeFrame(OPCODE_PONG, rawFrame.payload));
return;
}
if (rawFrame.opcode !== OPCODE_TEXT) return;
let frame;
try {
frame = JSON.parse(rawFrame.payload.toString('utf8'));
} catch {
closeSocket(socket, CLOSE_CODE_PROTOCOL_ERROR, 'invalid json');
return;
}
if (frame.type === FRAME_TYPE_AUTH) {
client.authed = true;
client.user = frame.payload?.user ?? client.user;
reply(socket, frame, { accepted: true, server: 'conn-chat-server' });
return;
}
if (frame.type === FRAME_TYPE_JOIN) {
const room = typeof frame.topic === 'string' ? frame.topic : DEFAULT_ROOM;
client.room = room;
client.user = frame.payload?.user ?? client.user;
broadcastSystem(room, `${client.user} ha entrado en ${room}.`);
return;
}
if (frame.type === FRAME_TYPE_LEAVE) {
const room = client.room;
client.room = null;
if (room !== null) broadcastSystem(room, `${client.user} ha salido de ${room}.`);
return;
}
if (frame.type === FRAME_TYPE_PING) {
sendJson(socket, { type: FRAME_TYPE_PONG, payload: { at: Date.now() } });
return;
}
if (frame.type === CHAT_EVENT_WHOAMI) {
reply(socket, frame, {
user: client.user,
room: client.room,
connection: client.id,
generation: client.generation
});
return;
}
if (frame.type === DEMO_EVENT_DISCONNECT) {
setTimeout(() => {
closeSocket(socket, CLOSE_CODE_NORMAL, 'demo disconnect');
}, FORCED_CLOSE_DELAY_MS);
return;
}
if (frame.type === CHAT_EVENT_MESSAGE) {
const room = typeof frame.topic === 'string' ? frame.topic : client.room;
if (room === null) return;
broadcast(room, frame);
broadcast(room, {
topic: room,
type: CHAT_EVENT_TYPING,
payload: { user: BOT_NAME, active: true }
});
setTimeout(() => {
broadcast(room, {
topic: room,
type: CHAT_EVENT_TYPING,
payload: { user: BOT_NAME, active: false }
});
broadcast(room, {
topic: room,
type: CHAT_EVENT_MESSAGE,
payload: createMessage(
BOT_NAME,
`Recibido por ${client.user}: "${frame.payload?.text ?? ''}"`,
'bot'
)
});
}, BOT_REPLY_DELAY_MS);
}
}
const server = createServer((_, response) => {
response.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
response.end('conn chat websocket server\n');
});
server.on('upgrade', (request, socket) => {
const upgrade = request.headers[HEADER_UPGRADE];
const key = request.headers[HEADER_WEBSOCKET_KEY];
if (upgrade !== UPGRADE_WEBSOCKET || typeof key !== 'string') {
socket.destroy();
return;
}
socket.write(
[
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
'Connection: Upgrade',
`Sec-WebSocket-Accept: ${makeAcceptKey(key)}`,
'',
''
].join('\r\n')
);
const client = {
id: randomUUID(),
user: DEFAULT_USER,
room: null,
authed: false,
generation: Date.now(),
buffer: Buffer.alloc(0)
};
clients.set(socket, client);
console.log(`[conn-chat] connected ${client.id}`);
socket.on('data', (chunk) => {
const current = clients.get(socket);
if (current === undefined) return;
current.buffer = Buffer.concat([current.buffer, chunk]);
try {
for (const frame of parseFrames(current)) handleFrame(socket, frame);
} catch (error) {
console.error('[conn-chat] protocol error', error);
closeSocket(socket, CLOSE_CODE_PROTOCOL_ERROR, 'protocol error');
}
});
socket.on('close', () => {
const current = clients.get(socket);
clients.delete(socket);
if (current?.room) broadcastSystem(current.room, `${current.user} se ha desconectado.`);
console.log(`[conn-chat] disconnected ${current?.id ?? 'unknown'}`);
});
socket.on('error', (error) => {
console.error('[conn-chat] socket error', error);
});
});
server.listen(PORT, HOST, () => {
console.log(`[conn-chat] ws://${HOST}:${PORT}`);
});

@ -30,15 +30,17 @@ App.dispose();
## What it composes
| Member | Always present | Default when not configured |
| -------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| -------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `App.Logger` | yes | engine default — `level: WARN` + `consoleTransport()`. Pass `{ level: LogLevel.NONE, transports: [] }` for silence |
| `App.Lang` | yes | mono — `t('a.b')` returns `'a.b'`, ` | fallback`honored,`ts(record)`returns first entry. DEV warns once per unresolved path via Logger under`lang.mono` |
| `App.Lang` | yes | mono — `t('a.b')` returns `'a.b'`, `t('a.b\|Fallback')` returns `'Fallback'`, and DEV warns once per unresolved path through Logger under `lang.mono` |
| `App.Formats` | yes | real, locale = `DEFAULT_LOCALE` (`'en-US'`) |
| `App.Frontend` | yes | real with default theme/mode/density |
| `App.Dom` | yes | real with default breakpoints |
| `App.Storage` | yes | in-memory adapter (resets on reload). Configure `storage: { adapter: localAdapter }` for real persistence; `onError` is wired through `Logger.error('storage', ...)` |
| `App.Http` | yes | engine default — `globalThis.fetch`, no `baseUrl`, idempotent-by-default retry, 10s per-attempt timeout. The shared `Logger` is wired automatically; configure `http: { baseUrl, timeout, retry, hooks }` for app-wide defaults |
| `App.Sess` | **opt-in** | only present when `sess: { userSchema, ... }` is configured. Auto-wires bearer header into `App.Http` (per attempt) and a 401-rescue hook (`createBeforeErrorHook(Sess)`) into `http.hooks.beforeError`. The shared Logger is injected. |
| `App.Http` | yes | engine default — `globalThis.fetch`, no `baseUrl`, idempotent-by-default retry, 10s per-attempt timeout. The shared `Logger` is wired automatically; configure `http: { baseUrl, timeout, retry }` |
| `App.Timers` | yes | `ActiveTimers` scheduler owned by App. Used by artifacts that need keyed runtime timers (`sess` auto-refresh, `conn` reconnect/heartbeat/ack) and disposed by `App.dispose()` |
| `App.Sess` | no | created lazily through `App.createActiveSession(...)`. Logger is injected automatically; storage, refresh/revoke handlers and HTTP hooks remain explicit so auth policy does not become hidden magic |
| `Connections` | no | created lazily through `App.createActiveConnections(...)`. App injects Logger, Timers and a structural session bridge; each connection opts into session behavior independently |
`Sium` is **not** a member of App. Validation is page-scoped — pages with
forms construct their own engine via the one-line `App.createSiumEngine()`
@ -68,6 +70,18 @@ The method lives on App rather than as a standalone helper because App is
already in scope on every page via context — `App.createSiumEngine()` is
the natural call site.
`Connections` is also lazy, but for the opposite reason: realtime is
application-scoped infrastructure, while the connection map is app-specific and
benefits from call-site generics:
```ts
const Connections = App.createActiveConnections<AppConnections>();
```
Each registry is disposed by `App.dispose()`. Individual connections decide
whether they react to session refresh/expire events via their own `session`
option.
## What it solves
- **Single locale source.** `App.setLocale('es-MX')` propagates to `Lang`,
@ -78,8 +92,9 @@ the natural call site.
Sentry, Datadog, ...).
- **Uniform call sites.** `App.Lang.t(label)` and `App.Formats.*` always work,
whether or not the caller configured i18n or fmts. No null checks.
- **Single lifecycle.** `App.dispose()` runs `Frontend → Formats → Lang →
Logger` in that order, flushing buffered transports before clearing them.
- **Single lifecycle.** `App.dispose()` tears down the optional session,
timers, persistence bridge, frontend, dom, formats, storage, lang and logger
in a deterministic order.
## Composition order
@ -95,16 +110,14 @@ Logger` in that order, flushing buffered transports before clearing them.
6. **Frontend** — receives Dom and the same `localeSource`. When
`frontend.persist` is configured, persisted values seed the initial
options and `onPreferenceChange` is wired to write back to Storage.
7. **Sess** — built before Http so its `bearerHeader()` and 401-rescue
hook can be auto-wired into the HTTP engine. Optional — only built when
`sess: { userSchema, ... }` is configured.
8. **Http** — built last with the shared `Logger` injected automatically
so request/retry/error events land under category `'http'`. When `Sess`
is configured: `headers: () => Sess.bearerHeader() ?? {}` is layered
onto user-supplied default headers (Sess wins on `Authorization`
conflicts) and `createBeforeErrorHook(Sess)` is prepended to
`hooks.beforeError`. In SvelteKit `load`, scope to the request via
`App.Http.with({ fetch: event.fetch })`.
7. **Http** — built with the shared `Logger` injected automatically so
request/retry/error events land under category `'http'`. In SvelteKit
`load`, scope to the request via `App.Http.with({ fetch: event.fetch })`.
8. **Timers** — built with the shared `Logger`. This is the App-owned
scheduler used by long-lived runtime tasks; no module-global singleton.
9. **Sess** — created lazily via `App.createActiveSession(...)`, not from
`createActiveApp(...)` options. App injects Logger, but the consumer keeps
auth policy explicit (`storage`, `onRefresh`, `onRevoke`, HTTP hooks).
`dispose()` runs in reverse order.
@ -321,10 +334,9 @@ const App = createActiveApp({
// Cookies + broadcast = changes propagate across tabs the moment they
// happen (browsers do not emit a native event for cookie mutations).
const session = withBroadcast(
cookieAdapter({ path: '/', maxAge: 3600 }),
{ channel: 'my-app:session' }
);
const session = withBroadcast(cookieAdapter({ path: '/', maxAge: 3600 }), {
channel: 'my-app:session'
});
```
### Persisting Frontend preferences
@ -432,8 +444,14 @@ interface ActiveAppOptions<S extends LangNode> {
logger?: LoggerOptions;
lang?: { schema: S; defaultLocale?: SupportedLocale; fallbackChain?: SupportedLocale[] };
formats?: Omit<ActiveFormatsOptions, 'locale' | 'localeSource'>;
frontend?: Omit<ActiveFrontendOptions, 'locale' | 'localeSource' | 'dom'>;
frontend?: Omit<ActiveFrontendOptions, 'locale' | 'localeSource' | 'dom'> & {
persist?: FrontendPersist;
};
dom?: ActiveDomProps;
storage?: ActiveAppStorageOptions;
http?: Omit<EngineHttpOptions, 'logger'>;
timers?: Omit<EngineTimersOptions, 'logger'>;
connections?: Omit<ActiveConnectionsOptions, 'logger' | 'timers' | 'session'>;
}
interface ActiveApp<S extends LangNode = LangNode> {
@ -442,12 +460,22 @@ interface ActiveApp<S extends LangNode = LangNode> {
readonly Formats: ActiveFormats;
readonly Frontend: ActiveFrontend;
readonly Dom: ActiveDom;
readonly Storage: ActiveStorage;
readonly Http: EngineHttp;
readonly Timers: ActiveTimers;
readonly Sess: ActiveSession<unknown, unknown, unknown> | undefined;
getLocale(): SupportedLocale;
setLocale(locale: SupportedLocale): void;
onLocaleChange(fn: (locale: SupportedLocale) => void): () => void;
createSiumEngine(): EngineSium;
createActiveSession<TUser, TCredential = undefined, TData = undefined>(
options?: Omit<EngineSessionOptions<TUser, TCredential, TData>, 'logger'>
): ActiveSession<TUser, TCredential, TData>;
createActiveConnections<TConnections extends ConnectionMap = ConnectionMap>(
options?: Omit<ActiveConnectionsOptions, 'logger' | 'timers' | 'session'>
): ActiveConnections<TConnections>;
dispose(): void;
}

@ -1,4 +1,6 @@
import { createActiveDom } from '$adom';
import { createActiveConnections as createActiveConnectionsRegistry } from '$conn';
import type { ActiveConnections, ActiveConnectionsOptions, ConnectionMap } from '$conn';
import { createActiveFrontend } from '$fend';
import { createActiveFormats } from '$fmts';
import { createEngineHttp } from '$http';
@ -6,6 +8,8 @@ import type { ActiveLang, LangNode } from '$lang';
import { createActiveLang } from '$lang/active-lang.svelte';
import { createActiveMonoLang } from '$lang/mono-lang.svelte';
import { createEngineLogger } from '$logr';
import { createActivePermissions as createActivePermissionsClient } from '$perm';
import type { ActivePermissions, ActivePermissionsOptions } from '$perm';
import {
createActiveSession,
SessAlreadyCreatedError,
@ -18,12 +22,19 @@ import {
LOGGER_CATEGORY as STORAGE_LOGGER_CATEGORY,
type StorageErrorContext
} from '$stor';
import { createActiveTimers } from '$timr';
import { SvelteSet } from 'svelte/reactivity';
import {
applyFrontendPreferenceSnapshot,
bindFrontendStorage,
loadPersistedFrontendPreferences
} from './integrations/frontend-storage';
import {
APP_ERROR_ALREADY_CREATED_PERMISSIONS,
APP_ERROR_ALREADY_CREATED_SESSION,
APP_STORAGE_ERROR_MESSAGE
} from './consts.ts';
import type { ActiveApp, ActiveAppOptions } from './types.ts';
/**
@ -51,7 +62,7 @@ export function createActiveApp<S extends LangNode = LangNode>(
adapter: options.storage?.adapter,
namespace: options.storage?.namespace,
onError: (ctx: StorageErrorContext) => {
Logger.error(STORAGE_LOGGER_CATEGORY, `${ctx.op} on "${ctx.fullKey}"`, {
Logger.error(STORAGE_LOGGER_CATEGORY, APP_STORAGE_ERROR_MESSAGE(ctx.op, ctx.fullKey), {
error: ctx.error,
context: { adapter: ctx.adapter, key: ctx.key }
});
@ -87,8 +98,33 @@ export function createActiveApp<S extends LangNode = LangNode>(
logger: Logger
});
const Timers = createActiveTimers({
...options.timers,
logger: Logger
});
let disposed = false;
let Sess: ActiveSession<unknown, unknown, unknown> | undefined;
let Permissions: ActivePermissions | undefined;
let detachSessionBridge: (() => void) | undefined;
const sessionBridgeListeners = new SvelteSet<(change: { readonly event: string }) => void>();
const connectionRegistries = new SvelteSet<ActiveConnections>();
const sessionBridge = {
onChange(listener: (change: { readonly event: string }) => void) {
sessionBridgeListeners.add(listener);
return () => {
sessionBridgeListeners.delete(listener);
};
}
};
function attachSessionBridge(session: ActiveSession<unknown, unknown, unknown>): void {
detachSessionBridge?.();
detachSessionBridge = session.onChange((change) => {
for (const listener of [...sessionBridgeListeners]) listener({ event: change.event });
});
}
const app: ActiveApp<S> = {
Logger,
@ -98,11 +134,16 @@ export function createActiveApp<S extends LangNode = LangNode>(
Dom,
Storage,
Http,
Timers,
get Sess() {
return Sess;
},
get Permissions() {
return Permissions;
},
getLocale: () => Lang.getLocale(),
setLocale: (locale) => Lang.setLocale(locale),
onLocaleChange: (fn) => Lang.onLocaleChange(fn),
@ -119,23 +160,64 @@ export function createActiveApp<S extends LangNode = LangNode>(
sessOptions: Omit<EngineSessionOptions<TUser, TCredential, TData>, 'logger'> = {}
): ActiveSession<TUser, TCredential, TData> {
if (Sess !== undefined) {
throw new SessAlreadyCreatedError(
'[aapp] App.createActiveSession() called twice — only one session per App.'
);
throw new SessAlreadyCreatedError(APP_ERROR_ALREADY_CREATED_SESSION);
}
const built = createActiveSession<TUser, TCredential, TData>({
...sessOptions,
logger: Logger
});
Sess = built as ActiveSession<unknown, unknown, unknown>;
attachSessionBridge(Sess);
return built;
},
createActiveConnections<TConnections extends ConnectionMap = ConnectionMap>(
connectionOptions: Omit<ActiveConnectionsOptions, 'logger' | 'timers' | 'session'> = {}
): ActiveConnections<TConnections> {
const built = createActiveConnectionsRegistry<TConnections>({
...options.connections,
...connectionOptions,
logger: Logger,
timers: Timers,
session: sessionBridge
});
connectionRegistries.add(built as ActiveConnections);
return built;
},
createActivePermissions(
permissionOptions: Partial<Omit<ActivePermissionsOptions, 'logger' | 'http'>> = {}
): ActivePermissions {
if (Permissions !== undefined) {
throw new Error(APP_ERROR_ALREADY_CREATED_PERMISSIONS);
}
const merged = {
...options.permissions,
...permissionOptions
};
const built = createActivePermissionsClient({
...merged,
endpoint: merged.endpoint ?? '',
logger: Logger,
http: Http
});
Permissions = built;
return built;
},
dispose() {
if (disposed) return;
disposed = true;
Permissions?.dispose();
Permissions = undefined;
for (const registry of connectionRegistries) registry.dispose();
connectionRegistries.clear();
detachSessionBridge?.();
detachSessionBridge = undefined;
sessionBridgeListeners.clear();
Sess?.dispose();
Sess = undefined;
Timers.dispose();
teardownPersistence();
Frontend.dispose();
Dom.dispose();

@ -1 +1,10 @@
export const LOGGER_CATEGORY = 'app';
export const APP_STORAGE_ERROR_MESSAGE = (op: string, fullKey: string): string =>
`${op} on "${fullKey}"`;
export const APP_ERROR_ALREADY_CREATED_SESSION =
'[aapp] App.createActiveSession() called twice — only one session per App.';
export const APP_ERROR_ALREADY_CREATED_PERMISSIONS =
'[aapp] App.createActivePermissions() called twice — only one permissions client per App.';

@ -19,6 +19,7 @@ import { createActiveApp } from '../active-app.svelte';
import { LogLevel, type LogEntry } from '$logr';
import type { LangNode } from '$lang';
import { MONO_LANG_CATEGORY } from '$lang/mono-lang.svelte';
import { createMockTransport } from '$conn';
const schema = {
greeting: { es: 'Hola', en: 'Hello', 'es-MX': 'Qué onda' },
@ -112,6 +113,25 @@ describe('createActiveApp — composition', () => {
expect(() => App.dispose()).not.toThrow();
});
it('creates App-wired connection registries', async () => {
const App = createActiveApp({
logger: { level: LogLevel.NONE, transports: [] }
});
const Connections = App.createActiveConnections();
const Main = Connections.createConnection('main', {
transport: createMockTransport(),
heartbeat: false,
reconnect: false
});
const result = await Main.connect();
expect(result.ok).toBe(true);
expect(Connections.names()).toEqual(['main']);
App.dispose();
});
it('dispose() flushes buffered transports', () => {
const writes: string[] = [];
const App = createActiveApp({

@ -1,13 +1,16 @@
import type { ActiveDom, ActiveDomProps } from '$adom';
import type { ActiveConnections, ActiveConnectionsOptions, ConnectionMap } from '$conn';
import type { ActiveFrontend, ActiveFrontendOptions } from '$fend';
import type { FrontendPreferenceKey } from '$fend';
import type { ActiveFormats, ActiveFormatsOptions } from '$fmts';
import type { EngineHttp, EngineHttpOptions } from '$http';
import type { ActiveLang, LangNode, SupportedLocale } from '$lang';
import type { EngineLogger, LoggerOptions } from '$logr';
import type { ActivePermissions, ActivePermissionsOptions } from '$perm';
import type { ActiveSession, EngineSessionOptions } from '$sess';
import type { EngineSium } from '$sium';
import type { ActiveStorage, SyncStorageAdapter } from '$stor';
import type { ActiveTimers, EngineTimersOptions } from '$timr';
/** Frontend preferences eligible for App-managed persistence. */
export type FrontendPersistKey = FrontendPreferenceKey;
@ -91,6 +94,12 @@ export interface ActiveAppOptions<S extends LangNode = LangNode> {
};
dom?: ActiveDomProps;
storage?: ActiveAppStorageOptions;
/**
* Runtime timer scheduler defaults. App injects Logger automatically.
* Exposed as `App.Timers` and reused by integrations such as session
* auto-refresh.
*/
timers?: Omit<EngineTimersOptions, 'logger'>;
/**
* HTTP client defaults — `baseUrl`, default headers, retry, timeout, hooks.
* When omitted, `App.Http` is still present with engine defaults
@ -102,6 +111,17 @@ export interface ActiveAppOptions<S extends LangNode = LangNode> {
* resolution flow through the framework.
*/
http?: Omit<EngineHttpOptions, 'logger'>;
/**
* Defaults for `App.createActiveConnections()`. App injects Logger,
* Timers and the session bridge automatically.
*/
connections?: Omit<ActiveConnectionsOptions, 'logger' | 'timers' | 'session'>;
/**
* Defaults for `App.createActivePermissions()`. App injects Logger and
* Http automatically; the endpoint remains explicit because the client
* only reflects server decisions.
*/
permissions?: Omit<ActivePermissionsOptions, 'logger' | 'http'>;
}
/**
@ -128,6 +148,7 @@ export interface ActiveApp<S extends LangNode = LangNode> {
readonly Dom: ActiveDom;
readonly Storage: ActiveStorage;
readonly Http: EngineHttp;
readonly Timers: ActiveTimers;
/** Active locale. Sourced from Lang (real or mono). */
getLocale: () => SupportedLocale;
@ -190,12 +211,32 @@ export interface ActiveApp<S extends LangNode = LangNode> {
options?: Omit<EngineSessionOptions<TUser, TCredential, TData>, 'logger'>
): ActiveSession<TUser, TCredential, TData>;
/**
* Build an App-scoped realtime connection registry. Each call returns a
* fresh registry; App injects `Logger`, `Timers` and a structural session
* event bridge so individual connections can opt into auth/session
* behavior without importing `sess` directly.
*/
createActiveConnections<TConnections extends ConnectionMap = ConnectionMap>(
options?: Omit<ActiveConnectionsOptions, 'logger' | 'timers' | 'session'>
): ActiveConnections<TConnections>;
/**
* Build the App-scoped reactive permissions client. The authoritative
* runtime is `createEnginePermissions()` on the server; this client is
* only for UI/UX reflection, snapshots and cache.
*/
createActivePermissions(
options?: Partial<Omit<ActivePermissionsOptions, 'logger' | 'http'>>
): ActivePermissions;
/**
* The active session, when one has been built via
* `App.createActiveSession(...)`. `undefined` until then. Pages narrow
* with `if (App.Sess) { ... }`.
*/
readonly Sess: ActiveSession<unknown, unknown, unknown> | undefined;
readonly Permissions: ActivePermissions | undefined;
/** Tear down owned instances in reverse construction order. Idempotent. */
dispose: () => void;

@ -7,6 +7,8 @@
> **Status.** Design target for first implementation.
>
> **Core decision.** The artifact is called `conn` / `connections`, not `sock` / `sockets`, because the public model must support WebSocket, SSE, polling, WebTransport, mocks, and future transports through adapters.
>
> **Naming correction applied.** `EngineConnections` / `ActiveConnections` are the root registry names. An individual runtime unit is `Connection`. Any older references below to `EngineConnection` / `ActiveConnection` describe that unit conceptually, but are not implementation names.
---
@ -43,7 +45,6 @@ App
A connection is not necessarily a WebSocket. A connection is an abstract realtime transport endpoint.
---
## 1.1 Mandatory v1 scope
@ -128,7 +129,7 @@ Required behavior:
v1 MUST include:
```ts
createWebSocketTransport(options)
createWebSocketTransport(options);
```
Required behavior:
@ -141,7 +142,7 @@ Required behavior:
- Supports dynamic URL:
```ts
url: string | (() => string)
url: string | (() => string);
```
Required usage:
@ -159,7 +160,7 @@ const Main = Connections.createConnection('main', {
v1 MUST include:
```ts
createMockTransport()
createMockTransport();
```
Required behavior:
@ -329,6 +330,10 @@ Required behavior:
- Reconnect increments connection generation.
- After reconnect, configured channels rejoin.
- After reconnect, auth re-runs if configured.
- Reconnect delay must be computed through the shared timer/backoff primitives
(`$libs/timers.computeBackoffDelay`) and scheduled through injected
`TimerScheduler`/`App.Timers`, not raw `setTimeout` inside the connection
engine. A native fallback is allowed only in the standalone engine path.
### 1.1.9 Heartbeat
@ -424,7 +429,7 @@ auth?: {
or shorthand:
```ts
auth: () => Record<string, unknown> | null | Promise<Record<string, unknown> | null>
auth: () => Record<string, unknown> | null | Promise<Record<string, unknown> | null>;
```
### 1.1.12 Minimum v1 deliverables
@ -453,7 +458,6 @@ consts.ts
Presence, SSE and polling may be deferred, but WebSocket + mock are mandatory.
---
## 2. Explicit non-goals
@ -503,9 +507,9 @@ App.Connections.connection('main');
Do **not** put this under:
```ts
Sess.connections
Sess.socket
Sess.realtime
Sess.connections;
Sess.socket;
Sess.realtime;
```
Session is only a source of auth/lifecycle events.
@ -577,6 +581,33 @@ No persistence by default.
Connection state is runtime state. Do not persist connections in storage.
### 3.6 With `timr`
`arts/conn` should use `App.Timers` when built from `aapp`. Reconnect,
heartbeat and pending-ack timeouts must be keyed timers so app-level debug
panels and `App.dispose()` can see and cancel them uniformly.
Recommended key shape:
```txt
conn:<connection>:reconnect
conn:<connection>:heartbeat
conn:<connection>:ack:<id>
```
The engine accepts a structural scheduler option compatible with
`TimerScheduler` instead of importing Svelte-specific active wrappers.
### 3.7 With `libs/http` and `libs/timers`
Protocol vocabulary that is not connection-specific belongs in `libs/*`.
- HTTP method/header/status names used by polling/SSE transports must come
from `$libs/http`.
- Reconnect backoff must reuse `$libs/timers`.
- `arts/conn` owns only connection-specific constants: states, events,
transport kinds, frame keys, close reasons, logger messages and error names.
---
## 4. Naming
@ -590,39 +621,39 @@ src/arts/conn/
Public names:
```ts
EngineConnections
ActiveConnections
EngineConnections;
ActiveConnections;
EngineConnection
ActiveConnection
EngineConnection;
ActiveConnection;
ConnectionChannel
ConnectionPresence
ConnectionChannel;
ConnectionPresence;
ConnectionTransport
ConnectionSerializer
ConnectionTransport;
ConnectionSerializer;
```
Factory names:
```ts
createEngineConnections
createActiveConnections
createEngineConnections;
createActiveConnections;
createWebSocketTransport
createSseTransport
createPollingTransport
createMockTransport
createWebSocketTransport;
createSseTransport;
createPollingTransport;
createMockTransport;
jsonConnectionSerializer
jsonConnectionSerializer;
```
Avoid names such as:
```ts
SocketService
RealtimeManager
ConnectionManager
SocketService;
RealtimeManager;
ConnectionManager;
```
---
@ -688,9 +719,7 @@ Rules:
The hub owns a registry of named connections.
```ts
export interface EngineConnections<
TConnections extends ConnectionMap = ConnectionMap
> {
export interface EngineConnections<TConnections extends ConnectionMap = ConnectionMap> {
createConnection<K extends keyof TConnections & string>(
name: K,
options: ConnectionOptions<TConnections[K]>
@ -701,9 +730,7 @@ export interface EngineConnections<
options: ConnectionOptions<TChannels>
): EngineConnection<TChannels>;
connection<K extends keyof TConnections & string>(
name: K
): EngineConnection<TConnections[K]>;
connection<K extends keyof TConnections & string>(name: K): EngineConnection<TConnections[K]>;
connection<TChannels extends ConnectionChannelMap = ConnectionChannelMap>(
name: string
@ -729,8 +756,6 @@ Behavior:
- `closeAll()` disconnects all registered connections.
- `dispose()` disconnects everything and clears listeners.
### 6.1.1 Hub lifecycle methods
In addition to create/lookup methods, the hub MUST expose lifecycle methods by connection name:
@ -755,7 +780,6 @@ Connections.connection(name).reconnect();
Both levels are intentionally supported.
### 6.2 Active hub
```ts
@ -808,9 +832,7 @@ Each connection has its own:
- pending acks/requests
```ts
export interface EngineConnection<
TChannels extends ConnectionChannelMap = ConnectionChannelMap
> {
export interface EngineConnection<TChannels extends ConnectionChannelMap = ConnectionChannelMap> {
readonly name: string;
readonly state: ConnectionState;
readonly connected: boolean;
@ -949,17 +971,11 @@ export interface ConnectionTransport {
onOpen(listener: () => void): () => void;
onMessage(
listener: (message: string | ArrayBuffer) => void
): () => void;
onMessage(listener: (message: string | ArrayBuffer) => void): () => void;
onClose(
listener: (event: ConnectionCloseEvent) => void
): () => void;
onClose(listener: (event: ConnectionCloseEvent) => void): () => void;
onError(
listener: (error: unknown) => void
): () => void;
onError(listener: (error: unknown) => void): () => void;
}
```
@ -1000,7 +1016,7 @@ createSseTransport({
If no `send` function is provided:
```ts
canSend === false
canSend === false;
```
Calling `send()` should return:
@ -1057,7 +1073,7 @@ export interface ConnectionSerializer<TFrame = ConnectionFrame> {
Default:
```ts
jsonConnectionSerializer()
jsonConnectionSerializer();
```
`jsonConnectionSerializer` must:
@ -1074,10 +1090,7 @@ jsonConnectionSerializer()
Base frame:
```ts
export interface ConnectionFrame<
TType extends string = string,
TPayload = unknown
> {
export interface ConnectionFrame<TType extends string = string, TPayload = unknown> {
readonly id?: string;
readonly topic?: string;
readonly type: TType;
@ -1168,13 +1181,7 @@ The generic typing should help at call-sites, but runtime validation is optional
A channel is a logical topic within one connection.
```ts
export type ConnectionChannelState =
| 'idle'
| 'joining'
| 'joined'
| 'leaving'
| 'left'
| 'failed';
export type ConnectionChannelState = 'idle' | 'joining' | 'joined' | 'leaving' | 'left' | 'failed';
export interface ConnectionChannel<TEvents extends ConnectionEventMap = ConnectionEventMap> {
readonly name: string;
@ -1201,9 +1208,7 @@ export interface ConnectionChannel<TEvents extends ConnectionEventMap = Connecti
listener: (payload: TEvents[K], meta: ConnectionMessageMeta) => void
): () => void;
onAny(
listener: (frame: ConnectionFrame, meta: ConnectionMessageMeta) => void
): () => void;
onAny(listener: (frame: ConnectionFrame, meta: ConnectionMessageMeta) => void): () => void;
dispose(): void;
}
@ -1286,12 +1291,7 @@ export type ConnectionAckResult<T = unknown> =
| { readonly ok: true; readonly payload: T }
| {
readonly ok: false;
readonly reason:
| 'timeout'
| 'closed'
| 'rejected'
| 'transport_error'
| 'invalid_reply';
readonly reason: 'timeout' | 'closed' | 'rejected' | 'transport_error' | 'invalid_reply';
readonly error?: unknown;
};
```
@ -1429,10 +1429,7 @@ Auth is connection-specific and opaque.
export type ConnectionAuthPayload = Record<string, unknown>;
export interface ConnectionAuthOptions {
readonly getAuth?: () =>
| ConnectionAuthPayload
| null
| Promise<ConnectionAuthPayload | null>;
readonly getAuth?: () => ConnectionAuthPayload | null | Promise<ConnectionAuthPayload | null>;
readonly authType?: string;
}
@ -1461,12 +1458,7 @@ export type ConnectionAuthResult =
| { readonly ok: true }
| {
readonly ok: false;
readonly reason:
| 'no_auth_provider'
| 'closed'
| 'rejected'
| 'timeout'
| 'transport_error';
readonly reason: 'no_auth_provider' | 'closed' | 'rejected' | 'timeout' | 'transport_error';
readonly error?: unknown;
};
```
@ -1555,9 +1547,7 @@ Do not auto-wire domain behavior.
## 21. Options
```ts
export interface ConnectionOptions<
TChannels extends ConnectionChannelMap = ConnectionChannelMap
> {
export interface ConnectionOptions<TChannels extends ConnectionChannelMap = ConnectionChannelMap> {
readonly transport: ConnectionTransport | (() => ConnectionTransport);
readonly serializer?: ConnectionSerializer;
@ -1568,7 +1558,9 @@ export interface ConnectionOptions<
readonly buffer?: ConnectionBufferOptions;
readonly auth?: ConnectionAuthOptions | (() => ConnectionAuthPayload | null | Promise<ConnectionAuthPayload | null>);
readonly auth?:
| ConnectionAuthOptions
| (() => ConnectionAuthPayload | null | Promise<ConnectionAuthPayload | null>);
readonly session?:
| false
@ -1600,13 +1592,13 @@ Use custom errors for programmer errors, not normal runtime failures.
Examples:
```ts
ConnConnectionAlreadyExistsError
ConnConnectionNotFoundError
ConnInvalidConnectionNameError
ConnInvalidFrameError
ConnDisposedError
ConnChannelAlreadyExistsError
ConnChannelNotFoundError
ConnConnectionAlreadyExistsError;
ConnConnectionNotFoundError;
ConnInvalidConnectionNameError;
ConnInvalidFrameError;
ConnDisposedError;
ConnChannelAlreadyExistsError;
ConnChannelNotFoundError;
```
Runtime network failures should generally return tagged results or emit state/error events, not throw unexpectedly.
@ -1633,9 +1625,29 @@ export const DEFAULT_HEARTBEAT_INTERVAL_MS = 25_000;
export const DEFAULT_HEARTBEAT_TIMEOUT_MS = 10_000;
export const DEFAULT_ACK_TIMEOUT_MS = 10_000;
export const TRANSPORT_KIND_WEBSOCKET = 'websocket';
export const TRANSPORT_KIND_MOCK = 'mock';
export const FRAME_KEY_ID = 'id';
export const FRAME_KEY_TYPE = 'type';
export const FRAME_KEY_TOPIC = 'topic';
export const FRAME_KEY_PAYLOAD = 'payload';
export const FRAME_KEY_REPLY_TO = 'replyTo';
export const LOGGER_CATEGORY = 'conn';
export const LOG_MSG_LISTENER_THREW_PREFIX = 'Listener threw on ';
export const ERROR_NAME_DISPOSED = 'ConnDisposedError';
export const ERROR_NAME_ALREADY_EXISTS = 'ConnConnectionAlreadyExistsError';
export const ERROR_NAME_NOT_FOUND = 'ConnConnectionNotFoundError';
export const ERROR_NAME_INVALID_NAME = 'ConnInvalidConnectionNameError';
export const ERROR_NAME_INVALID_FRAME = 'ConnInvalidFrameError';
```
Prefer exported constants over magic strings/numbers.
Prefer exported constants over magic strings/numbers. Engine bodies should not
inline connection states, event names, transport kinds, close reasons, frame
field names, logger categories or error names.
---
@ -1717,10 +1729,12 @@ Runtime frame validation should be minimal by default:
```ts
function isConnectionFrame(value: unknown): value is ConnectionFrame {
return typeof value === 'object'
&& value !== null
&& typeof (value as { type?: unknown }).type === 'string'
&& 'payload' in value;
return (
typeof value === 'object' &&
value !== null &&
typeof (value as { type?: unknown }).type === 'string' &&
'payload' in value
);
}
```
@ -1878,9 +1892,7 @@ const Main = Connections.createConnection('main', {
auth: () => {
const session = Sess.current;
return session?.credential
? { token: session.credential.accessToken }
: null;
return session?.credential ? { token: session.credential.accessToken } : null;
},
session: {

@ -0,0 +1,73 @@
# `arts/conn`
`conn` manages runtime realtime connections without coupling the app to WebSocket as a concept. The root artifact is a registry:
```ts
import { createEngineConnections, createWebSocketTransport } from '$conn';
const Connections = createEngineConnections();
const Main = Connections.createConnection('main', {
transport: createWebSocketTransport({ url: () => '/realtime' }),
heartbeat: false
});
await Main.connect();
```
## Naming
- Root engine: `createEngineConnections()` / `EngineConnections`.
- Reactive root: `createActiveConnections()` / `ActiveConnections`.
- Individual unit: `Connection`.
- Logical topic: `ConnectionChannel`.
There is intentionally no `EngineConnection` or `ActiveConnection`; `Engine*` and `Active*` are reserved for artifact roots.
## App Integration
`aapp` exposes a factory instead of a permanent property:
```ts
const Connections = App.createActiveConnections<AppConnections>();
```
App injects `Logger`, `Timers` and a structural session event bridge. A connection reacts to session events only when it opts in:
```ts
Connections.createConnection('main', {
transport: createWebSocketTransport({ url: '/realtime' }),
auth: () => ({ token: App.Sess?.current?.credential }),
session: {
enabled: true,
reauthOnRefresh: true,
disconnectOnExpire: true
}
});
```
## Channels
Channels are cached by name and route frames by `topic`.
```ts
const Orders = Main.channel<{ 'order.updated': { id: string } }>('orders');
Orders.on('order.updated', (payload) => {
console.log(payload.id);
});
await Orders.join({ tenant: 'acme' });
```
## Request / Reply
`request()` creates a frame id, sends `ack: true`, and resolves when an incoming frame carries `replyTo` with that id.
```ts
const result = await Main.request<{ id: string }, { ok: boolean }>('order.sync', {
id: 'o1'
});
```
Runtime failures return tagged results. Programmer errors such as duplicated names or invalid connection names throw typed `Conn*` errors.

@ -0,0 +1,127 @@
import { createEngineConnections } from './engine-connections.ts';
import {
CONNECTION_STATE_CLOSED,
CONNECTION_STATE_CONNECTING,
CONNECTION_STATE_FAILED,
CONNECTION_STATE_OPEN,
CONNECTION_STATE_RECONNECTING
} from './consts.ts';
import { SvelteMap } from 'svelte/reactivity';
import type {
ActiveConnections,
ActiveConnectionsOptions,
Connection,
ConnectionChannelMap,
ConnectionMap,
ConnectionOptions,
ConnectionState
} from './types.ts';
export function createActiveConnections<TConnections extends ConnectionMap = ConnectionMap>(
options: ActiveConnectionsOptions = {}
): ActiveConnections<TConnections> {
const engine = createEngineConnections<TConnections>(options);
const detachers = new SvelteMap<string, () => void>();
let activeNames = $state<readonly string[]>(engine.names());
let states = $state<Readonly<Record<string, ConnectionState>>>({});
function refreshNames(): void {
activeNames = engine.names();
}
function setConnectionState(name: string, state: ConnectionState): void {
states = { ...states, [name]: state };
}
function namesBy(state: ConnectionState): readonly string[] {
return activeNames.filter((name) => states[name] === state);
}
function observe(name: string, connection: Connection): void {
detachers.get(name)?.();
setConnectionState(name, connection.state);
detachers.set(
name,
connection.onState((change) => {
setConnectionState(change.connection, change.to);
})
);
refreshNames();
}
const active: ActiveConnections<TConnections> = {
get size() {
return activeNames.length;
},
get activeNames() {
return activeNames;
},
get states() {
return states;
},
get connectedNames() {
return namesBy(CONNECTION_STATE_OPEN);
},
get connectingNames() {
return namesBy(CONNECTION_STATE_CONNECTING);
},
get reconnectingNames() {
return namesBy(CONNECTION_STATE_RECONNECTING);
},
get failedNames() {
return namesBy(CONNECTION_STATE_FAILED);
},
get closedNames() {
return namesBy(CONNECTION_STATE_CLOSED);
},
get allConnected() {
return (
activeNames.length > 0 &&
activeNames.every((name) => states[name] === CONNECTION_STATE_OPEN)
);
},
get anyConnected() {
return activeNames.some((name) => states[name] === CONNECTION_STATE_OPEN);
},
get anyConnecting() {
return activeNames.some((name) => states[name] === CONNECTION_STATE_CONNECTING);
},
get anyReconnecting() {
return activeNames.some((name) => states[name] === CONNECTION_STATE_RECONNECTING);
},
get anyFailed() {
return activeNames.some((name) => states[name] === CONNECTION_STATE_FAILED);
},
createConnection<TChannels extends ConnectionChannelMap = ConnectionChannelMap>(
name: string,
connectionOptions: ConnectionOptions<TChannels>
): Connection<TChannels> {
const connection = engine.createConnection<TChannels>(name, connectionOptions);
observe(name, connection as Connection);
return connection;
},
connection<TChannels extends ConnectionChannelMap = ConnectionChannelMap>(
name: string
): Connection<TChannels> {
return engine.connection<TChannels>(name);
},
has: (name) => engine.has(name),
names: () => engine.names(),
openConnection: (name) => engine.openConnection(name),
closeConnection: (name, reason) => engine.closeConnection(name, reason),
reconnectConnection: (name, reason) => engine.reconnectConnection(name, reason),
openAll: () => engine.openAll(),
closeAll: (reason) => engine.closeAll(reason),
reconnectAll: (reason) => engine.reconnectAll(reason),
close: (name, reason) => engine.close(name, reason),
dispose() {
for (const detach of detachers.values()) detach();
detachers.clear();
activeNames = [];
states = {};
engine.dispose();
}
};
return active;
}

@ -0,0 +1,177 @@
import {
CONNECTION_CHANNEL_JOIN_REASON_CLOSED,
CONNECTION_CHANNEL_JOIN_REASON_REJECTED,
CONNECTION_CHANNEL_JOIN_REASON_TRANSPORT_ERROR,
CONNECTION_CHANNEL_STATE_FAILED,
CONNECTION_CHANNEL_STATE_IDLE,
CONNECTION_CHANNEL_STATE_JOINED,
CONNECTION_CHANNEL_STATE_JOINING,
CONNECTION_CHANNEL_STATE_LEAVING,
CONNECTION_CHANNEL_STATE_LEFT,
CONNECTION_FRAME_TYPE_JOIN,
CONNECTION_FRAME_TYPE_LEAVE,
CONNECTION_SEND_REASON_CLOSED
} from './consts.ts';
import type {
Connection,
ConnectionAckResult,
ConnectionChannel,
ConnectionChannelJoinResult,
ConnectionChannelOptions,
ConnectionChannelState,
ConnectionEventMap,
ConnectionFrame,
ConnectionMessageMeta,
ConnectionRequestOptions,
ConnectionSendOptions,
ConnectionSendResult
} from './types.ts';
export interface InternalConnectionChannel<
TEvents extends ConnectionEventMap = ConnectionEventMap
> extends ConnectionChannel<TEvents> {
readonly shouldRejoin: boolean;
receive(frame: ConnectionFrame, meta: ConnectionMessageMeta): void;
rejoin(): Promise<ConnectionChannelJoinResult>;
}
export interface ConnectionChannelRuntime<TEvents extends ConnectionEventMap = ConnectionEventMap> {
readonly connection: Connection<Record<string, TEvents>>;
readonly reportListenerError?: (event: string, error: unknown) => void;
}
function mapSendToJoinResult(result: ConnectionSendResult): ConnectionChannelJoinResult {
if (result.ok) return { ok: true };
if (result.reason === CONNECTION_SEND_REASON_CLOSED) {
return { ok: false, reason: CONNECTION_CHANNEL_JOIN_REASON_CLOSED, error: result.error };
}
return {
ok: false,
reason:
result.error === undefined
? CONNECTION_CHANNEL_JOIN_REASON_REJECTED
: CONNECTION_CHANNEL_JOIN_REASON_TRANSPORT_ERROR,
error: result.error
};
}
export function createConnectionChannel<TEvents extends ConnectionEventMap = ConnectionEventMap>(
name: string,
options: ConnectionChannelOptions = {},
runtime: ConnectionChannelRuntime<TEvents>
): InternalConnectionChannel<TEvents> {
let state: ConnectionChannelState = CONNECTION_CHANNEL_STATE_IDLE;
let desiredJoined = options.autoJoin === true;
let disposed = false;
const anyListeners = new Set<(frame: ConnectionFrame, meta: ConnectionMessageMeta) => void>();
const typeListeners = new Map<
string,
Set<(payload: unknown, meta: ConnectionMessageMeta) => void>
>();
function setState(next: ConnectionChannelState): void {
state = next;
}
function emitToListeners(frame: ConnectionFrame, meta: ConnectionMessageMeta): void {
for (const listener of [...anyListeners]) {
try {
listener(frame, meta);
} catch (error) {
runtime.reportListenerError?.(frame.type, error);
}
}
const listeners = typeListeners.get(frame.type);
if (listeners === undefined) return;
for (const listener of [...listeners]) {
try {
listener(frame.payload, meta);
} catch (error) {
runtime.reportListenerError?.(frame.type, error);
}
}
}
async function resolveJoinPayload(explicitParams: unknown): Promise<unknown> {
if (explicitParams !== undefined) return explicitParams;
return options.params?.();
}
const channel: InternalConnectionChannel<TEvents> = {
name,
get state() {
return state;
},
get shouldRejoin() {
return desiredJoined && options.rejoinOnReconnect !== false;
},
async join(params) {
if (disposed) return { ok: false, reason: CONNECTION_CHANNEL_JOIN_REASON_CLOSED };
setState(CONNECTION_CHANNEL_STATE_JOINING);
const payload = await resolveJoinPayload(params);
const result = await runtime.connection.send(CONNECTION_FRAME_TYPE_JOIN, payload, {
topic: name
});
const mapped = mapSendToJoinResult(result);
if (mapped.ok) {
desiredJoined = true;
setState(CONNECTION_CHANNEL_STATE_JOINED);
} else {
setState(CONNECTION_CHANNEL_STATE_FAILED);
}
return mapped;
},
async leave() {
if (disposed) return;
setState(CONNECTION_CHANNEL_STATE_LEAVING);
desiredJoined = false;
await runtime.connection.send(CONNECTION_FRAME_TYPE_LEAVE, undefined, { topic: name });
setState(CONNECTION_CHANNEL_STATE_LEFT);
},
send(type, payload, sendOptions?: ConnectionSendOptions): Promise<ConnectionSendResult> {
return runtime.connection.send(type, payload, { ...sendOptions, topic: name });
},
request<K extends keyof TEvents & string, TResult = unknown>(
type: K,
payload: TEvents[K],
requestOptions?: ConnectionRequestOptions
): Promise<ConnectionAckResult<TResult>> {
return runtime.connection.request(type, payload, { ...requestOptions, topic: name });
},
on(type, listener) {
let listeners = typeListeners.get(type);
if (listeners === undefined) {
listeners = new Set();
typeListeners.set(type, listeners);
}
const wrapped = listener as (payload: unknown, meta: ConnectionMessageMeta) => void;
listeners.add(wrapped);
return () => {
listeners?.delete(wrapped);
if (listeners?.size === 0) typeListeners.delete(type);
};
},
onAny(listener) {
anyListeners.add(listener);
return () => {
anyListeners.delete(listener);
};
},
receive(frame, meta) {
if (disposed) return;
emitToListeners(frame, meta);
},
rejoin() {
if (!channel.shouldRejoin) return Promise.resolve({ ok: true });
return channel.join();
},
dispose() {
disposed = true;
anyListeners.clear();
typeListeners.clear();
}
};
return channel;
}

@ -0,0 +1,865 @@
import { computeBackoffDelay } from '$libs/timers';
import type { TimerScheduler } from '$timr';
import { createConnectionChannel, type InternalConnectionChannel } from './channel.ts';
import {
BROWSER_EVENT_ONLINE,
BROWSER_EVENT_VISIBILITY_CHANGE,
CONNECTION_ACK_REASON_CLOSED,
CONNECTION_ACK_REASON_REJECTED,
CONNECTION_ACK_REASON_TIMEOUT,
CONNECTION_ACK_REASON_TRANSPORT_ERROR,
CONNECTION_AUTH_REASON_CLOSED,
CONNECTION_AUTH_REASON_NO_PROVIDER,
CONNECTION_AUTH_REASON_REJECTED,
CONNECTION_AUTH_REASON_TIMEOUT,
CONNECTION_AUTH_REASON_TRANSPORT_ERROR,
CONNECTION_BUFFER_POLICY_BUFFER,
CONNECTION_BUFFER_POLICY_DROP,
CONNECTION_CLOSE_REASON_DISCONNECT,
CONNECTION_CLOSE_REASON_DISPOSE,
CONNECTION_CLOSE_REASON_AUTH_FAILED,
CONNECTION_CLOSE_REASON_HEARTBEAT_TIMEOUT,
CONNECTION_CLOSE_REASON_RECONNECT,
CONNECTION_CLOSE_REASON_SESSION_EXPIRED,
CONNECTION_CONNECT_REASON_AUTH_FAILED,
CONNECTION_CONNECT_REASON_DISPOSED,
CONNECTION_CONNECT_REASON_TRANSPORT_ERROR,
CONNECTION_FRAME_TYPE_AUTH,
CONNECTION_FRAME_TYPE_PING,
CONNECTION_FRAME_TYPE_PONG,
CONNECTION_ID_PREFIX,
CONNECTION_ID_SEPARATOR,
CONNECTION_SEND_REASON_BUFFER_FULL,
CONNECTION_SEND_REASON_CLOSED,
CONNECTION_SEND_REASON_INVALID_FRAME,
CONNECTION_SEND_REASON_SEND_NOT_SUPPORTED,
CONNECTION_SEND_REASON_SERIALIZE_FAILED,
CONNECTION_SEND_REASON_TRANSPORT_ERROR,
CONNECTION_STATE_CLOSED,
CONNECTION_STATE_CLOSING,
CONNECTION_STATE_CONNECTING,
CONNECTION_STATE_FAILED,
CONNECTION_STATE_IDLE,
CONNECTION_STATE_OPEN,
CONNECTION_STATE_RECONNECTING,
CONNECTION_TRANSPORT_STATE_OPEN,
DEFAULT_ACK_TIMEOUT_MS,
DEFAULT_BUFFER_MAX_BYTES,
DEFAULT_BUFFER_MAX_MESSAGES,
DEFAULT_HEARTBEAT_ENABLED,
DEFAULT_HEARTBEAT_INTERVAL_MS,
DEFAULT_HEARTBEAT_TIMEOUT_MS,
DEFAULT_RECONNECT_ENABLED,
DEFAULT_RECONNECT_FACTOR,
DEFAULT_RECONNECT_JITTER_MS,
DEFAULT_RECONNECT_MAX_DELAY_MS,
DEFAULT_RECONNECT_MIN_DELAY_MS,
DEFAULT_RECONNECT_ON_ONLINE,
DEFAULT_RECONNECT_ON_VISIBLE,
DOCUMENT_VISIBILITY_VISIBLE,
LOG_MSG_AUTH_FAILED,
LOG_MSG_BROWSER_RECONNECT,
LOG_MSG_CONNECT_FAILED,
LOG_MSG_FRAME_DECODE_FAILED,
LOG_MSG_FRAME_ENCODE_FAILED,
LOG_MSG_HEARTBEAT_TIMEOUT,
LOG_MSG_REAUTH_FAILED,
LOG_MSG_RECONNECT_EXHAUSTED,
LOG_MSG_SEND_FAILED,
LOG_MSG_SESSION_EXPIRED,
LOG_MSG_SESSION_REFRESHED,
LOG_MSG_SESSION_REVOKED,
LOG_MSG_TRANSPORT_ERROR,
SESSION_EVENT_EXPIRED,
SESSION_EVENT_REFRESHED,
SESSION_EVENT_REVOKED,
TIMER_KEY_ACK,
TIMER_KEY_HEARTBEAT,
TIMER_KEY_HEARTBEAT_TIMEOUT,
TIMER_KEY_RECONNECT,
loggerScope,
listenerThrewMessage,
timerKey
} from './consts.ts';
import { assertConnectionFrame, createFrame } from './serializer.ts';
import { jsonConnectionSerializer } from './serializers/json.ts';
import type {
Connection,
ConnectionAckResult,
ConnectionAuthPayload,
ConnectionAuthResult,
ConnectionChannel,
ConnectionChannelMap,
ConnectionChannelOptions,
ConnectionCloseEvent,
ConnectionConnectResult,
ConnectionFrame,
ConnectionLogger,
ConnectionMessageMeta,
ConnectionOptions,
ConnectionRequestOptions,
ConnectionSendResult,
ConnectionSessionSource,
ConnectionState,
ConnectionStateChange,
ConnectionTransport
} from './types.ts';
interface ConnectionRuntime {
readonly logger?: ConnectionLogger;
readonly timers: TimerScheduler;
readonly session?: ConnectionSessionSource;
}
interface PendingAck {
readonly timer: string;
readonly resolve: (result: ConnectionAckResult<unknown>) => void;
}
type GlobalListener = (frame: ConnectionFrame, meta: ConnectionMessageMeta) => void;
type StateListener = (change: ConnectionStateChange) => void;
function createIdFactory(connectionName: string): () => string {
let next = 0;
return () => {
next += 1;
return [CONNECTION_ID_PREFIX, connectionName, String(next)].join(CONNECTION_ID_SEPARATOR);
};
}
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
return typeof value === 'object' && value !== null && 'then' in value;
}
function isClosedLike(state: ConnectionState): boolean {
return (
state === CONNECTION_STATE_CLOSED ||
state === CONNECTION_STATE_FAILED ||
state === CONNECTION_STATE_IDLE
);
}
export function createConnection<TChannels extends ConnectionChannelMap = ConnectionChannelMap>(
name: string,
options: ConnectionOptions<TChannels>,
runtime: ConnectionRuntime
): Connection<TChannels> {
const serializer = options.serializer ?? jsonConnectionSerializer;
const reconnectDisabled = options.reconnect === false;
const reconnectOptions = reconnectDisabled ? undefined : options.reconnect;
const heartbeatOptions = options.heartbeat === false ? false : options.heartbeat;
const bufferOptions = options.buffer ?? {};
const stateListeners = new Set<StateListener>();
const globalListeners = new Set<GlobalListener>();
const pendingAcks = new Map<string, PendingAck>();
const bufferedFrames: ConnectionFrame[] = [];
const channels = new Map<string, InternalConnectionChannel>();
const detachBrowserListeners: (() => void)[] = [];
const nextId = createIdFactory(name);
const scope = loggerScope(name, options.loggerScope);
let transport: ConnectionTransport | null = null;
let detachTransportListeners: (() => void)[] = [];
let state: ConnectionState = CONNECTION_STATE_IDLE;
let generation = 0;
let error: unknown | null = null;
let openedAt: number | null = null;
let closedAt: number | null = null;
let lastMessageAt: number | null = null;
let reconnectAttempt = 0;
let disposed = false;
let intentionalClose = false;
let connectPromise: Promise<ConnectionConnectResult> | null = null;
function now(): number {
return Date.now();
}
function logDebug(message: string, meta?: unknown): void {
runtime.logger?.debug?.(scope, message, meta);
}
function logWarn(message: string, meta?: unknown): void {
runtime.logger?.warn?.(scope, message, meta);
}
function logError(message: string, meta?: unknown): void {
runtime.logger?.error?.(scope, message, meta);
}
function emitState(to: ConnectionState, changeError?: unknown): void {
if (state === to && changeError === undefined) return;
const from = state;
state = to;
const change: ConnectionStateChange = {
connection: name,
from,
to,
generation,
error: changeError,
at: now()
};
for (const listener of [...stateListeners]) {
try {
listener(change);
} catch (err) {
logError(listenerThrewMessage(to), { error: err });
}
}
}
function resolveTransport(): ConnectionTransport {
return typeof options.transport === 'function' ? options.transport() : options.transport;
}
function detachTransport(): void {
for (const detach of detachTransportListeners) detach();
detachTransportListeners = [];
}
function attachTransport(next: ConnectionTransport): void {
detachTransport();
detachTransportListeners = [
next.onOpen(handleTransportOpen),
next.onMessage(handleTransportMessage),
next.onClose(handleTransportClose),
next.onError(handleTransportError)
];
}
function markOpen(): void {
if (state === CONNECTION_STATE_OPEN) return;
generation += 1;
error = null;
openedAt = now();
closedAt = null;
reconnectAttempt = 0;
emitState(CONNECTION_STATE_OPEN);
startHeartbeat();
}
function markClosed(nextState: ConnectionState, cause?: unknown): void {
closedAt = now();
stopHeartbeat();
resolvePendingAcks({ ok: false, reason: CONNECTION_ACK_REASON_CLOSED });
emitState(nextState, cause);
}
function handleTransportOpen(): void {
markOpen();
}
function handleTransportMessage(raw: string | ArrayBuffer): void {
lastMessageAt = now();
cancelTimer(TIMER_KEY_HEARTBEAT_TIMEOUT);
let frame: ConnectionFrame;
try {
frame = serializer.decode(raw);
assertConnectionFrame(frame);
} catch (err) {
error = err;
logError(LOG_MSG_FRAME_DECODE_FAILED, { error: err });
return;
}
if (frame.replyTo !== undefined) {
resolveAckFromFrame(frame);
return;
}
if (frame.type === CONNECTION_FRAME_TYPE_PONG) return;
const meta: ConnectionMessageMeta = {
connection: name,
topic: frame.topic,
receivedAt: lastMessageAt,
generation
};
if (frame.topic !== undefined) {
channels.get(frame.topic)?.receive(frame, meta);
}
emitGlobal(frame, meta);
}
function handleTransportClose(event: ConnectionCloseEvent): void {
if (disposed) return;
if (intentionalClose) {
markClosed(CONNECTION_STATE_CLOSED);
return;
}
error = event;
if (shouldReconnect()) {
markClosed(CONNECTION_STATE_RECONNECTING, event);
scheduleReconnect();
return;
}
markClosed(CONNECTION_STATE_FAILED, event);
}
function handleTransportError(err: unknown): void {
error = err;
logError(LOG_MSG_TRANSPORT_ERROR, { error: err });
}
function emitGlobal(frame: ConnectionFrame, meta: ConnectionMessageMeta): void {
for (const listener of [...globalListeners]) {
try {
listener(frame, meta);
} catch (err) {
logError(listenerThrewMessage(frame.type), { error: err });
}
}
}
function cancelTimer(kind: string, id?: string): void {
runtime.timers.cancel(timerKey(name, kind, id));
}
function scheduleTimer(
kind: string,
delayMs: number,
task: () => void | Promise<void>,
id?: string
): void {
runtime.timers.schedule(timerKey(name, kind, id), delayMs, task, { replace: true });
}
function shouldReconnect(): boolean {
if (disposed || intentionalClose) return false;
if (reconnectDisabled) return false;
return reconnectOptions?.enabled ?? DEFAULT_RECONNECT_ENABLED;
}
function scheduleReconnect(): void {
if (!shouldReconnect()) return;
const maxAttempts = reconnectOptions?.maxAttempts;
if (maxAttempts !== undefined && reconnectAttempt >= maxAttempts) {
logWarn(LOG_MSG_RECONNECT_EXHAUSTED, { reconnectAttempt, maxAttempts });
error = new Error(LOG_MSG_RECONNECT_EXHAUSTED);
emitState(CONNECTION_STATE_FAILED, error);
return;
}
reconnectAttempt += 1;
const delayMs = computeBackoffDelay(reconnectAttempt - 1, {
minDelayMs: reconnectOptions?.minDelayMs ?? DEFAULT_RECONNECT_MIN_DELAY_MS,
maxDelayMs: reconnectOptions?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS,
factor: reconnectOptions?.factor ?? DEFAULT_RECONNECT_FACTOR,
jitterMs: reconnectOptions?.jitterMs ?? DEFAULT_RECONNECT_JITTER_MS
});
scheduleTimer(TIMER_KEY_RECONNECT, delayMs, () => {
void connectInternal(true);
});
}
function startHeartbeat(): void {
if (heartbeatOptions === false) return;
if ((heartbeatOptions?.enabled ?? DEFAULT_HEARTBEAT_ENABLED) === false) return;
const intervalMs = heartbeatOptions?.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
runtime.timers.interval(
timerKey(name, TIMER_KEY_HEARTBEAT),
intervalMs,
() => {
void sendHeartbeat();
},
{ replace: true, awaitTask: false }
);
}
function stopHeartbeat(): void {
cancelTimer(TIMER_KEY_HEARTBEAT);
cancelTimer(TIMER_KEY_HEARTBEAT_TIMEOUT);
}
async function sendHeartbeat(): Promise<void> {
if (!isConnected()) return;
const pingType = heartbeatOptions === false ? undefined : heartbeatOptions?.pingType;
const result = await sendFrame(
createFrame({
type: pingType ?? CONNECTION_FRAME_TYPE_PING,
payload: undefined
}),
false
);
if (!result.ok) return;
const timeoutMs =
heartbeatOptions === false
? DEFAULT_HEARTBEAT_TIMEOUT_MS
: (heartbeatOptions?.timeoutMs ?? DEFAULT_HEARTBEAT_TIMEOUT_MS);
scheduleTimer(TIMER_KEY_HEARTBEAT_TIMEOUT, timeoutMs, () => {
logWarn(LOG_MSG_HEARTBEAT_TIMEOUT);
transport?.close(undefined, CONNECTION_CLOSE_REASON_HEARTBEAT_TIMEOUT);
});
}
function isConnected(): boolean {
return state === CONNECTION_STATE_OPEN && transport?.state === CONNECTION_TRANSPORT_STATE_OPEN;
}
function resolvePendingAcks(result: ConnectionAckResult<unknown>): void {
for (const id of [...pendingAcks.keys()]) resolveAck(id, result);
}
function resolveAck(id: string, result: ConnectionAckResult<unknown>): void {
const pending = pendingAcks.get(id);
if (pending === undefined) return;
pendingAcks.delete(id);
runtime.timers.cancel(pending.timer);
pending.resolve(result);
}
function resolveAckFromFrame(frame: ConnectionFrame): void {
if (frame.replyTo === undefined) return;
if (frame.error !== undefined) {
resolveAck(frame.replyTo, {
ok: false,
reason: CONNECTION_ACK_REASON_REJECTED,
error: frame.error
});
return;
}
resolveAck(frame.replyTo, { ok: true, payload: frame.payload });
}
function mapSendFailureToAck(result: ConnectionSendResult): ConnectionAckResult<unknown> {
if (result.ok) return { ok: true, payload: undefined };
if (result.reason === CONNECTION_SEND_REASON_CLOSED) {
return { ok: false, reason: CONNECTION_ACK_REASON_CLOSED, error: result.error };
}
return { ok: false, reason: CONNECTION_ACK_REASON_TRANSPORT_ERROR, error: result.error };
}
function canBuffer(frame: ConnectionFrame, allowBuffer: boolean): boolean {
if (!allowBuffer) return false;
if (frame.ack === true) return false;
return bufferOptions.policy === CONNECTION_BUFFER_POLICY_BUFFER;
}
function bufferFrame(frame: ConnectionFrame): ConnectionSendResult {
const maxMessages = bufferOptions.maxMessages ?? DEFAULT_BUFFER_MAX_MESSAGES;
if (bufferedFrames.length >= maxMessages) {
return { ok: false, reason: CONNECTION_SEND_REASON_BUFFER_FULL };
}
bufferedFrames.push(frame);
return { ok: true, id: frame.id };
}
async function flushBuffer(): Promise<void> {
while (isConnected() && bufferedFrames.length > 0) {
const frame = bufferedFrames.shift();
if (frame === undefined) return;
const result = await sendFrame(frame, false);
if (!result.ok) {
bufferedFrames.unshift(frame);
return;
}
}
}
async function sendFrame(
frame: ConnectionFrame,
allowBuffer: boolean
): Promise<ConnectionSendResult> {
try {
createFrame(frame);
} catch (err) {
return { ok: false, reason: CONNECTION_SEND_REASON_INVALID_FRAME, error: err };
}
if (!isConnected()) {
if (canBuffer(frame, allowBuffer)) return bufferFrame(frame);
if (bufferOptions.policy === CONNECTION_BUFFER_POLICY_DROP) {
return { ok: true, id: frame.id };
}
return { ok: false, reason: CONNECTION_SEND_REASON_CLOSED };
}
const currentTransport = transport;
if (currentTransport === null || !currentTransport.canSend) {
return { ok: false, reason: CONNECTION_SEND_REASON_SEND_NOT_SUPPORTED };
}
const maxBytes = bufferOptions.maxBytes ?? DEFAULT_BUFFER_MAX_BYTES;
if (currentTransport.bufferedAmount > maxBytes) {
return { ok: false, reason: CONNECTION_SEND_REASON_BUFFER_FULL };
}
let encoded: string | ArrayBuffer;
try {
encoded = serializer.encode(frame);
} catch (err) {
logError(LOG_MSG_FRAME_ENCODE_FAILED, { error: err });
return { ok: false, reason: CONNECTION_SEND_REASON_SERIALIZE_FAILED, error: err };
}
try {
const maybe = currentTransport.send(encoded);
if (isPromiseLike(maybe)) await maybe;
return { ok: true, id: frame.id };
} catch (err) {
error = err;
logError(LOG_MSG_SEND_FAILED, { error: err });
return { ok: false, reason: CONNECTION_SEND_REASON_TRANSPORT_ERROR, error: err };
}
}
function getAuthProvider():
| (() => ConnectionAuthPayload | null | Promise<ConnectionAuthPayload | null>)
| null {
if (typeof options.auth === 'function') return options.auth;
return options.auth?.getAuth ?? null;
}
async function runAuth(): Promise<ConnectionAuthResult> {
if (!isConnected()) return { ok: false, reason: CONNECTION_AUTH_REASON_CLOSED };
const provider = getAuthProvider();
if (provider === null) return { ok: false, reason: CONNECTION_AUTH_REASON_NO_PROVIDER };
let payload: ConnectionAuthPayload | null;
try {
payload = await provider();
} catch (err) {
return { ok: false, reason: CONNECTION_AUTH_REASON_REJECTED, error: err };
}
if (payload === null) return { ok: false, reason: CONNECTION_AUTH_REASON_NO_PROVIDER };
const authOptions = typeof options.auth === 'function' ? undefined : options.auth;
const authType =
typeof options.auth === 'function' ? CONNECTION_FRAME_TYPE_AUTH : authOptions?.authType;
const result = await requestFrame<ConnectionAuthPayload, unknown>(
authType ?? CONNECTION_FRAME_TYPE_AUTH,
payload,
{ timeoutMs: authOptions?.timeoutMs }
);
if (result.ok) return { ok: true };
if (result.reason === CONNECTION_ACK_REASON_TIMEOUT) {
return { ok: false, reason: CONNECTION_AUTH_REASON_TIMEOUT, error: result.error };
}
if (result.reason === CONNECTION_ACK_REASON_CLOSED) {
return { ok: false, reason: CONNECTION_AUTH_REASON_CLOSED, error: result.error };
}
if (result.reason === CONNECTION_ACK_REASON_REJECTED) {
return { ok: false, reason: CONNECTION_AUTH_REASON_REJECTED, error: result.error };
}
return { ok: false, reason: CONNECTION_AUTH_REASON_TRANSPORT_ERROR, error: result.error };
}
async function requestFrame<TPayload, TResult>(
type: string,
payload: TPayload,
requestOptions: ConnectionRequestOptions = {}
): Promise<ConnectionAckResult<TResult>> {
if (!isConnected()) return { ok: false, reason: CONNECTION_ACK_REASON_CLOSED };
const id = requestOptions.id ?? nextId();
const frame = createFrame({
id,
type,
payload,
topic: requestOptions.topic,
ack: true,
ts: requestOptions.ts
});
const timeoutMs = requestOptions.timeoutMs ?? DEFAULT_ACK_TIMEOUT_MS;
const ackTimer = timerKey(name, TIMER_KEY_ACK, id);
const promise = new Promise<ConnectionAckResult<TResult>>((resolve) => {
pendingAcks.set(id, {
timer: ackTimer,
resolve: resolve as (result: ConnectionAckResult<unknown>) => void
});
runtime.timers.schedule(
ackTimer,
timeoutMs,
() => {
resolveAck(id, { ok: false, reason: CONNECTION_ACK_REASON_TIMEOUT });
},
{ replace: true }
);
});
const result = await sendFrame(frame, requestOptions.buffer === true);
if (!result.ok) resolveAck(id, mapSendFailureToAck(result));
return promise;
}
async function joinConfiguredChannels(reconnect: boolean): Promise<void> {
const configured = (options.channels ?? {}) as Record<
string,
ConnectionChannelOptions | undefined
>;
for (const channelName of Object.keys(configured)) {
const configuredOptions = configured[channelName] ?? {};
const current = connection.channel(
channelName,
configuredOptions
) as InternalConnectionChannel;
if (configuredOptions.autoJoin === true || (reconnect && current.shouldRejoin)) {
await current.join();
}
}
if (!reconnect) return;
for (const current of channels.values()) {
if (current.shouldRejoin) await current.rejoin();
}
}
async function connectInternal(reconnect: boolean): Promise<ConnectionConnectResult> {
if (disposed) {
return {
ok: false,
state,
reason: CONNECTION_CONNECT_REASON_DISPOSED
};
}
if (state === CONNECTION_STATE_OPEN && isConnected()) {
return { ok: true, state, reused: true };
}
if (connectPromise !== null) return connectPromise;
connectPromise = (async () => {
intentionalClose = false;
cancelTimer(TIMER_KEY_RECONNECT);
const nextTransport = resolveTransport();
transport = nextTransport;
attachTransport(nextTransport);
emitState(reconnect ? CONNECTION_STATE_RECONNECTING : CONNECTION_STATE_CONNECTING);
try {
await nextTransport.open();
markOpen();
if (getAuthProvider() !== null) {
const auth = await runAuth();
if (!auth.ok) {
error = auth.error ?? auth.reason;
logWarn(LOG_MSG_AUTH_FAILED, auth);
detachTransport();
transport?.close(undefined, CONNECTION_CLOSE_REASON_AUTH_FAILED);
markClosed(CONNECTION_STATE_FAILED, error);
return {
ok: false,
state,
reason: CONNECTION_CONNECT_REASON_AUTH_FAILED,
error
};
}
}
await flushBuffer();
await joinConfiguredChannels(reconnect);
return { ok: true, state };
} catch (err) {
error = err;
logError(LOG_MSG_CONNECT_FAILED, { error: err });
emitState(CONNECTION_STATE_FAILED, err);
return {
ok: false,
state,
reason: CONNECTION_CONNECT_REASON_TRANSPORT_ERROR,
error: err
};
} finally {
connectPromise = null;
}
})();
return connectPromise;
}
function closeTransport(reason: string): void {
intentionalClose = true;
cancelTimer(TIMER_KEY_RECONNECT);
stopHeartbeat();
resolvePendingAcks({ ok: false, reason: CONNECTION_ACK_REASON_CLOSED });
emitState(CONNECTION_STATE_CLOSING);
transport?.close(undefined, reason);
markClosed(CONNECTION_STATE_CLOSED);
}
function wireBrowserReconnect(): void {
if (reconnectDisabled) return;
const reconnectOnOnline = reconnectOptions?.reconnectOnOnline ?? DEFAULT_RECONNECT_ON_ONLINE;
const reconnectOnVisible = reconnectOptions?.reconnectOnVisible ?? DEFAULT_RECONNECT_ON_VISIBLE;
const target = globalThis as {
addEventListener?: (type: string, listener: () => void) => void;
removeEventListener?: (type: string, listener: () => void) => void;
document?: {
readonly visibilityState?: string;
addEventListener?: (type: string, listener: () => void) => void;
removeEventListener?: (type: string, listener: () => void) => void;
};
};
if (reconnectOnOnline && target.addEventListener && target.removeEventListener) {
const onOnline = (): void => {
if (intentionalClose || disposed || !isClosedLike(state)) return;
logDebug(LOG_MSG_BROWSER_RECONNECT);
void connectInternal(true);
};
target.addEventListener(BROWSER_EVENT_ONLINE, onOnline);
detachBrowserListeners.push(() => {
target.removeEventListener?.(BROWSER_EVENT_ONLINE, onOnline);
});
}
if (
reconnectOnVisible &&
target.document?.addEventListener &&
target.document.removeEventListener
) {
const onVisible = (): void => {
if (target.document?.visibilityState !== DOCUMENT_VISIBILITY_VISIBLE) return;
if (intentionalClose || disposed || !isClosedLike(state)) return;
logDebug(LOG_MSG_BROWSER_RECONNECT);
void connectInternal(true);
};
target.document.addEventListener(BROWSER_EVENT_VISIBILITY_CHANGE, onVisible);
detachBrowserListeners.push(() => {
target.document?.removeEventListener?.(BROWSER_EVENT_VISIBILITY_CHANGE, onVisible);
});
}
}
function wireSession(): () => void {
const sessionOptions = options.session;
if (
sessionOptions === undefined ||
sessionOptions === false ||
sessionOptions.enabled === false
) {
return () => {};
}
const source = runtime.session;
if (source === undefined) return () => {};
return source.onChange((change) => {
if (change.event === SESSION_EVENT_REFRESHED && sessionOptions.reauthOnRefresh !== false) {
logDebug(LOG_MSG_SESSION_REFRESHED);
void connection.reauthenticate().then((result) => {
if (!result.ok) logWarn(LOG_MSG_REAUTH_FAILED, result);
});
return;
}
if (
(change.event === SESSION_EVENT_EXPIRED || change.event === SESSION_EVENT_REVOKED) &&
sessionOptions.disconnectOnExpire !== false
) {
logWarn(
change.event === SESSION_EVENT_EXPIRED ? LOG_MSG_SESSION_EXPIRED : LOG_MSG_SESSION_REVOKED
);
connection.disconnect(CONNECTION_CLOSE_REASON_SESSION_EXPIRED);
}
});
}
function reportChannelListenerError(event: string, err: unknown): void {
logError(listenerThrewMessage(event), { error: err });
}
let detachSession = (): void => {};
const connection: Connection<TChannels> = {
name,
get state() {
return state;
},
get connected() {
return isConnected();
},
get generation() {
return generation;
},
get error() {
return error;
},
get openedAt() {
return openedAt;
},
get closedAt() {
return closedAt;
},
get lastMessageAt() {
return lastMessageAt;
},
get reconnectAttempt() {
return reconnectAttempt;
},
connect() {
return connectInternal(false);
},
disconnect(reason = CONNECTION_CLOSE_REASON_DISCONNECT) {
if (disposed) return;
closeTransport(reason);
},
reconnect(reason = CONNECTION_CLOSE_REASON_RECONNECT) {
if (disposed) {
return Promise.resolve({
ok: false,
state,
reason: CONNECTION_CONNECT_REASON_DISPOSED
});
}
closeTransport(reason);
intentionalClose = false;
return connectInternal(true);
},
reauthenticate() {
return runAuth();
},
send(type, payload, sendOptions = {}) {
const frame = createFrame({
id: sendOptions.id,
type,
payload,
topic: sendOptions.topic,
ack: sendOptions.ack,
ts: sendOptions.ts
});
return sendFrame(frame, sendOptions.buffer !== false);
},
request(type, payload, requestOptions = {}) {
return requestFrame(type, payload, requestOptions);
},
channel<TEvents extends Record<string, unknown> = Record<string, unknown>>(
channelName: string,
channelOptions: ConnectionChannelOptions = {}
): ConnectionChannel<TEvents> {
const existing = channels.get(channelName);
if (existing !== undefined) return existing as ConnectionChannel<TEvents>;
const created = createConnectionChannel<TEvents>(channelName, channelOptions, {
connection: connection as unknown as Connection<Record<string, TEvents>>,
reportListenerError: reportChannelListenerError
});
channels.set(channelName, created as InternalConnectionChannel);
return created;
},
channels() {
return [...channels.values()];
},
hasChannel(channelName) {
return channels.has(channelName);
},
async leaveChannel(channelName) {
await channels.get(channelName)?.leave();
},
onState(listener) {
stateListeners.add(listener);
return () => {
stateListeners.delete(listener);
};
},
onAny(listener) {
globalListeners.add(listener);
return () => {
globalListeners.delete(listener);
};
},
dispose() {
if (disposed) return;
disposed = true;
closeTransport(CONNECTION_CLOSE_REASON_DISPOSE);
detachSession();
for (const detach of detachBrowserListeners) detach();
detachBrowserListeners.length = 0;
detachTransport();
for (const current of channels.values()) current.dispose();
channels.clear();
stateListeners.clear();
globalListeners.clear();
bufferedFrames.length = 0;
}
};
detachSession = wireSession();
wireBrowserReconnect();
return connection;
}

@ -0,0 +1,249 @@
export const LOGGER_CATEGORY = 'conn';
export const LOGGER_SCOPE_SEPARATOR = ':';
export const CONNECTION_STATE_IDLE = 'idle';
export const CONNECTION_STATE_CONNECTING = 'connecting';
export const CONNECTION_STATE_OPEN = 'open';
export const CONNECTION_STATE_RECONNECTING = 'reconnecting';
export const CONNECTION_STATE_CLOSING = 'closing';
export const CONNECTION_STATE_CLOSED = 'closed';
export const CONNECTION_STATE_FAILED = 'failed';
export const CONNECTION_STATES = [
CONNECTION_STATE_IDLE,
CONNECTION_STATE_CONNECTING,
CONNECTION_STATE_OPEN,
CONNECTION_STATE_RECONNECTING,
CONNECTION_STATE_CLOSING,
CONNECTION_STATE_CLOSED,
CONNECTION_STATE_FAILED
] as const;
export const CONNECTION_CHANNEL_STATE_IDLE = 'idle';
export const CONNECTION_CHANNEL_STATE_JOINING = 'joining';
export const CONNECTION_CHANNEL_STATE_JOINED = 'joined';
export const CONNECTION_CHANNEL_STATE_LEAVING = 'leaving';
export const CONNECTION_CHANNEL_STATE_LEFT = 'left';
export const CONNECTION_CHANNEL_STATE_FAILED = 'failed';
export const CONNECTION_TRANSPORT_STATE_IDLE = 'idle';
export const CONNECTION_TRANSPORT_STATE_OPENING = 'opening';
export const CONNECTION_TRANSPORT_STATE_OPEN = 'open';
export const CONNECTION_TRANSPORT_STATE_CLOSING = 'closing';
export const CONNECTION_TRANSPORT_STATE_CLOSED = 'closed';
export const CONNECTION_TRANSPORT_STATES = [
CONNECTION_TRANSPORT_STATE_IDLE,
CONNECTION_TRANSPORT_STATE_OPENING,
CONNECTION_TRANSPORT_STATE_OPEN,
CONNECTION_TRANSPORT_STATE_CLOSING,
CONNECTION_TRANSPORT_STATE_CLOSED
] as const;
export const TRANSPORT_KIND_WEBSOCKET = 'websocket';
export const TRANSPORT_KIND_MOCK = 'mock';
export const FRAME_KEY_ID = 'id';
export const FRAME_KEY_TYPE = 'type';
export const FRAME_KEY_TOPIC = 'topic';
export const FRAME_KEY_PAYLOAD = 'payload';
export const FRAME_KEY_TS = 'ts';
export const FRAME_KEY_ACK = 'ack';
export const FRAME_KEY_REPLY_TO = 'replyTo';
export const FRAME_KEY_ERROR = 'error';
export const DEFAULT_ACK_TIMEOUT_MS = 10_000;
export const DEFAULT_RECONNECT_MIN_DELAY_MS = 500;
export const DEFAULT_RECONNECT_MAX_DELAY_MS = 15_000;
export const DEFAULT_RECONNECT_FACTOR = 1.8;
export const DEFAULT_RECONNECT_JITTER_MS = 500;
export const DEFAULT_HEARTBEAT_INTERVAL_MS = 25_000;
export const DEFAULT_HEARTBEAT_TIMEOUT_MS = 10_000;
export const DEFAULT_BUFFER_MAX_MESSAGES = 100;
export const DEFAULT_BUFFER_MAX_BYTES = 1_000_000;
export const DEFAULT_RECONNECT_ENABLED = true;
export const DEFAULT_RECONNECT_ON_VISIBLE = true;
export const DEFAULT_RECONNECT_ON_ONLINE = true;
export const DEFAULT_HEARTBEAT_ENABLED = true;
export const CONNECTION_BUFFER_POLICY_BUFFER = 'buffer';
export const CONNECTION_BUFFER_POLICY_DROP = 'drop';
export const CONNECTION_BUFFER_POLICY_FAIL = 'fail';
export const CONNECTION_SEND_REASON_CLOSED = 'closed';
export const CONNECTION_SEND_REASON_SEND_NOT_SUPPORTED = 'send_not_supported';
export const CONNECTION_SEND_REASON_BUFFER_FULL = 'buffer_full';
export const CONNECTION_SEND_REASON_INVALID_FRAME = 'invalid_frame';
export const CONNECTION_SEND_REASON_SERIALIZE_FAILED = 'serialize_failed';
export const CONNECTION_SEND_REASON_TRANSPORT_ERROR = 'transport_error';
export const CONNECTION_ACK_REASON_TIMEOUT = 'timeout';
export const CONNECTION_ACK_REASON_CLOSED = 'closed';
export const CONNECTION_ACK_REASON_REJECTED = 'rejected';
export const CONNECTION_ACK_REASON_TRANSPORT_ERROR = 'transport_error';
export const CONNECTION_ACK_REASON_INVALID_REPLY = 'invalid_reply';
export const CONNECTION_AUTH_REASON_NO_PROVIDER = 'no_auth_provider';
export const CONNECTION_AUTH_REASON_CLOSED = 'closed';
export const CONNECTION_AUTH_REASON_REJECTED = 'rejected';
export const CONNECTION_AUTH_REASON_TIMEOUT = 'timeout';
export const CONNECTION_AUTH_REASON_TRANSPORT_ERROR = 'transport_error';
export const CONNECTION_CONNECT_REASON_TRANSPORT_ERROR = 'transport_error';
export const CONNECTION_CONNECT_REASON_AUTH_FAILED = 'auth_failed';
export const CONNECTION_CONNECT_REASON_DISPOSED = 'disposed';
export const CONNECTION_CONNECT_REASON_RECONNECT_EXHAUSTED = 'reconnect_exhausted';
export const CONNECTION_CHANNEL_JOIN_REASON_CLOSED = 'closed';
export const CONNECTION_CHANNEL_JOIN_REASON_REJECTED = 'rejected';
export const CONNECTION_CHANNEL_JOIN_REASON_TRANSPORT_ERROR = 'transport_error';
export const CONNECTION_EVENT_STATE = 'state';
export const CONNECTION_EVENT_MESSAGE = 'message';
export const CONNECTION_EVENT_CHANNELS = 'channels';
export const CONNECTION_EVENT_DISPOSED = 'disposed';
export const CONNECTION_METHOD_CONNECT = 'connect';
export const CONNECTION_METHOD_DISCONNECT = 'disconnect';
export const CONNECTION_METHOD_RECONNECT = 'reconnect';
export const CONNECTION_METHOD_SEND = 'send';
export const CONNECTION_METHOD_REQUEST = 'request';
export const CONNECTION_METHOD_CREATE_CONNECTION = 'createConnection';
export const CONNECTION_METHOD_CONNECTION = 'connection';
export const CONNECTION_METHOD_OPEN_CONNECTION = 'openConnection';
export const CONNECTION_METHOD_CLOSE_CONNECTION = 'closeConnection';
export const CONNECTION_METHOD_RECONNECT_CONNECTION = 'reconnectConnection';
export const CONNECTION_METHOD_OPEN_ALL = 'openAll';
export const CONNECTION_METHOD_CLOSE_ALL = 'closeAll';
export const CONNECTION_METHOD_RECONNECT_ALL = 'reconnectAll';
export const CONNECTION_METHOD_REAUTHENTICATE = 'reauthenticate';
export const CONNECTION_METHOD_JOIN = 'join';
export const CONNECTION_METHOD_LEAVE = 'leave';
export const CONNECTION_FRAME_TYPE_AUTH = 'conn.auth';
export const CONNECTION_FRAME_TYPE_JOIN = 'conn.join';
export const CONNECTION_FRAME_TYPE_LEAVE = 'conn.leave';
export const CONNECTION_FRAME_TYPE_PING = 'conn.ping';
export const CONNECTION_FRAME_TYPE_PONG = 'conn.pong';
export const CONNECTION_ID_PREFIX = 'conn';
export const CONNECTION_ID_SEPARATOR = '-';
export const CONNECTION_CLOSE_REASON_DISCONNECT = 'disconnect';
export const CONNECTION_CLOSE_REASON_RECONNECT = 'reconnect';
export const CONNECTION_CLOSE_REASON_DISPOSE = 'dispose';
export const CONNECTION_CLOSE_REASON_HEARTBEAT_TIMEOUT = 'heartbeat_timeout';
export const CONNECTION_CLOSE_REASON_SESSION_EXPIRED = 'session_expired';
export const CONNECTION_CLOSE_REASON_AUTH_FAILED = 'auth_failed';
export const TIMER_KEY_RECONNECT = 'reconnect';
export const TIMER_KEY_HEARTBEAT = 'heartbeat';
export const TIMER_KEY_HEARTBEAT_TIMEOUT = 'heartbeat-timeout';
export const TIMER_KEY_ACK = 'ack';
export const BROWSER_EVENT_OPEN = 'open';
export const BROWSER_EVENT_MESSAGE = 'message';
export const BROWSER_EVENT_CLOSE = 'close';
export const BROWSER_EVENT_ERROR = 'error';
export const BROWSER_EVENT_ONLINE = 'online';
export const BROWSER_EVENT_VISIBILITY_CHANGE = 'visibilitychange';
export const DOCUMENT_VISIBILITY_VISIBLE = 'visible';
export const WEBSOCKET_BINARY_TYPE_ARRAYBUFFER = 'arraybuffer';
export const WEBSOCKET_READY_STATE_CONNECTING = 0;
export const WEBSOCKET_READY_STATE_OPEN = 1;
export const WEBSOCKET_READY_STATE_CLOSING = 2;
export const WEBSOCKET_READY_STATE_CLOSED = 3;
export const WEBSOCKET_CLOSE_CODE_NORMAL = 1000;
export const WEBSOCKET_CLOSE_CODE_ABNORMAL = 1006;
export const MOCK_TRANSPORT_DEFAULT_CLOSE_CODE = WEBSOCKET_CLOSE_CODE_NORMAL;
export const MOCK_TRANSPORT_DEFAULT_CLOSE_CLEAN = true;
export const SESSION_EVENT_REFRESHED = 'REFRESHED';
export const SESSION_EVENT_EXPIRED = 'EXPIRED';
export const SESSION_EVENT_REVOKED = 'REVOKED';
export const LOG_MSG_LISTENER_THREW_PREFIX = 'Listener threw on ';
export const LOG_MSG_TRANSPORT_ERROR = 'transport error';
export const LOG_MSG_RECONNECT_EXHAUSTED = 'reconnect attempts exhausted';
export const LOG_MSG_CONNECT_FAILED = 'connect failed';
export const LOG_MSG_SEND_FAILED = 'send failed';
export const LOG_MSG_FRAME_DECODE_FAILED = 'frame decode failed';
export const LOG_MSG_FRAME_ENCODE_FAILED = 'frame encode failed';
export const LOG_MSG_AUTH_FAILED = 'auth failed';
export const LOG_MSG_REAUTH_FAILED = 'reauthentication failed';
export const LOG_MSG_HEARTBEAT_TIMEOUT = 'heartbeat timeout';
export const LOG_MSG_SESSION_REVOKED = 'session revoked; disconnecting connection';
export const LOG_MSG_SESSION_EXPIRED = 'session expired; disconnecting connection';
export const LOG_MSG_SESSION_REFRESHED = 'session refreshed; reauthenticating connection';
export const LOG_MSG_BROWSER_RECONNECT = 'browser lifecycle reconnect requested';
export const ERROR_PREFIX = '[conn] ';
export const ERROR_NAME_DISPOSED = 'ConnDisposedError';
export const ERROR_NAME_ALREADY_EXISTS = 'ConnConnectionAlreadyExistsError';
export const ERROR_NAME_NOT_FOUND = 'ConnConnectionNotFoundError';
export const ERROR_NAME_INVALID_NAME = 'ConnInvalidConnectionNameError';
export const ERROR_NAME_INVALID_FRAME = 'ConnInvalidFrameError';
export const ERROR_NAME_CHANNEL_ALREADY_EXISTS = 'ConnChannelAlreadyExistsError';
export const ERROR_NAME_CHANNEL_NOT_FOUND = 'ConnChannelNotFoundError';
export const ERROR_MSG_DISPOSED_SUFFIX = '() called on a disposed connection engine';
export const ERROR_MSG_ALREADY_EXISTS_PREFIX = 'connection already exists: ';
export const ERROR_MSG_NOT_FOUND_PREFIX = 'connection not found: ';
export const ERROR_MSG_INVALID_NAME_PREFIX = 'invalid connection name: ';
export const ERROR_MSG_INVALID_FRAME = 'invalid connection frame';
export const ERROR_MSG_FRAME_MUST_BE_OBJECT = 'Connection frame must be an object';
export const ERROR_MSG_FRAME_TYPE_MUST_BE_STRING =
'Connection frame type must be a non-empty string';
export const ERROR_MSG_FRAME_ID_MUST_BE_STRING = 'Connection frame id must be a string';
export const ERROR_MSG_FRAME_TOPIC_MUST_BE_STRING = 'Connection frame topic must be a string';
export const ERROR_MSG_FRAME_REPLY_TO_MUST_BE_STRING = 'Connection frame replyTo must be a string';
export const ERROR_MSG_FRAME_ACK_MUST_BE_BOOLEAN = 'Connection frame ack must be a boolean';
export const ERROR_MSG_FRAME_TS_MUST_BE_NUMBER = 'Connection frame ts must be a number';
export const ERROR_MSG_FRAME_ERROR_MUST_BE_OBJECT = 'Connection frame error must be an object';
export const ERROR_MSG_JSON_DECODE_FAILED = 'Connection JSON payload is invalid';
export const ERROR_MSG_WEBSOCKET_UNAVAILABLE = 'WebSocket is not available in this runtime';
export const ERROR_MSG_CHANNEL_ALREADY_EXISTS_PREFIX = 'channel already exists: ';
export const ERROR_MSG_CHANNEL_NOT_FOUND_PREFIX = 'channel not found: ';
export function disposedErrorMessage(method: string): string {
return `${ERROR_PREFIX}${method}${ERROR_MSG_DISPOSED_SUFFIX}`;
}
export function alreadyExistsErrorMessage(name: string): string {
return `${ERROR_PREFIX}${ERROR_MSG_ALREADY_EXISTS_PREFIX}${name}`;
}
export function notFoundErrorMessage(name: string): string {
return `${ERROR_PREFIX}${ERROR_MSG_NOT_FOUND_PREFIX}${name}`;
}
export function invalidNameErrorMessage(name: unknown): string {
return `${ERROR_PREFIX}${ERROR_MSG_INVALID_NAME_PREFIX}${String(name)}`;
}
export function channelAlreadyExistsErrorMessage(name: string): string {
return `${ERROR_PREFIX}${ERROR_MSG_CHANNEL_ALREADY_EXISTS_PREFIX}${name}`;
}
export function channelNotFoundErrorMessage(name: string): string {
return `${ERROR_PREFIX}${ERROR_MSG_CHANNEL_NOT_FOUND_PREFIX}${name}`;
}
export function listenerThrewMessage(event: string): string {
return `${LOG_MSG_LISTENER_THREW_PREFIX}${event}`;
}
export function loggerScope(name: string, topic?: string): string {
return topic === undefined
? `${LOGGER_CATEGORY}${LOGGER_SCOPE_SEPARATOR}${name}`
: `${LOGGER_CATEGORY}${LOGGER_SCOPE_SEPARATOR}${name}${LOGGER_SCOPE_SEPARATOR}${topic}`;
}
export function timerKey(connection: string, kind: string, id?: string): string {
return id === undefined
? `${LOGGER_CATEGORY}${LOGGER_SCOPE_SEPARATOR}${connection}${LOGGER_SCOPE_SEPARATOR}${kind}`
: `${LOGGER_CATEGORY}${LOGGER_SCOPE_SEPARATOR}${connection}${LOGGER_SCOPE_SEPARATOR}${kind}${LOGGER_SCOPE_SEPARATOR}${id}`;
}

@ -0,0 +1,173 @@
import { createEngineTimers } from '$timr';
import { createConnection } from './connection.ts';
import {
CONNECTION_METHOD_CLOSE_ALL,
CONNECTION_METHOD_CLOSE_CONNECTION,
CONNECTION_METHOD_CONNECTION,
CONNECTION_METHOD_CREATE_CONNECTION,
CONNECTION_METHOD_OPEN_ALL,
CONNECTION_METHOD_OPEN_CONNECTION,
CONNECTION_METHOD_RECONNECT_ALL,
CONNECTION_METHOD_RECONNECT_CONNECTION,
alreadyExistsErrorMessage,
disposedErrorMessage,
invalidNameErrorMessage,
notFoundErrorMessage
} from './consts.ts';
import {
ConnConnectionAlreadyExistsError,
ConnConnectionNotFoundError,
ConnDisposedError,
ConnInvalidConnectionNameError
} from './errors.ts';
import type {
Connection,
ConnectionChannelMap,
ConnectionMap,
ConnectionOptions,
EngineConnections,
EngineConnectionsOptions
} from './types.ts';
function validateConnectionName(name: unknown): asserts name is string {
if (typeof name !== 'string' || name.trim().length === 0) {
throw new ConnInvalidConnectionNameError(invalidNameErrorMessage(name));
}
}
function mergeObject<T extends object>(
defaults: T | undefined,
next: T | undefined
): T | undefined {
if (defaults === undefined) return next;
if (next === undefined) return defaults;
return { ...defaults, ...next };
}
function mergeConnectionOptions<TChannels extends ConnectionChannelMap>(
defaults: Partial<ConnectionOptions> | undefined,
options: ConnectionOptions<TChannels>
): ConnectionOptions<TChannels> {
const reconnect =
options.reconnect === false
? false
: defaults?.reconnect === false
? (options.reconnect ?? false)
: mergeObject(defaults?.reconnect, options.reconnect);
const heartbeat =
options.heartbeat === false
? false
: defaults?.heartbeat === false
? (options.heartbeat ?? false)
: mergeObject(defaults?.heartbeat, options.heartbeat);
return {
...defaults,
...options,
reconnect,
heartbeat,
buffer: mergeObject(defaults?.buffer, options.buffer),
channels: mergeObject(
defaults?.channels,
options.channels
) as ConnectionOptions<TChannels>['channels']
} as ConnectionOptions<TChannels>;
}
export function createEngineConnections<TConnections extends ConnectionMap = ConnectionMap>(
options: EngineConnectionsOptions = {}
): EngineConnections<TConnections> {
const connections = new Map<string, Connection>();
const timers =
options.timers ??
createEngineTimers({
logger: options.logger
});
const ownsTimers = options.timers === undefined;
let disposed = false;
function ensureLive(method: string): void {
if (disposed) throw new ConnDisposedError(disposedErrorMessage(method));
}
function getConnection(name: string, method: string): Connection {
ensureLive(method);
validateConnectionName(name);
const connection = connections.get(name);
if (connection === undefined) {
throw new ConnConnectionNotFoundError(notFoundErrorMessage(name), name);
}
return connection;
}
const engine: EngineConnections<TConnections> = {
createConnection<TChannels extends ConnectionChannelMap = ConnectionChannelMap>(
name: string,
connectionOptions: ConnectionOptions<TChannels>
): Connection<TChannels> {
ensureLive(CONNECTION_METHOD_CREATE_CONNECTION);
validateConnectionName(name);
if (connections.has(name)) {
throw new ConnConnectionAlreadyExistsError(alreadyExistsErrorMessage(name), name);
}
const connection = createConnection<TChannels>(
name,
mergeConnectionOptions<TChannels>(options.defaults, connectionOptions),
{
logger: options.logger,
timers,
session: options.session
}
);
connections.set(name, connection as Connection);
return connection;
},
connection<TChannels extends ConnectionChannelMap = ConnectionChannelMap>(
name: string
): Connection<TChannels> {
return getConnection(name, CONNECTION_METHOD_CONNECTION) as Connection<TChannels>;
},
has(name) {
if (disposed) return false;
return connections.has(name);
},
names() {
if (disposed) return [];
return [...connections.keys()];
},
openConnection(name) {
return getConnection(name, CONNECTION_METHOD_OPEN_CONNECTION).connect();
},
closeConnection(name, reason) {
getConnection(name, CONNECTION_METHOD_CLOSE_CONNECTION).disconnect(reason);
},
reconnectConnection(name, reason) {
return getConnection(name, CONNECTION_METHOD_RECONNECT_CONNECTION).reconnect(reason);
},
openAll() {
ensureLive(CONNECTION_METHOD_OPEN_ALL);
return Promise.all([...connections.values()].map((connection) => connection.connect()));
},
closeAll(reason) {
ensureLive(CONNECTION_METHOD_CLOSE_ALL);
for (const connection of connections.values()) connection.disconnect(reason);
},
reconnectAll(reason) {
ensureLive(CONNECTION_METHOD_RECONNECT_ALL);
return Promise.all(
[...connections.values()].map((connection) => connection.reconnect(reason))
);
},
close(name, reason) {
this.closeConnection(name, reason);
},
dispose() {
if (disposed) return;
disposed = true;
for (const connection of connections.values()) connection.dispose();
connections.clear();
if (ownsTimers) timers.dispose();
}
};
return engine;
}

@ -0,0 +1,90 @@
import {
ERROR_NAME_ALREADY_EXISTS,
ERROR_NAME_CHANNEL_ALREADY_EXISTS,
ERROR_NAME_CHANNEL_NOT_FOUND,
ERROR_NAME_DISPOSED,
ERROR_NAME_INVALID_FRAME,
ERROR_NAME_INVALID_NAME,
ERROR_NAME_NOT_FOUND
} from './consts.ts';
abstract class ConnEngineError extends Error {
abstract readonly name: string;
constructor(message: string, options?: { cause?: unknown }) {
super(message, options);
}
}
export class ConnDisposedError extends ConnEngineError {
readonly name = ERROR_NAME_DISPOSED;
}
export class ConnConnectionAlreadyExistsError extends ConnEngineError {
readonly name = ERROR_NAME_ALREADY_EXISTS;
readonly connection: string;
constructor(message: string, connection: string) {
super(message);
this.connection = connection;
}
}
export class ConnConnectionNotFoundError extends ConnEngineError {
readonly name = ERROR_NAME_NOT_FOUND;
readonly connection: string;
constructor(message: string, connection: string) {
super(message);
this.connection = connection;
}
}
export class ConnInvalidConnectionNameError extends ConnEngineError {
readonly name = ERROR_NAME_INVALID_NAME;
}
export class ConnInvalidFrameError extends ConnEngineError {
readonly name = ERROR_NAME_INVALID_FRAME;
}
export class ConnChannelAlreadyExistsError extends ConnEngineError {
readonly name = ERROR_NAME_CHANNEL_ALREADY_EXISTS;
readonly channel: string;
constructor(message: string, channel: string) {
super(message);
this.channel = channel;
}
}
export class ConnChannelNotFoundError extends ConnEngineError {
readonly name = ERROR_NAME_CHANNEL_NOT_FOUND;
readonly channel: string;
constructor(message: string, channel: string) {
super(message);
this.channel = channel;
}
}
export function isConnDisposedError(value: unknown): value is ConnDisposedError {
return value instanceof Error && (value as { name?: string }).name === ERROR_NAME_DISPOSED;
}
export function isConnConnectionAlreadyExistsError(
value: unknown
): value is ConnConnectionAlreadyExistsError {
return value instanceof Error && (value as { name?: string }).name === ERROR_NAME_ALREADY_EXISTS;
}
export function isConnConnectionNotFoundError(
value: unknown
): value is ConnConnectionNotFoundError {
return value instanceof Error && (value as { name?: string }).name === ERROR_NAME_NOT_FOUND;
}
export function isConnInvalidConnectionNameError(
value: unknown
): value is ConnInvalidConnectionNameError {
return value instanceof Error && (value as { name?: string }).name === ERROR_NAME_INVALID_NAME;
}
export function isConnInvalidFrameError(value: unknown): value is ConnInvalidFrameError {
return value instanceof Error && (value as { name?: string }).name === ERROR_NAME_INVALID_FRAME;
}

@ -0,0 +1,12 @@
export { createActiveConnections } from './active-connections.svelte.ts';
export { createConnectionChannel } from './channel.ts';
export { createConnection } from './connection.ts';
export { createEngineConnections } from './engine-connections.ts';
export { assertConnectionFrame, createFrame } from './serializer.ts';
export { jsonConnectionSerializer } from './serializers/json.ts';
export { createMockTransport } from './transports/mock.ts';
export { createWebSocketTransport } from './transports/websocket.ts';
export * from './consts.ts';
export * from './errors.ts';
export * from './types.ts';

@ -0,0 +1,87 @@
import {
FRAME_KEY_ACK,
FRAME_KEY_ERROR,
FRAME_KEY_ID,
FRAME_KEY_PAYLOAD,
FRAME_KEY_REPLY_TO,
FRAME_KEY_TOPIC,
FRAME_KEY_TS,
FRAME_KEY_TYPE,
ERROR_MSG_FRAME_ACK_MUST_BE_BOOLEAN,
ERROR_MSG_FRAME_ERROR_MUST_BE_OBJECT,
ERROR_MSG_FRAME_ID_MUST_BE_STRING,
ERROR_MSG_FRAME_MUST_BE_OBJECT,
ERROR_MSG_FRAME_REPLY_TO_MUST_BE_STRING,
ERROR_MSG_FRAME_TOPIC_MUST_BE_STRING,
ERROR_MSG_FRAME_TS_MUST_BE_NUMBER,
ERROR_MSG_FRAME_TYPE_MUST_BE_STRING
} from './consts.ts';
import { ConnInvalidFrameError } from './errors.ts';
import type { ConnectionFrame } from './types.ts';
export function assertConnectionFrame(value: unknown): asserts value is ConnectionFrame {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new ConnInvalidFrameError(ERROR_MSG_FRAME_MUST_BE_OBJECT);
}
const frame = value as Record<string, unknown>;
if (typeof frame[FRAME_KEY_TYPE] !== 'string' || frame[FRAME_KEY_TYPE].length === 0) {
throw new ConnInvalidFrameError(ERROR_MSG_FRAME_TYPE_MUST_BE_STRING);
}
if (
FRAME_KEY_ID in frame &&
frame[FRAME_KEY_ID] !== undefined &&
typeof frame[FRAME_KEY_ID] !== 'string'
) {
throw new ConnInvalidFrameError(ERROR_MSG_FRAME_ID_MUST_BE_STRING);
}
if (
FRAME_KEY_TOPIC in frame &&
frame[FRAME_KEY_TOPIC] !== undefined &&
typeof frame[FRAME_KEY_TOPIC] !== 'string'
) {
throw new ConnInvalidFrameError(ERROR_MSG_FRAME_TOPIC_MUST_BE_STRING);
}
if (
FRAME_KEY_REPLY_TO in frame &&
frame[FRAME_KEY_REPLY_TO] !== undefined &&
typeof frame[FRAME_KEY_REPLY_TO] !== 'string'
) {
throw new ConnInvalidFrameError(ERROR_MSG_FRAME_REPLY_TO_MUST_BE_STRING);
}
if (
FRAME_KEY_ACK in frame &&
frame[FRAME_KEY_ACK] !== undefined &&
typeof frame[FRAME_KEY_ACK] !== 'boolean'
) {
throw new ConnInvalidFrameError(ERROR_MSG_FRAME_ACK_MUST_BE_BOOLEAN);
}
if (
FRAME_KEY_TS in frame &&
frame[FRAME_KEY_TS] !== undefined &&
typeof frame[FRAME_KEY_TS] !== 'number'
) {
throw new ConnInvalidFrameError(ERROR_MSG_FRAME_TS_MUST_BE_NUMBER);
}
if (
FRAME_KEY_ERROR in frame &&
frame[FRAME_KEY_ERROR] !== undefined &&
(frame[FRAME_KEY_ERROR] === null || typeof frame[FRAME_KEY_ERROR] !== 'object')
) {
throw new ConnInvalidFrameError(ERROR_MSG_FRAME_ERROR_MUST_BE_OBJECT);
}
}
export function createFrame(input: ConnectionFrame): ConnectionFrame {
assertConnectionFrame(input);
const frame: Record<string, unknown> = {
[FRAME_KEY_TYPE]: input.type,
[FRAME_KEY_PAYLOAD]: input.payload
};
if (input.id !== undefined) frame[FRAME_KEY_ID] = input.id;
if (input.topic !== undefined) frame[FRAME_KEY_TOPIC] = input.topic;
if (input.ts !== undefined) frame[FRAME_KEY_TS] = input.ts;
if (input.ack !== undefined) frame[FRAME_KEY_ACK] = input.ack;
if (input.replyTo !== undefined) frame[FRAME_KEY_REPLY_TO] = input.replyTo;
if (input.error !== undefined) frame[FRAME_KEY_ERROR] = input.error;
return frame as unknown as ConnectionFrame;
}

@ -0,0 +1,26 @@
import { ERROR_MSG_JSON_DECODE_FAILED } from '../consts.ts';
import { ConnInvalidFrameError } from '../errors.ts';
import { assertConnectionFrame, createFrame } from '../serializer.ts';
import type { ConnectionFrame, ConnectionSerializer } from '../types.ts';
const textDecoder = new TextDecoder();
function rawToText(raw: string | ArrayBuffer): string {
return typeof raw === 'string' ? raw : textDecoder.decode(raw);
}
export const jsonConnectionSerializer: ConnectionSerializer<ConnectionFrame> = {
encode(frame) {
return JSON.stringify(createFrame(frame));
},
decode(raw) {
let parsed: unknown;
try {
parsed = JSON.parse(rawToText(raw));
} catch (cause) {
throw new ConnInvalidFrameError(ERROR_MSG_JSON_DECODE_FAILED, { cause });
}
assertConnectionFrame(parsed);
return createFrame(parsed);
}
};

@ -0,0 +1,345 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
CONNECTION_ACK_REASON_CLOSED,
CONNECTION_ACK_REASON_TIMEOUT,
CONNECTION_BUFFER_POLICY_BUFFER,
CONNECTION_CONNECT_REASON_AUTH_FAILED,
CONNECTION_FRAME_TYPE_JOIN,
CONNECTION_SEND_REASON_CLOSED,
CONNECTION_STATE_CLOSED,
CONNECTION_STATE_FAILED,
CONNECTION_STATE_OPEN,
CONNECTION_STATE_RECONNECTING,
SESSION_EVENT_EXPIRED,
createEngineConnections,
createFrame,
createMockTransport
} from '../index.ts';
import type { ConnectionFrame, MockConnectionTransport } from '../index.ts';
function latestFrame(transport: MockConnectionTransport): ConnectionFrame {
const sent = transport.sentMessages();
return JSON.parse(sent[sent.length - 1] as string) as ConnectionFrame;
}
function emitFrame(transport: MockConnectionTransport, frame: ConnectionFrame): void {
transport.emitMessage(JSON.stringify(createFrame(frame)));
}
afterEach(() => {
vi.useRealTimers();
});
describe('Connection lifecycle', () => {
it('connects and disconnects through the transport', async () => {
const Connections = createEngineConnections();
const transport = createMockTransport();
const Main = Connections.createConnection('main', {
transport,
heartbeat: false,
reconnect: false
});
const result = await Main.connect();
expect(result.ok).toBe(true);
expect(Main.state).toBe(CONNECTION_STATE_OPEN);
expect(Main.connected).toBe(true);
Main.disconnect();
expect(Main.state).toBe(CONNECTION_STATE_CLOSED);
expect(Main.connected).toBe(false);
Connections.dispose();
});
it('does not reconnect after an intentional disconnect', async () => {
vi.useFakeTimers();
const Connections = createEngineConnections();
const transport = createMockTransport();
const Main = Connections.createConnection('main', {
transport,
heartbeat: false,
reconnect: { minDelayMs: 1, maxDelayMs: 1, jitterMs: 0 }
});
await Main.connect();
Main.disconnect();
await vi.advanceTimersByTimeAsync(10);
expect(Main.state).toBe(CONNECTION_STATE_CLOSED);
Connections.dispose();
});
it('reconnects after an unexpected close', async () => {
vi.useFakeTimers();
const Connections = createEngineConnections();
const transport = createMockTransport();
const Main = Connections.createConnection('main', {
transport,
heartbeat: false,
reconnect: { minDelayMs: 1, maxDelayMs: 1, jitterMs: 0 }
});
await Main.connect();
const firstGeneration = Main.generation;
transport.emitClose({ clean: false });
expect(Main.state).toBe(CONNECTION_STATE_RECONNECTING);
await vi.advanceTimersByTimeAsync(1);
expect(Main.state).toBe(CONNECTION_STATE_OPEN);
expect(Main.generation).toBe(firstGeneration + 1);
Connections.dispose();
});
it('closes the transport when connect-time auth fails', async () => {
vi.useFakeTimers();
const Connections = createEngineConnections();
const transport = createMockTransport();
const Main = Connections.createConnection('main', {
transport,
heartbeat: false,
reconnect: false,
auth: {
getAuth: () => ({}),
timeoutMs: 5
}
});
const pending = Main.connect();
await vi.advanceTimersByTimeAsync(5);
const result = await pending;
expect(result).toMatchObject({
ok: false,
reason: CONNECTION_CONNECT_REASON_AUTH_FAILED
});
expect(Main.state).toBe(CONNECTION_STATE_FAILED);
expect(transport.canSend).toBe(false);
Connections.dispose();
});
});
describe('Connection send/request', () => {
it('sends a serialized frame when open', async () => {
const Connections = createEngineConnections();
const transport = createMockTransport();
const Main = Connections.createConnection('main', {
transport,
heartbeat: false,
reconnect: false
});
await Main.connect();
const result = await Main.send('demo.event', { ok: true });
const sent = latestFrame(transport);
expect(result.ok).toBe(true);
expect(sent).toMatchObject({
type: 'demo.event',
payload: { ok: true }
});
Connections.dispose();
});
it('fails fire-and-forget sends while closed unless buffering is enabled', async () => {
const Connections = createEngineConnections();
const Main = Connections.createConnection('main', {
transport: createMockTransport(),
heartbeat: false,
reconnect: false
});
const result = await Main.send('demo.event', undefined);
expect(result).toEqual({ ok: false, reason: CONNECTION_SEND_REASON_CLOSED });
Connections.dispose();
});
it('buffers fire-and-forget frames and flushes on connect', async () => {
const Connections = createEngineConnections();
const transport = createMockTransport();
const Main = Connections.createConnection('main', {
transport,
heartbeat: false,
reconnect: false,
buffer: { policy: CONNECTION_BUFFER_POLICY_BUFFER }
});
const buffered = await Main.send('demo.buffered', { queued: true });
expect(buffered.ok).toBe(true);
expect(transport.sentMessages()).toHaveLength(0);
await Main.connect();
expect(latestFrame(transport)).toMatchObject({
type: 'demo.buffered',
payload: { queued: true }
});
Connections.dispose();
});
it('resolves a request from an incoming replyTo frame', async () => {
const Connections = createEngineConnections();
const transport = createMockTransport();
const Main = Connections.createConnection('main', {
transport,
heartbeat: false,
reconnect: false
});
await Main.connect();
const pending = Main.request<{ ask: boolean }, { answer: boolean }>('demo.request', {
ask: true
});
await Promise.resolve();
const sent = latestFrame(transport);
emitFrame(transport, {
type: 'demo.reply',
payload: { answer: true },
replyTo: sent.id
});
await expect(pending).resolves.toEqual({ ok: true, payload: { answer: true } });
Connections.dispose();
});
it('times out unanswered requests', async () => {
vi.useFakeTimers();
const Connections = createEngineConnections();
const Main = Connections.createConnection('main', {
transport: createMockTransport(),
heartbeat: false,
reconnect: false
});
await Main.connect();
const pending = Main.request('demo.request', undefined, { timeoutMs: 5 });
await vi.advanceTimersByTimeAsync(5);
await expect(pending).resolves.toEqual({ ok: false, reason: CONNECTION_ACK_REASON_TIMEOUT });
Connections.dispose();
});
it('closes pending requests on disconnect', async () => {
const Connections = createEngineConnections();
const Main = Connections.createConnection('main', {
transport: createMockTransport(),
heartbeat: false,
reconnect: false
});
await Main.connect();
const pending = Main.request('demo.request', undefined);
Main.disconnect();
await expect(pending).resolves.toEqual({ ok: false, reason: CONNECTION_ACK_REASON_CLOSED });
Connections.dispose();
});
});
describe('Connection channels', () => {
it('caches channel instances by name', () => {
const Connections = createEngineConnections();
const Main = Connections.createConnection('main', {
transport: createMockTransport(),
heartbeat: false,
reconnect: false
});
expect(Main.channel('orders')).toBe(Main.channel('orders'));
Connections.dispose();
});
it('routes topic frames to the matching channel', async () => {
const Connections = createEngineConnections();
const transport = createMockTransport();
const Main = Connections.createConnection('main', {
transport,
heartbeat: false,
reconnect: false
});
const Orders = Main.channel<{ 'order.updated': { id: string } }>('orders');
const calls: string[] = [];
Orders.on('order.updated', (payload) => {
calls.push(payload.id);
});
await Main.connect();
emitFrame(transport, {
topic: 'orders',
type: 'order.updated',
payload: { id: 'o1' }
});
expect(calls).toEqual(['o1']);
Connections.dispose();
});
it('join sends the standard channel join frame', async () => {
const Connections = createEngineConnections();
const transport = createMockTransport();
const Main = Connections.createConnection('main', {
transport,
heartbeat: false,
reconnect: false
});
const Orders = Main.channel('orders');
await Main.connect();
const result = await Orders.join({ tenant: 'acme' });
expect(result.ok).toBe(true);
expect(latestFrame(transport)).toMatchObject({
topic: 'orders',
type: CONNECTION_FRAME_TYPE_JOIN,
payload: { tenant: 'acme' }
});
Connections.dispose();
});
});
describe('Connection session bridge', () => {
it('disconnects opted-in connections when session expires', async () => {
let listener: ((change: { readonly event: string }) => void) | undefined;
const Connections = createEngineConnections({
session: {
onChange(fn) {
listener = fn;
return () => {
listener = undefined;
};
}
}
});
const Main = Connections.createConnection('main', {
transport: createMockTransport(),
heartbeat: false,
reconnect: false,
session: { enabled: true }
});
await Main.connect();
listener?.({ event: SESSION_EVENT_EXPIRED });
expect(Main.state).toBe(CONNECTION_STATE_CLOSED);
Connections.dispose();
});
});

@ -0,0 +1,90 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
CONNECTION_STATE_CLOSED,
CONNECTION_STATE_OPEN,
createEngineConnections,
createMockTransport,
isConnConnectionAlreadyExistsError,
isConnConnectionNotFoundError
} from '../index.ts';
afterEach(() => {
vi.useRealTimers();
});
describe('createEngineConnections', () => {
it('creates, retrieves and lists named connections', () => {
const Connections = createEngineConnections();
const transport = createMockTransport();
const Main = Connections.createConnection('main', {
transport,
heartbeat: false,
reconnect: false
});
expect(Connections.has('main')).toBe(true);
expect(Connections.names()).toEqual(['main']);
expect(Connections.connection('main')).toBe(Main);
Connections.dispose();
});
it('rejects duplicate names with a typed error', () => {
const Connections = createEngineConnections();
Connections.createConnection('main', {
transport: createMockTransport(),
heartbeat: false,
reconnect: false
});
try {
Connections.createConnection('main', {
transport: createMockTransport(),
heartbeat: false,
reconnect: false
});
} catch (error) {
expect(isConnConnectionAlreadyExistsError(error)).toBe(true);
}
Connections.dispose();
});
it('throws a typed error for missing names', () => {
const Connections = createEngineConnections();
try {
Connections.connection('missing');
} catch (error) {
expect(isConnConnectionNotFoundError(error)).toBe(true);
}
Connections.dispose();
});
it('opens and closes every registered connection', async () => {
const Connections = createEngineConnections();
Connections.createConnection('main', {
transport: createMockTransport(),
heartbeat: false,
reconnect: false
});
Connections.createConnection('chat', {
transport: createMockTransport(),
heartbeat: false,
reconnect: false
});
const results = await Connections.openAll();
expect(results.every((result) => result.ok)).toBe(true);
expect(Connections.connection('main').state).toBe(CONNECTION_STATE_OPEN);
expect(Connections.connection('chat').state).toBe(CONNECTION_STATE_OPEN);
Connections.closeAll();
expect(Connections.connection('main').state).toBe(CONNECTION_STATE_CLOSED);
expect(Connections.connection('chat').state).toBe(CONNECTION_STATE_CLOSED);
Connections.dispose();
});
});

@ -0,0 +1,25 @@
export interface TransportEmitter<TListener extends (...args: never[]) => void> {
on(listener: TListener): () => void;
emit(...args: Parameters<TListener>): void;
clear(): void;
}
export function createTransportEmitter<
TListener extends (...args: never[]) => void
>(): TransportEmitter<TListener> {
const listeners = new Set<TListener>();
return {
on(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
emit(...args) {
for (const listener of [...listeners]) listener(...args);
},
clear() {
listeners.clear();
}
};
}

@ -0,0 +1,99 @@
import {
CONNECTION_TRANSPORT_STATE_CLOSED,
CONNECTION_TRANSPORT_STATE_CLOSING,
CONNECTION_TRANSPORT_STATE_IDLE,
CONNECTION_TRANSPORT_STATE_OPEN,
CONNECTION_TRANSPORT_STATE_OPENING,
MOCK_TRANSPORT_DEFAULT_CLOSE_CLEAN,
MOCK_TRANSPORT_DEFAULT_CLOSE_CODE,
TRANSPORT_KIND_MOCK
} from '../consts.ts';
import { createTransportEmitter } from '../transport.ts';
import type {
ConnectionCloseEvent,
ConnectionTransportState,
MockConnectionTransport,
MockTransportOptions
} from '../types.ts';
export function createMockTransport(options: MockTransportOptions = {}): MockConnectionTransport {
const autoOpen = options.autoOpen ?? true;
const openEmitter = createTransportEmitter<() => void>();
const messageEmitter = createTransportEmitter<(message: string | ArrayBuffer) => void>();
const closeEmitter = createTransportEmitter<(event: ConnectionCloseEvent) => void>();
const errorEmitter = createTransportEmitter<(error: unknown) => void>();
const sent: (string | ArrayBuffer)[] = [];
let state: ConnectionTransportState = CONNECTION_TRANSPORT_STATE_IDLE;
const openResolvers = new Set<() => void>();
function resolveOpenWaiters(): void {
for (const resolve of openResolvers) resolve();
openResolvers.clear();
}
function emitOpen(): void {
state = CONNECTION_TRANSPORT_STATE_OPEN;
resolveOpenWaiters();
openEmitter.emit();
}
function emitClose(event: Partial<ConnectionCloseEvent> = {}): void {
state = CONNECTION_TRANSPORT_STATE_CLOSED;
resolveOpenWaiters();
closeEmitter.emit({
code: event.code ?? MOCK_TRANSPORT_DEFAULT_CLOSE_CODE,
reason: event.reason,
clean: event.clean ?? MOCK_TRANSPORT_DEFAULT_CLOSE_CLEAN
});
}
return {
kind: TRANSPORT_KIND_MOCK,
get state() {
return state;
},
get bufferedAmount() {
return sent.length;
},
get canSend() {
return state === CONNECTION_TRANSPORT_STATE_OPEN;
},
open() {
if (state === CONNECTION_TRANSPORT_STATE_OPEN) return Promise.resolve();
state = CONNECTION_TRANSPORT_STATE_OPENING;
if (autoOpen) {
emitOpen();
return Promise.resolve();
}
return new Promise<void>((resolve) => {
openResolvers.add(resolve);
});
},
send(data) {
sent.push(data);
},
close(code, reason) {
if (state === CONNECTION_TRANSPORT_STATE_CLOSED) return;
state = CONNECTION_TRANSPORT_STATE_CLOSING;
emitClose({ code, reason });
},
onOpen: (listener) => openEmitter.on(listener),
onMessage: (listener) => messageEmitter.on(listener),
onClose: (listener) => closeEmitter.on(listener),
onError: (listener) => errorEmitter.on(listener),
emitOpen,
emitMessage(message) {
messageEmitter.emit(message);
},
emitClose,
emitError(error) {
errorEmitter.emit(error);
},
sentMessages() {
return [...sent];
},
clearSent() {
sent.length = 0;
}
};
}

@ -0,0 +1,187 @@
import {
BROWSER_EVENT_CLOSE,
BROWSER_EVENT_ERROR,
BROWSER_EVENT_MESSAGE,
BROWSER_EVENT_OPEN,
CONNECTION_TRANSPORT_STATE_CLOSED,
CONNECTION_TRANSPORT_STATE_CLOSING,
CONNECTION_TRANSPORT_STATE_IDLE,
CONNECTION_TRANSPORT_STATE_OPEN,
CONNECTION_TRANSPORT_STATE_OPENING,
ERROR_MSG_WEBSOCKET_UNAVAILABLE,
TRANSPORT_KIND_WEBSOCKET,
WEBSOCKET_BINARY_TYPE_ARRAYBUFFER,
WEBSOCKET_CLOSE_CODE_ABNORMAL,
WEBSOCKET_CLOSE_CODE_NORMAL,
WEBSOCKET_READY_STATE_CLOSED,
WEBSOCKET_READY_STATE_CLOSING,
WEBSOCKET_READY_STATE_OPEN
} from '../consts.ts';
import { createTransportEmitter } from '../transport.ts';
import type {
ConnectionCloseEvent,
ConnectionTransport,
ConnectionTransportState,
WebSocketConstructorLike,
WebSocketLike,
WebSocketTransportOptions
} from '../types.ts';
type OpenWaiter = {
resolve(): void;
reject(error: unknown): void;
};
export function createWebSocketTransport(options: WebSocketTransportOptions): ConnectionTransport {
const openEmitter = createTransportEmitter<() => void>();
const messageEmitter = createTransportEmitter<(message: string | ArrayBuffer) => void>();
const closeEmitter = createTransportEmitter<(event: ConnectionCloseEvent) => void>();
const errorEmitter = createTransportEmitter<(error: unknown) => void>();
let socket: WebSocketLike | null = null;
let state: ConnectionTransportState = CONNECTION_TRANSPORT_STATE_IDLE;
let openWaiter: OpenWaiter | null = null;
let detachSocketListeners: (() => void) | null = null;
function resolveUrl(): string | URL {
return typeof options.url === 'function' ? options.url() : options.url;
}
function resolveProtocols(): string | string[] | undefined {
const protocols = options.protocols;
return typeof protocols === 'function' ? protocols() : protocols;
}
function resolveConstructor(): WebSocketConstructorLike {
const ctor =
options.WebSocket ??
(globalThis as { readonly WebSocket?: WebSocketConstructorLike }).WebSocket;
if (ctor === undefined) throw new Error(ERROR_MSG_WEBSOCKET_UNAVAILABLE);
return ctor;
}
function settleOpen(error?: unknown): void {
const waiter = openWaiter;
openWaiter = null;
if (waiter === null) return;
if (error === undefined) waiter.resolve();
else waiter.reject(error);
}
function readNativeState(): ConnectionTransportState {
const current = socket;
if (current === null) return state;
if (current.readyState === WEBSOCKET_READY_STATE_OPEN) return CONNECTION_TRANSPORT_STATE_OPEN;
if (current.readyState === WEBSOCKET_READY_STATE_CLOSING) {
return CONNECTION_TRANSPORT_STATE_CLOSING;
}
if (current.readyState === WEBSOCKET_READY_STATE_CLOSED) {
return CONNECTION_TRANSPORT_STATE_CLOSED;
}
return CONNECTION_TRANSPORT_STATE_OPENING;
}
function removeSocketListeners(): void {
detachSocketListeners?.();
detachSocketListeners = null;
}
function attachSocketListeners(next: WebSocketLike): void {
const handleOpen = (): void => {
state = CONNECTION_TRANSPORT_STATE_OPEN;
settleOpen();
openEmitter.emit();
};
const handleMessage = (event: unknown): void => {
const data = (event as { readonly data?: unknown }).data;
if (typeof data === 'string' || data instanceof ArrayBuffer) {
messageEmitter.emit(data);
return;
}
if (data !== undefined) messageEmitter.emit(String(data));
};
const handleClose = (event: unknown): void => {
state = CONNECTION_TRANSPORT_STATE_CLOSED;
const closeEvent = event as {
readonly code?: unknown;
readonly reason?: unknown;
readonly wasClean?: unknown;
};
const mapped: ConnectionCloseEvent = {
code: typeof closeEvent.code === 'number' ? closeEvent.code : WEBSOCKET_CLOSE_CODE_ABNORMAL,
reason: typeof closeEvent.reason === 'string' ? closeEvent.reason : undefined,
clean: closeEvent.wasClean === true
};
settleOpen(mapped);
closeEmitter.emit(mapped);
};
const handleError = (event: unknown): void => {
errorEmitter.emit(event);
settleOpen(event);
};
next.addEventListener(BROWSER_EVENT_OPEN, handleOpen);
next.addEventListener(BROWSER_EVENT_MESSAGE, handleMessage);
next.addEventListener(BROWSER_EVENT_CLOSE, handleClose);
next.addEventListener(BROWSER_EVENT_ERROR, handleError);
detachSocketListeners = () => {
next.removeEventListener(BROWSER_EVENT_OPEN, handleOpen);
next.removeEventListener(BROWSER_EVENT_MESSAGE, handleMessage);
next.removeEventListener(BROWSER_EVENT_CLOSE, handleClose);
next.removeEventListener(BROWSER_EVENT_ERROR, handleError);
};
}
return {
kind: TRANSPORT_KIND_WEBSOCKET,
get state() {
return readNativeState();
},
get bufferedAmount() {
return socket?.bufferedAmount ?? 0;
},
get canSend() {
return socket?.readyState === WEBSOCKET_READY_STATE_OPEN;
},
open() {
if (socket?.readyState === WEBSOCKET_READY_STATE_OPEN) return Promise.resolve();
if (openWaiter !== null) {
return new Promise<void>((resolve, reject) => {
const previous = openWaiter;
openWaiter = {
resolve() {
previous?.resolve();
resolve();
},
reject(error) {
previous?.reject(error);
reject(error);
}
};
});
}
removeSocketListeners();
state = CONNECTION_TRANSPORT_STATE_OPENING;
const Ctor = resolveConstructor();
const protocols = resolveProtocols();
const next =
protocols === undefined ? new Ctor(resolveUrl()) : new Ctor(resolveUrl(), protocols);
next.binaryType = WEBSOCKET_BINARY_TYPE_ARRAYBUFFER;
socket = next;
attachSocketListeners(next);
return new Promise<void>((resolve, reject) => {
openWaiter = { resolve, reject };
});
},
send(data) {
socket?.send(data);
},
close(code = WEBSOCKET_CLOSE_CODE_NORMAL, reason) {
state = CONNECTION_TRANSPORT_STATE_CLOSING;
socket?.close(code, reason);
},
onOpen: (listener) => openEmitter.on(listener),
onMessage: (listener) => messageEmitter.on(listener),
onClose: (listener) => closeEmitter.on(listener),
onError: (listener) => errorEmitter.on(listener)
};
}

@ -0,0 +1,430 @@
import type { TimerScheduler } from '$timr';
import type {
CONNECTION_ACK_REASON_CLOSED,
CONNECTION_ACK_REASON_INVALID_REPLY,
CONNECTION_ACK_REASON_REJECTED,
CONNECTION_ACK_REASON_TIMEOUT,
CONNECTION_ACK_REASON_TRANSPORT_ERROR,
CONNECTION_AUTH_REASON_CLOSED,
CONNECTION_AUTH_REASON_NO_PROVIDER,
CONNECTION_AUTH_REASON_REJECTED,
CONNECTION_AUTH_REASON_TIMEOUT,
CONNECTION_AUTH_REASON_TRANSPORT_ERROR,
CONNECTION_BUFFER_POLICY_BUFFER,
CONNECTION_BUFFER_POLICY_DROP,
CONNECTION_BUFFER_POLICY_FAIL,
CONNECTION_CHANNEL_JOIN_REASON_CLOSED,
CONNECTION_CHANNEL_JOIN_REASON_REJECTED,
CONNECTION_CHANNEL_JOIN_REASON_TRANSPORT_ERROR,
CONNECTION_CHANNEL_STATE_FAILED,
CONNECTION_CHANNEL_STATE_IDLE,
CONNECTION_CHANNEL_STATE_JOINED,
CONNECTION_CHANNEL_STATE_JOINING,
CONNECTION_CHANNEL_STATE_LEAVING,
CONNECTION_CHANNEL_STATE_LEFT,
CONNECTION_CONNECT_REASON_AUTH_FAILED,
CONNECTION_CONNECT_REASON_DISPOSED,
CONNECTION_CONNECT_REASON_RECONNECT_EXHAUSTED,
CONNECTION_CONNECT_REASON_TRANSPORT_ERROR,
CONNECTION_SEND_REASON_BUFFER_FULL,
CONNECTION_SEND_REASON_CLOSED,
CONNECTION_SEND_REASON_INVALID_FRAME,
CONNECTION_SEND_REASON_SEND_NOT_SUPPORTED,
CONNECTION_SEND_REASON_SERIALIZE_FAILED,
CONNECTION_SEND_REASON_TRANSPORT_ERROR,
CONNECTION_STATES,
CONNECTION_TRANSPORT_STATES
} from './consts.ts';
export type ConnectionState = (typeof CONNECTION_STATES)[number];
export type ConnectionTransportState = (typeof CONNECTION_TRANSPORT_STATES)[number];
export type ConnectionChannelState =
| typeof CONNECTION_CHANNEL_STATE_IDLE
| typeof CONNECTION_CHANNEL_STATE_JOINING
| typeof CONNECTION_CHANNEL_STATE_JOINED
| typeof CONNECTION_CHANNEL_STATE_LEAVING
| typeof CONNECTION_CHANNEL_STATE_LEFT
| typeof CONNECTION_CHANNEL_STATE_FAILED;
export type ConnectionEventMap = Record<string, unknown>;
export type ConnectionChannelMap = Record<string, ConnectionEventMap>;
export type ConnectionMap = Record<string, ConnectionChannelMap>;
export interface ConnectionLogger {
debug?(category: string, message: string, input?: unknown): void;
info?(category: string, message: string, input?: unknown): void;
warn?(category: string, message: string, input?: unknown): void;
error?(category: string, message: string, input?: unknown): void;
}
export interface ConnectionCloseEvent {
readonly code: number;
readonly reason?: string;
readonly clean: boolean;
}
export interface ConnectionTransport {
readonly kind: string;
readonly state: ConnectionTransportState;
readonly bufferedAmount: number;
readonly canSend: boolean;
open(): Promise<void>;
send(data: string | ArrayBuffer): Promise<void> | void;
close(code?: number, reason?: string): void;
onOpen(listener: () => void): () => void;
onMessage(listener: (message: string | ArrayBuffer) => void): () => void;
onClose(listener: (event: ConnectionCloseEvent) => void): () => void;
onError(listener: (error: unknown) => void): () => void;
}
export type WebSocketUrlSource = string | URL | (() => string | URL);
export type WebSocketProtocolSource = string | string[] | (() => string | string[] | undefined);
export type WebSocketEventListener = (event: unknown) => void;
export interface WebSocketLike {
readonly readyState: number;
readonly bufferedAmount: number;
binaryType: string;
send(data: string | ArrayBuffer): void;
close(code?: number, reason?: string): void;
addEventListener(type: string, listener: WebSocketEventListener): void;
removeEventListener(type: string, listener: WebSocketEventListener): void;
}
export interface WebSocketConstructorLike {
new (url: string | URL, protocols?: string | string[]): WebSocketLike;
}
export interface WebSocketTransportOptions {
readonly url: WebSocketUrlSource;
readonly protocols?: WebSocketProtocolSource;
readonly WebSocket?: WebSocketConstructorLike;
}
export interface MockTransportOptions {
readonly autoOpen?: boolean;
}
export interface MockConnectionTransport extends ConnectionTransport {
emitOpen(): void;
emitMessage(message: string | ArrayBuffer): void;
emitClose(event?: Partial<ConnectionCloseEvent>): void;
emitError(error: unknown): void;
sentMessages(): readonly (string | ArrayBuffer)[];
clearSent(): void;
}
export interface ConnectionSerializer<TFrame = ConnectionFrame> {
encode(frame: TFrame): string | ArrayBuffer;
decode(raw: string | ArrayBuffer): TFrame;
}
export interface ConnectionFrameError {
readonly message: string;
readonly code?: string;
readonly details?: unknown;
}
export interface ConnectionFrame<TType extends string = string, TPayload = unknown> {
readonly id?: string;
readonly topic?: string;
readonly type: TType;
readonly payload: TPayload;
readonly ts?: number;
readonly ack?: boolean;
readonly replyTo?: string;
readonly error?: ConnectionFrameError;
}
export interface ConnectionMessageMeta {
readonly connection: string;
readonly topic?: string;
readonly receivedAt: number;
readonly generation: number;
}
export interface ConnectionStateChange {
readonly connection: string;
readonly from: ConnectionState;
readonly to: ConnectionState;
readonly generation: number;
readonly error?: unknown;
readonly at: number;
}
export interface ConnectionReconnectOptions {
readonly enabled?: boolean;
readonly minDelayMs?: number;
readonly maxDelayMs?: number;
readonly factor?: number;
readonly jitterMs?: number;
readonly maxAttempts?: number;
readonly reconnectOnVisible?: boolean;
readonly reconnectOnOnline?: boolean;
}
export interface ConnectionHeartbeatOptions {
readonly enabled?: boolean;
readonly intervalMs?: number;
readonly timeoutMs?: number;
readonly pingType?: string;
readonly pongType?: string;
}
export interface ConnectionBufferOptions {
readonly policy?:
| typeof CONNECTION_BUFFER_POLICY_BUFFER
| typeof CONNECTION_BUFFER_POLICY_DROP
| typeof CONNECTION_BUFFER_POLICY_FAIL;
readonly maxMessages?: number;
readonly maxBytes?: number;
}
export type ConnectionAuthPayload = Record<string, unknown>;
export interface ConnectionAuthOptions {
readonly getAuth?: () => ConnectionAuthPayload | null | Promise<ConnectionAuthPayload | null>;
readonly authType?: string;
readonly timeoutMs?: number;
}
export interface ConnectionSessionOptions {
readonly enabled?: boolean;
readonly reauthOnRefresh?: boolean;
readonly disconnectOnExpire?: boolean;
}
export interface ConnectionChannelOptions {
readonly autoJoin?: boolean;
readonly rejoinOnReconnect?: boolean;
readonly params?: () => unknown | Promise<unknown>;
}
export interface ConnectionOptions<TChannels extends ConnectionChannelMap = ConnectionChannelMap> {
readonly transport: ConnectionTransport | (() => ConnectionTransport);
readonly serializer?: ConnectionSerializer;
readonly reconnect?: ConnectionReconnectOptions | false;
readonly heartbeat?: ConnectionHeartbeatOptions | false;
readonly buffer?: ConnectionBufferOptions;
readonly auth?:
| ConnectionAuthOptions
| (() => ConnectionAuthPayload | null | Promise<ConnectionAuthPayload | null>);
readonly session?: false | ConnectionSessionOptions;
readonly channels?: {
readonly [K in keyof TChannels & string]?: ConnectionChannelOptions;
};
readonly loggerScope?: string;
}
export interface ConnectionSendOptions {
readonly id?: string;
readonly topic?: string;
readonly ack?: boolean;
readonly ts?: number;
readonly buffer?: boolean;
}
export interface ConnectionRequestOptions extends ConnectionSendOptions {
readonly timeoutMs?: number;
}
export type ConnectionSendResult =
| { readonly ok: true; readonly id?: string }
| {
readonly ok: false;
readonly reason:
| typeof CONNECTION_SEND_REASON_CLOSED
| typeof CONNECTION_SEND_REASON_SEND_NOT_SUPPORTED
| typeof CONNECTION_SEND_REASON_BUFFER_FULL
| typeof CONNECTION_SEND_REASON_INVALID_FRAME
| typeof CONNECTION_SEND_REASON_SERIALIZE_FAILED
| typeof CONNECTION_SEND_REASON_TRANSPORT_ERROR;
readonly error?: unknown;
};
export type ConnectionAckResult<T = unknown> =
| { readonly ok: true; readonly payload: T }
| {
readonly ok: false;
readonly reason:
| typeof CONNECTION_ACK_REASON_TIMEOUT
| typeof CONNECTION_ACK_REASON_CLOSED
| typeof CONNECTION_ACK_REASON_REJECTED
| typeof CONNECTION_ACK_REASON_TRANSPORT_ERROR
| typeof CONNECTION_ACK_REASON_INVALID_REPLY;
readonly error?: unknown;
};
export type ConnectionAuthResult =
| { readonly ok: true }
| {
readonly ok: false;
readonly reason:
| typeof CONNECTION_AUTH_REASON_NO_PROVIDER
| typeof CONNECTION_AUTH_REASON_CLOSED
| typeof CONNECTION_AUTH_REASON_REJECTED
| typeof CONNECTION_AUTH_REASON_TIMEOUT
| typeof CONNECTION_AUTH_REASON_TRANSPORT_ERROR;
readonly error?: unknown;
};
export type ConnectionConnectResult =
| { readonly ok: true; readonly state: ConnectionState; readonly reused?: boolean }
| {
readonly ok: false;
readonly state: ConnectionState;
readonly reason:
| typeof CONNECTION_CONNECT_REASON_TRANSPORT_ERROR
| typeof CONNECTION_CONNECT_REASON_AUTH_FAILED
| typeof CONNECTION_CONNECT_REASON_DISPOSED
| typeof CONNECTION_CONNECT_REASON_RECONNECT_EXHAUSTED;
readonly error?: unknown;
};
export type ConnectionChannelJoinResult =
| { readonly ok: true }
| {
readonly ok: false;
readonly reason:
| typeof CONNECTION_CHANNEL_JOIN_REASON_CLOSED
| typeof CONNECTION_CHANNEL_JOIN_REASON_REJECTED
| typeof CONNECTION_CHANNEL_JOIN_REASON_TRANSPORT_ERROR;
readonly error?: unknown;
};
export interface ConnectionChannel<TEvents extends ConnectionEventMap = ConnectionEventMap> {
readonly name: string;
readonly state: ConnectionChannelState;
join(params?: unknown): Promise<ConnectionChannelJoinResult>;
leave(): Promise<void>;
send<K extends keyof TEvents & string>(
type: K,
payload: TEvents[K],
options?: ConnectionSendOptions
): Promise<ConnectionSendResult>;
request<K extends keyof TEvents & string, TResult = unknown>(
type: K,
payload: TEvents[K],
options?: ConnectionRequestOptions
): Promise<ConnectionAckResult<TResult>>;
on<K extends keyof TEvents & string>(
type: K,
listener: (payload: TEvents[K], meta: ConnectionMessageMeta) => void
): () => void;
onAny(listener: (frame: ConnectionFrame, meta: ConnectionMessageMeta) => void): () => void;
dispose(): void;
}
export interface Connection<TChannels extends ConnectionChannelMap = ConnectionChannelMap> {
readonly name: string;
readonly state: ConnectionState;
readonly connected: boolean;
readonly generation: number;
readonly error: unknown | null;
readonly openedAt: number | null;
readonly closedAt: number | null;
readonly lastMessageAt: number | null;
readonly reconnectAttempt: number;
connect(): Promise<ConnectionConnectResult>;
disconnect(reason?: string): void;
reconnect(reason?: string): Promise<ConnectionConnectResult>;
reauthenticate(): Promise<ConnectionAuthResult>;
send<TPayload>(
type: string,
payload: TPayload,
options?: ConnectionSendOptions
): Promise<ConnectionSendResult>;
request<TPayload, TResult = unknown>(
type: string,
payload: TPayload,
options?: ConnectionRequestOptions
): Promise<ConnectionAckResult<TResult>>;
channel<K extends keyof TChannels & string>(
name: K,
options?: ConnectionChannelOptions
): ConnectionChannel<TChannels[K]>;
channel<TEvents extends ConnectionEventMap = ConnectionEventMap>(
name: string,
options?: ConnectionChannelOptions
): ConnectionChannel<TEvents>;
channels(): readonly ConnectionChannel[];
hasChannel(name: string): boolean;
leaveChannel(name: string): Promise<void>;
onState(listener: (change: ConnectionStateChange) => void): () => void;
onAny(listener: (frame: ConnectionFrame, meta: ConnectionMessageMeta) => void): () => void;
dispose(): void;
}
export interface EngineConnectionsOptions {
readonly logger?: ConnectionLogger;
readonly timers?: TimerScheduler;
readonly defaults?: Partial<ConnectionOptions>;
readonly session?: ConnectionSessionSource;
}
export type ActiveConnectionsOptions = EngineConnectionsOptions;
export interface ConnectionSessionChange {
readonly event: string;
}
export interface ConnectionSessionSource {
onChange(listener: (change: ConnectionSessionChange) => void): () => void;
}
export interface EngineConnections<TConnections extends ConnectionMap = ConnectionMap> {
createConnection<K extends keyof TConnections & string>(
name: K,
options: ConnectionOptions<TConnections[K]>
): Connection<TConnections[K]>;
createConnection<TChannels extends ConnectionChannelMap = ConnectionChannelMap>(
name: string,
options: ConnectionOptions<TChannels>
): Connection<TChannels>;
connection<K extends keyof TConnections & string>(name: K): Connection<TConnections[K]>;
connection<TChannels extends ConnectionChannelMap = ConnectionChannelMap>(
name: string
): Connection<TChannels>;
has(name: string): boolean;
names(): readonly string[];
openConnection(name: string): Promise<ConnectionConnectResult>;
closeConnection(name: string, reason?: string): void;
reconnectConnection(name: string, reason?: string): Promise<ConnectionConnectResult>;
openAll(): Promise<readonly ConnectionConnectResult[]>;
closeAll(reason?: string): void;
reconnectAll(reason?: string): Promise<readonly ConnectionConnectResult[]>;
close(name: string, reason?: string): void;
dispose(): void;
}
export interface ActiveConnections<TConnections extends ConnectionMap = ConnectionMap> extends Omit<
EngineConnections<TConnections>,
'createConnection' | 'connection'
> {
readonly size: number;
readonly activeNames: readonly string[];
readonly states: Readonly<Record<string, ConnectionState>>;
readonly connectedNames: readonly string[];
readonly connectingNames: readonly string[];
readonly reconnectingNames: readonly string[];
readonly failedNames: readonly string[];
readonly closedNames: readonly string[];
readonly allConnected: boolean;
readonly anyConnected: boolean;
readonly anyConnecting: boolean;
readonly anyReconnecting: boolean;
readonly anyFailed: boolean;
createConnection<K extends keyof TConnections & string>(
name: K,
options: ConnectionOptions<TConnections[K]>
): Connection<TConnections[K]>;
createConnection<TChannels extends ConnectionChannelMap = ConnectionChannelMap>(
name: string,
options: ConnectionOptions<TChannels>
): Connection<TChannels>;
connection<K extends keyof TConnections & string>(name: K): Connection<TConnections[K]>;
connection<TChannels extends ConnectionChannelMap = ConnectionChannelMap>(
name: string
): Connection<TChannels>;
}

@ -1,129 +1,8 @@
import { NULL_BODY_STATUSES } from './consts.ts';
import type { HttpBodyInit, HttpHeadersHook, HttpHeadersInit } from './types.ts';
const JSON_CONTENT_TYPE_PATTERN = /\/(?:.*[.+-])?json(?:;|$)/i;
/**
* Detect whether the value is "plain enough" for the engine to JSON-stringify
* it automatically. Rejects native `BodyInit` types (FormData, Blob, etc.)
* because they should be passed through to `fetch` verbatim.
*
* Adapted from `ofetch`'s `isJSONSerializable` heuristic.
*/
export function isJSONSerializable(value: unknown): value is Record<string, unknown> | unknown[] {
if (value === null || value === undefined) return false;
if (typeof value !== 'object') return false;
if (Array.isArray(value)) return true;
const obj = value as { buffer?: unknown; constructor?: { name?: string }; toJSON?: unknown };
// Typed arrays (Uint8Array, etc.) — their `.buffer` is the ArrayBuffer.
if (obj.buffer !== undefined && obj.buffer instanceof ArrayBuffer) return false;
// Native `BodyInit` instances we want to pass through.
if (
value instanceof FormData ||
value instanceof URLSearchParams ||
value instanceof Blob ||
value instanceof ArrayBuffer ||
(typeof ReadableStream !== 'undefined' && value instanceof ReadableStream)
) {
return false;
}
// Plain object literals or objects with a `toJSON` method.
const ctor = obj.constructor?.name;
if (ctor === 'Object' || ctor === undefined) return true;
if (typeof obj.toJSON === 'function') return true;
return false;
}
/**
* Serialize a body for `fetch`. Returns the canonical body and (when the
* engine should auto-set it) the inferred `Content-Type`. Returns
* `{ body: undefined }` for `null`/`undefined` so callers can spread safely.
*/
export function serializeBody(body: HttpBodyInit): { body: BodyInit | undefined; contentType?: string } {
if (body === null || body === undefined) return { body: undefined };
if (isJSONSerializable(body)) {
return { body: JSON.stringify(body), contentType: 'application/json' };
}
// Native BodyInit — pass through. We do not set Content-Type for
// FormData / URLSearchParams so the browser regenerates the multipart
// boundary or sets the form-encoded type itself.
return { body: body as BodyInit };
}
/**
* Read a Response body, returning the most informative shape:
* - `undefined` when the status carries no body or the method is HEAD
* - parsed JSON when the response declares a JSON-shaped Content-Type
* - text otherwise
*
* Errors during body read resolve to `undefined` — callers that need raw
* access reach for `response.body` directly.
*/
export async function parseBody(response: Response, method: string): Promise<unknown> {
if (NULL_BODY_STATUSES.has(response.status)) return undefined;
if (method.toUpperCase() === 'HEAD') return undefined;
const ct = response.headers.get('content-type') ?? '';
if (JSON_CONTENT_TYPE_PATTERN.test(ct)) {
try {
return await response.json();
} catch {
return undefined;
}
}
try {
const text = await response.text();
return text === '' ? undefined : text;
} catch {
return undefined;
}
}
/**
* Merge engine-default headers with per-call headers. Per-call wins on
* conflict. Hooks resolve eagerly (so `await` is honored before the request
* leaves the engine). Returns a fresh `Headers` instance so callers cannot
* mutate the engine's default reference.
*/
export async function mergeHeaders(
defaults: HttpHeadersInit | HttpHeadersHook | undefined,
override: HttpHeadersInit | HttpHeadersHook | undefined
): Promise<Headers> {
const result = new Headers();
if (defaults !== undefined) await applyHeaders(result, defaults);
if (override !== undefined) await applyHeaders(result, override);
return result;
}
/**
* Apply a single header source onto an existing `Headers` instance. Exported
* so the engine can compose multiple sources from `with()` chains without
* losing the parent layer.
*/
export async function applyHeaders(target: Headers, source: HttpHeadersInit | HttpHeadersHook): Promise<void> {
const resolved = typeof source === 'function' ? await source() : source;
if (resolved instanceof Headers) {
resolved.forEach((value, key) => target.set(key, value));
return;
}
if (Array.isArray(resolved)) {
for (const [key, value] of resolved) target.set(key, value);
return;
}
for (const [key, value] of Object.entries(resolved)) {
target.set(key, value);
}
}
export {
applyHeaders,
isJSONSerializable,
isJsonContentType,
mergeHeaders,
parseBody,
serializeBody
} from '$libs/http';

@ -1,30 +1,31 @@
import type { HttpMethod, RetryConfig } from './types.ts';
import {
HTTP_DEFAULT_RETRY_STATUSES,
HTTP_HEADER_CONTENT_TYPE,
HTTP_IDEMPOTENT_METHODS,
HTTP_METHOD_DELETE,
HTTP_METHOD_GET,
HTTP_METHOD_HEAD,
HTTP_METHOD_OPTIONS,
HTTP_METHOD_PATCH,
HTTP_METHOD_POST,
HTTP_METHOD_PUT,
HTTP_NULL_BODY_STATUSES,
HTTP_RETRY_AFTER_HEADERS
} from '$libs/http';
/** Logger category emitted by the engine. */
export const LOGGER_CATEGORY = 'http';
export const ERROR_PREFIX = '[http] ';
/** HTTP statuses with no body — `parseBody` returns `undefined` for these. */
export const NULL_BODY_STATUSES: ReadonlySet<number> = new Set([101, 204, 205, 304]);
export const NULL_BODY_STATUSES = HTTP_NULL_BODY_STATUSES;
/** Methods retried by default (idempotent + safe). */
export const DEFAULT_RETRY_METHODS: ReadonlyArray<HttpMethod> = [
'GET',
'HEAD',
'PUT',
'DELETE',
'OPTIONS'
];
export const DEFAULT_RETRY_METHODS: ReadonlyArray<HttpMethod> = HTTP_IDEMPOTENT_METHODS;
/** Status codes that trigger a retry by default. */
export const DEFAULT_RETRY_STATUSES: ReadonlyArray<number> = [
408, // Request Timeout
425, // Too Early
429, // Too Many Requests
500, // Internal Server Error
502, // Bad Gateway
503, // Service Unavailable
504 // Gateway Timeout
];
export const DEFAULT_RETRY_STATUSES: ReadonlyArray<number> = HTTP_DEFAULT_RETRY_STATUSES;
/**
* Default retry policy. Idempotent-by-default — POST/PATCH never retry unless
@ -46,10 +47,93 @@ export const DEFAULT_TIMEOUT = 10_000;
export const MAX_ERROR_BODY_BYTES = 10 * 1024 * 1024;
/** Headers from which `Retry-After` semantics are read, in priority order. */
export const RETRY_AFTER_HEADERS: ReadonlyArray<string> = [
'retry-after',
'ratelimit-reset',
'x-ratelimit-reset',
'x-rate-limit-reset',
'x-ratelimit-retry-after'
];
export const RETRY_AFTER_HEADERS: ReadonlyArray<string> = HTTP_RETRY_AFTER_HEADERS;
export const HTTP_ERROR_NAME_NETWORK = 'HttpNetworkError';
export const HTTP_ERROR_NAME_TIMEOUT = 'HttpTimeoutError';
export const HTTP_ERROR_NAME_ABORT = 'HttpAbortError';
export const HTTP_ERROR_NAME_BODY_VALIDATION = 'HttpBodyValidationError';
export const HTTP_TIMEOUT_SCOPE_ATTEMPT = 'attempt';
export const HTTP_TIMEOUT_SCOPE_TOTAL = 'total';
export const HTTP_RESULT_KIND_HTTP = 'http';
export const HTTP_RESULT_KIND_VALIDATION = 'validation';
export const HTTP_RESULT_KIND_NETWORK = 'network';
export const LOG_MSG_BODY_SCHEMA_REJECTED = 'bodySchema rejected request payload';
export const LOG_MSG_NETWORK_ERROR = 'network error';
export const LOG_MSG_RESPONSE_SCHEMA_FAILED = 'response failed schema validation';
export const LOG_MSG_RETRYING_PREFIX = 'retrying after';
export const LOG_MSG_HTTP_PREFIX = 'HTTP';
export const ERROR_MSG_ATTEMPT_TIMEOUT_PREFIX = 'Request exceeded per-attempt timeout';
export const ERROR_MSG_TOTAL_TIMEOUT_PREFIX = 'Request exceeded total timeout';
export const ERROR_MSG_ABORT_PREFIX = 'Request aborted: ';
export const ERROR_MSG_ABORT_UNKNOWN_REASON = 'unknown reason';
export function requestBodyValidationErrorMessage(method: HttpMethod, url: string): string {
return `${ERROR_PREFIX}Request body failed validation for ${method} ${url}`;
}
export function requestLogMessage(method: HttpMethod, url: string): string {
return `${method} ${url}`;
}
export function bodySchemaRejectedLogMessage(method: HttpMethod, url: string): string {
return `${requestLogMessage(method, url)} - ${LOG_MSG_BODY_SCHEMA_REJECTED}`;
}
export function networkErrorLogMessage(method: HttpMethod, url: string): string {
return `${requestLogMessage(method, url)} - ${LOG_MSG_NETWORK_ERROR}`;
}
export function retryingLogMessage(
method: HttpMethod,
url: string,
delayMs: number,
nextAttempt: number,
totalAttempts: number
): string {
return `${requestLogMessage(method, url)} - ${LOG_MSG_RETRYING_PREFIX} ${delayMs}ms (attempt ${nextAttempt}/${totalAttempts})`;
}
export function responseSchemaFailedLogMessage(method: HttpMethod, url: string): string {
return `${requestLogMessage(method, url)} - ${LOG_MSG_RESPONSE_SCHEMA_FAILED}`;
}
export function httpStatusLogMessage(
method: HttpMethod,
url: string,
status: number,
statusText: string
): string {
return `${requestLogMessage(method, url)} - ${LOG_MSG_HTTP_PREFIX} ${status} ${statusText}`;
}
export function httpStatusErrorMessage(status: number): string {
return `${LOG_MSG_HTTP_PREFIX} ${status}`;
}
export function attemptTimeoutErrorMessage(timeoutMs: number): string {
return `${ERROR_MSG_ATTEMPT_TIMEOUT_PREFIX} (${timeoutMs}ms)`;
}
export function totalTimeoutErrorMessage(totalMs: number): string {
return `${ERROR_MSG_TOTAL_TIMEOUT_PREFIX} (${totalMs}ms)`;
}
export function abortErrorMessage(reason: unknown): string {
if (reason instanceof Error) return `${ERROR_MSG_ABORT_PREFIX}${reason.message}`;
return `${ERROR_MSG_ABORT_PREFIX}${typeof reason === 'string' ? reason : ERROR_MSG_ABORT_UNKNOWN_REASON}`;
}
export {
HTTP_HEADER_CONTENT_TYPE,
HTTP_METHOD_DELETE,
HTTP_METHOD_GET,
HTTP_METHOD_HEAD,
HTTP_METHOD_OPTIONS,
HTTP_METHOD_PATCH,
HTTP_METHOD_POST,
HTTP_METHOD_PUT
};

@ -2,7 +2,30 @@ import type { StandardSchemaV1 } from '$libs/standard-schema';
import { isPromiseLike } from '$libs/standard-schema';
import { applyHeaders, mergeHeaders, parseBody, serializeBody } from './body.ts';
import { DEFAULT_RETRY, DEFAULT_TIMEOUT, LOGGER_CATEGORY } from './consts.ts';
import {
DEFAULT_RETRY,
DEFAULT_TIMEOUT,
HTTP_HEADER_CONTENT_TYPE,
HTTP_METHOD_DELETE,
HTTP_METHOD_GET,
HTTP_METHOD_HEAD,
HTTP_METHOD_OPTIONS,
HTTP_METHOD_PATCH,
HTTP_METHOD_POST,
HTTP_METHOD_PUT,
HTTP_RESULT_KIND_HTTP,
HTTP_RESULT_KIND_NETWORK,
HTTP_RESULT_KIND_VALIDATION,
LOGGER_CATEGORY,
bodySchemaRejectedLogMessage,
httpStatusErrorMessage,
httpStatusLogMessage,
networkErrorLogMessage,
requestBodyValidationErrorMessage,
requestLogMessage,
responseSchemaFailedLogMessage,
retryingLogMessage
} from './consts.ts';
import { HttpBodyValidationError } from './errors.ts';
import { computeRetryDelay, delayWithSignal, shouldRetryRequest } from './retry.ts';
import { appendSearch, normalizeSearch, resolveUrl } from './search.ts';
@ -45,25 +68,25 @@ export function createEngineHttp(options: EngineHttpOptions = {}): EngineHttp {
return createEngineHttp(mergeOptions(options, overrides));
},
get(url, init) {
return execute(defaults, 'GET', url, init);
return execute(defaults, HTTP_METHOD_GET, url, init);
},
head(url, init) {
return execute(defaults, 'HEAD', url, init);
return execute(defaults, HTTP_METHOD_HEAD, url, init);
},
delete(url, init) {
return execute(defaults, 'DELETE', url, init);
return execute(defaults, HTTP_METHOD_DELETE, url, init);
},
options(url, init) {
return execute(defaults, 'OPTIONS', url, init);
return execute(defaults, HTTP_METHOD_OPTIONS, url, init);
},
post(url, init) {
return execute(defaults, 'POST', url, init);
return execute(defaults, HTTP_METHOD_POST, url, init);
},
put(url, init) {
return execute(defaults, 'PUT', url, init);
return execute(defaults, HTTP_METHOD_PUT, url, init);
},
patch(url, init) {
return execute(defaults, 'PATCH', url, init);
return execute(defaults, HTTP_METHOD_PATCH, url, init);
}
};
@ -104,10 +127,7 @@ function freezeDefaults(options: EngineHttpOptions): ResolvedDefaults {
};
}
function mergeOptions(
parent: EngineHttpOptions,
override: EngineHttpOptions
): EngineHttpOptions {
function mergeOptions(parent: EngineHttpOptions, override: EngineHttpOptions): EngineHttpOptions {
return {
baseUrl: override.baseUrl ?? parent.baseUrl,
// Headers compose: when both layers set them, build a synthetic hook
@ -119,15 +139,9 @@ function mergeOptions(
totalTimeout: override.totalTimeout ?? parent.totalTimeout,
retry: { ...parent.retry, ...override.retry },
hooks: {
beforeRequest: concatHooks(
parent.hooks?.beforeRequest,
override.hooks?.beforeRequest
),
beforeRequest: concatHooks(parent.hooks?.beforeRequest, override.hooks?.beforeRequest),
beforeRetry: concatHooks(parent.hooks?.beforeRetry, override.hooks?.beforeRetry),
afterResponse: concatHooks(
parent.hooks?.afterResponse,
override.hooks?.afterResponse
),
afterResponse: concatHooks(parent.hooks?.afterResponse, override.hooks?.afterResponse),
beforeError: concatHooks(parent.hooks?.beforeError, override.hooks?.beforeError)
},
logger: override.logger ?? parent.logger
@ -172,15 +186,9 @@ function resolveCallHooks(
): Required<HttpHooks> {
if (init?.hooks === undefined) return defaults.hooks;
return {
beforeRequest: [
...defaults.hooks.beforeRequest,
...(init.hooks.beforeRequest ?? [])
],
beforeRequest: [...defaults.hooks.beforeRequest, ...(init.hooks.beforeRequest ?? [])],
beforeRetry: [...defaults.hooks.beforeRetry, ...(init.hooks.beforeRetry ?? [])],
afterResponse: [
...defaults.hooks.afterResponse,
...(init.hooks.afterResponse ?? [])
],
afterResponse: [...defaults.hooks.afterResponse, ...(init.hooks.afterResponse ?? [])],
beforeError: [...defaults.hooks.beforeError, ...(init.hooks.beforeError ?? [])]
};
}
@ -212,14 +220,14 @@ async function execute<S extends StandardSchemaV1 | undefined>(
const validated = await runStandardValidate(bodySchema, bodyInput);
if (validated.issues !== undefined) {
const error = new HttpBodyValidationError(
`[http] Request body failed validation for ${method} ${url}`,
requestBodyValidationErrorMessage(method, url),
validated.issues
);
// Programmer error: log it before throwing so the failure is visible
// in the logger pipeline even when the caller's catch swallows the
// throw.
if (logger !== undefined) {
logger.error(LOGGER_CATEGORY, `${method} ${url} — bodySchema rejected request payload`, {
logger.error(LOGGER_CATEGORY, bodySchemaRejectedLogMessage(method, url), {
context: { url, issueCount: validated.issues.length },
error
});
@ -261,8 +269,8 @@ async function execute<S extends StandardSchemaV1 | undefined>(
// (auth refresh pattern) and `beforeRetry` may have updated the
// closure-captured token.
const headers = await mergeHeaders(defaults.headers, init?.headers);
if (contentType !== undefined && !headers.has('content-type')) {
headers.set('content-type', contentType);
if (contentType !== undefined && !headers.has(HTTP_HEADER_CONTENT_TYPE)) {
headers.set(HTTP_HEADER_CONTENT_TYPE, contentType);
}
// Fresh request bag per attempt — hooks mutate this; mutations within
@ -281,12 +289,13 @@ async function execute<S extends StandardSchemaV1 | undefined>(
try {
if (logger !== undefined) {
logger.debug(LOGGER_CATEGORY, `${method} ${ctx.url}`, {
logger.debug(LOGGER_CATEGORY, requestLogMessage(method, ctx.url), {
context: { attempt, url: ctx.url }
});
}
response = earlyResponse instanceof Response
response =
earlyResponse instanceof Response
? earlyResponse
: await fetchImpl(ctx.url, {
method: ctx.request.method,
@ -299,7 +308,7 @@ async function execute<S extends StandardSchemaV1 | undefined>(
response = undefined;
lastError = classifyFetchError(err, signal);
if (logger !== undefined) {
logger.warn(LOGGER_CATEGORY, `${method} ${ctx.url} — network error`, {
logger.warn(LOGGER_CATEGORY, networkErrorLogMessage(method, ctx.url), {
context: { attempt },
error: lastError instanceof Error ? lastError : new Error(String(lastError))
});
@ -318,7 +327,7 @@ async function execute<S extends StandardSchemaV1 | undefined>(
if (logger !== undefined) {
logger.warn(
LOGGER_CATEGORY,
`${method} ${fullUrl} — retrying after ${delay}ms (attempt ${attempt + 1}/${retry.limit + 1})`,
retryingLogMessage(method, fullUrl, delay, attempt + 1, retry.limit + 1),
{ context: { attempt, retryDelay: delay } }
);
}
@ -363,12 +372,10 @@ async function execute<S extends StandardSchemaV1 | undefined>(
// Validation failure on a 2xx body is a contract violation between
// client and server — log at ERROR so it surfaces independently of
// the result handling path.
if (!result.ok && result.kind === 'validation' && logger !== undefined) {
logger.error(
LOGGER_CATEGORY,
`${method} ${fullUrl} — response failed schema validation`,
{ context: { url: fullUrl, issueCount: result.issues.length } }
);
if (!result.ok && result.kind === HTTP_RESULT_KIND_VALIDATION && logger !== undefined) {
logger.error(LOGGER_CATEGORY, responseSchemaFailedLogMessage(method, fullUrl), {
context: { url: fullUrl, issueCount: result.issues.length }
});
}
return result;
}
@ -377,7 +384,7 @@ async function execute<S extends StandardSchemaV1 | undefined>(
const rescued = await runBeforeError(
hooks.beforeError,
{ ...finalCtx, response: finalResponse },
lastError ?? new Error(`HTTP ${finalResponse.status}`)
lastError ?? new Error(httpStatusErrorMessage(finalResponse.status))
);
if (rescued instanceof Response) {
return await buildOkOrValidation<S>(
@ -392,7 +399,7 @@ async function execute<S extends StandardSchemaV1 | undefined>(
if (logger !== undefined) {
logger.warn(
LOGGER_CATEGORY,
`${method} ${fullUrl} — HTTP ${finalResponse.status} ${finalResponse.statusText}`,
httpStatusLogMessage(method, fullUrl, finalResponse.status, finalResponse.statusText),
{
context: {
url: fullUrl,
@ -415,7 +422,7 @@ async function execute<S extends StandardSchemaV1 | undefined>(
);
}
return { ok: false, kind: 'network', error: lastError } as HttpResult<Out<S>>;
return { ok: false, kind: HTTP_RESULT_KIND_NETWORK, error: lastError } as HttpResult<Out<S>>;
}
// ============================================================================
@ -488,7 +495,7 @@ async function buildOkOrValidation<S extends StandardSchemaV1 | undefined>(
if (validated.issues !== undefined) {
return {
ok: false,
kind: 'validation',
kind: HTTP_RESULT_KIND_VALIDATION,
issues: validated.issues,
response
};
@ -503,7 +510,7 @@ async function buildHttpFailure<S extends StandardSchemaV1 | undefined>(
const body = await parseBody(response, method);
return {
ok: false,
kind: 'http',
kind: HTTP_RESULT_KIND_HTTP,
status: response.status,
statusText: response.statusText,
body,

@ -1,4 +1,12 @@
import type { StandardSchemaV1 } from '$libs/standard-schema';
import {
HTTP_ERROR_NAME_ABORT,
HTTP_ERROR_NAME_BODY_VALIDATION,
HTTP_ERROR_NAME_NETWORK,
HTTP_ERROR_NAME_TIMEOUT,
HTTP_TIMEOUT_SCOPE_ATTEMPT,
HTTP_TIMEOUT_SCOPE_TOTAL
} from './consts.ts';
/**
* Hierarchy of error classes raised internally by the engine. Most of them
@ -24,7 +32,7 @@ abstract class HttpEngineError extends Error {
* cause (DNS failure, CORS preflight, offline, etc.) in `cause`.
*/
export class HttpNetworkError extends HttpEngineError {
readonly name = 'HttpNetworkError' as const;
readonly name = HTTP_ERROR_NAME_NETWORK;
}
/**
@ -32,9 +40,12 @@ export class HttpNetworkError extends HttpEngineError {
* surfaces as `kind: 'network'` only on the final attempt.
*/
export class HttpTimeoutError extends HttpEngineError {
readonly name = 'HttpTimeoutError' as const;
readonly scope: 'attempt' | 'total';
constructor(message: string, scope: 'attempt' | 'total') {
readonly name = HTTP_ERROR_NAME_TIMEOUT;
readonly scope: typeof HTTP_TIMEOUT_SCOPE_ATTEMPT | typeof HTTP_TIMEOUT_SCOPE_TOTAL;
constructor(
message: string,
scope: typeof HTTP_TIMEOUT_SCOPE_ATTEMPT | typeof HTTP_TIMEOUT_SCOPE_TOTAL
) {
super(message);
this.scope = scope;
}
@ -44,7 +55,7 @@ export class HttpTimeoutError extends HttpEngineError {
* The user-supplied `signal` aborted. Engine never retries past this.
*/
export class HttpAbortError extends HttpEngineError {
readonly name = 'HttpAbortError' as const;
readonly name = HTTP_ERROR_NAME_ABORT;
readonly reason: unknown;
constructor(message: string, reason: unknown) {
super(message);
@ -59,7 +70,7 @@ export class HttpAbortError extends HttpEngineError {
* programmer error, not a runtime data condition.
*/
export class HttpBodyValidationError extends HttpEngineError {
readonly name = 'HttpBodyValidationError' as const;
readonly name = HTTP_ERROR_NAME_BODY_VALIDATION;
readonly issues: ReadonlyArray<StandardSchemaV1.Issue>;
constructor(message: string, issues: ReadonlyArray<StandardSchemaV1.Issue>) {
super(message);
@ -70,19 +81,19 @@ export class HttpBodyValidationError extends HttpEngineError {
// ── Type guards ─────────────────────────────────────────────────────────────
export function isHttpNetworkError(value: unknown): value is HttpNetworkError {
return value instanceof Error && (value as { name?: string }).name === 'HttpNetworkError';
return value instanceof Error && (value as { name?: string }).name === HTTP_ERROR_NAME_NETWORK;
}
export function isHttpTimeoutError(value: unknown): value is HttpTimeoutError {
return value instanceof Error && (value as { name?: string }).name === 'HttpTimeoutError';
return value instanceof Error && (value as { name?: string }).name === HTTP_ERROR_NAME_TIMEOUT;
}
export function isHttpAbortError(value: unknown): value is HttpAbortError {
return value instanceof Error && (value as { name?: string }).name === 'HttpAbortError';
return value instanceof Error && (value as { name?: string }).name === HTTP_ERROR_NAME_ABORT;
}
export function isHttpBodyValidationError(value: unknown): value is HttpBodyValidationError {
return (
value instanceof Error && (value as { name?: string }).name === 'HttpBodyValidationError'
value instanceof Error && (value as { name?: string }).name === HTTP_ERROR_NAME_BODY_VALIDATION
);
}

@ -1,47 +1,8 @@
import { RETRY_AFTER_HEADERS } from './consts.ts';
import { parseRetryAfter } from '$libs/http';
import { HTTP_TIMEOUT_SCOPE_TOTAL } from './consts.ts';
import { HttpAbortError, HttpTimeoutError } from './errors.ts';
import type { HttpMethod, RetryConfig } from './types.ts';
/**
* Pre-flight ceiling for `Retry-After` interpretation. Values above this in
* seconds are treated as epoch-second timestamps; below as a relative delay.
* Mirrors `ky`'s heuristic — chosen because no reasonable retry delay is
* larger than this and no realistic past timestamp is smaller. Any value
* after January 1 2024 (≈1.7e9) is unambiguously a date.
*/
const RETRY_AFTER_DATE_CEILING_SECONDS = 1_700_000_000;
/**
* Parse a `Retry-After` (or one of the rate-limit-reset variants) header.
* Returns the delay in milliseconds, clamped to non-negative. Returns
* `undefined` when no header carried a usable value.
*/
export function parseRetryAfter(headers: Headers, now: number = Date.now()): number | undefined {
for (const name of RETRY_AFTER_HEADERS) {
const raw = headers.get(name);
if (raw === null) continue;
const trimmed = raw.trim();
if (trimmed === '') continue;
// Numeric: seconds (delay) when small, epoch-seconds when large.
const numeric = Number(trimmed);
if (Number.isFinite(numeric)) {
if (numeric < 0) return 0;
if (numeric > RETRY_AFTER_DATE_CEILING_SECONDS) {
// Epoch seconds.
return Math.max(0, numeric * 1000 - now);
}
// Delta-seconds.
return numeric * 1000;
}
// HTTP-date.
const parsed = Date.parse(trimmed);
if (Number.isFinite(parsed)) return Math.max(0, parsed - now);
}
return undefined;
}
export { parseRetryAfter } from '$libs/http';
/**
* Decide whether a failed attempt should be retried. Hierarchy:
@ -60,7 +21,8 @@ export function shouldRetryRequest(
): boolean {
// Hard stops independent of policy.
if (ctx.error instanceof HttpAbortError) return false;
if (ctx.error instanceof HttpTimeoutError && ctx.error.scope === 'total') return false;
if (ctx.error instanceof HttpTimeoutError && ctx.error.scope === HTTP_TIMEOUT_SCOPE_TOTAL)
return false;
if (policy.shouldRetry) return policy.shouldRetry(ctx);

@ -1,59 +1 @@
import type { HttpSearchInit } from './types.ts';
/**
* Normalize any supported search input into a `URLSearchParams`. `null` and
* `undefined` values are stripped — callers that need an empty query value
* pass an explicit empty string.
*/
export function normalizeSearch(input: HttpSearchInit | undefined): URLSearchParams | undefined {
if (input === undefined) return undefined;
if (input instanceof URLSearchParams) return new URLSearchParams(input);
if (typeof input === 'string') {
return new URLSearchParams(input.startsWith('?') ? input.slice(1) : input);
}
const out = new URLSearchParams();
if (Array.isArray(input)) {
for (const [key, value] of input) {
out.append(key, String(value));
}
return out;
}
for (const [key, value] of Object.entries(input)) {
if (value === null || value === undefined) continue;
out.append(key, String(value));
}
return out;
}
/**
* Append `params` to the existing query string of `url`. If both sides
* declare the same key, both are kept (multi-value query semantics).
*/
export function appendSearch(url: string, params: URLSearchParams | undefined): string {
if (params === undefined || params.size === 0) return url;
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}${params.toString()}`;
}
/**
* Resolve a URL against an optional `baseUrl`. Behavior:
* - `url` is absolute (`http(s)://...`, `data:`, `blob:`, ...) → returned as-is
* - `baseUrl` is empty/undefined → `url` returned as-is (relative)
* - both supplied → `baseUrl` and `url` are joined with exactly one `/` between
* and any trailing query/hash on `baseUrl` is stripped first (rare, but
* defensive)
*/
export function resolveUrl(url: string, baseUrl: string | undefined): string {
if (baseUrl === undefined || baseUrl === '') return url;
if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(url)) return url;
const trimmedBase = baseUrl.split('?')[0].split('#')[0].replace(/\/+$/, '');
const trimmedUrl = url.replace(/^\/+/, '');
return `${trimmedBase}/${trimmedUrl}`;
}
export { appendSearch, normalizeSearch, resolveUrl } from '$libs/http';

@ -1,4 +1,11 @@
import { HttpAbortError, HttpTimeoutError } from './errors.ts';
import {
HTTP_TIMEOUT_SCOPE_ATTEMPT,
HTTP_TIMEOUT_SCOPE_TOTAL,
abortErrorMessage,
attemptTimeoutErrorMessage,
totalTimeoutErrorMessage
} from './consts.ts';
/**
* Compose any number of `AbortSignal`s into a single one that fires when the
@ -34,7 +41,10 @@ export function attemptTimeoutSignal(timeoutMs: number | undefined): AbortSignal
if (timeoutMs === undefined || timeoutMs <= 0) return undefined;
const ctrl = new AbortController();
const id = setTimeout(
() => ctrl.abort(new HttpTimeoutError(`Request exceeded per-attempt timeout (${timeoutMs}ms)`, 'attempt')),
() =>
ctrl.abort(
new HttpTimeoutError(attemptTimeoutErrorMessage(timeoutMs), HTTP_TIMEOUT_SCOPE_ATTEMPT)
),
timeoutMs
);
// Best-effort `unref` so the timer does not keep Node alive after the
@ -51,7 +61,8 @@ export function totalTimeoutSignal(totalMs: number | undefined): AbortSignal | u
if (totalMs === undefined || totalMs <= 0) return undefined;
const ctrl = new AbortController();
const id = setTimeout(
() => ctrl.abort(new HttpTimeoutError(`Request exceeded total timeout (${totalMs}ms)`, 'total')),
() =>
ctrl.abort(new HttpTimeoutError(totalTimeoutErrorMessage(totalMs), HTTP_TIMEOUT_SCOPE_TOTAL)),
totalMs
);
(id as unknown as { unref?: () => void }).unref?.();
@ -73,10 +84,7 @@ export function classifyAbort(signal: AbortSignal): HttpTimeoutError | HttpAbort
if (reason instanceof HttpTimeoutError) return reason;
if (reason instanceof Error) {
return new HttpAbortError(`Request aborted: ${reason.message}`, reason);
return new HttpAbortError(abortErrorMessage(reason), reason);
}
return new HttpAbortError(
`Request aborted: ${typeof reason === 'string' ? reason : 'unknown reason'}`,
reason
);
return new HttpAbortError(abortErrorMessage(reason), reason);
}

@ -16,45 +16,32 @@
*/
import type { StandardSchemaV1 } from '$libs/standard-schema';
import type {
HttpBodyInit,
HttpHeadersHook,
HttpHeadersInit,
HttpMethod,
HttpSearchInit
} from '$libs/http';
import type { EngineLogger } from '$logr';
import type {
HTTP_RESULT_KIND_HTTP,
HTTP_RESULT_KIND_NETWORK,
HTTP_RESULT_KIND_VALIDATION
} from './consts.ts';
export type {
HttpBodyInit,
HttpHeadersHook,
HttpHeadersInit,
HttpMethod,
HttpSearchInit
} from '$libs/http';
// ============================================================================
// CORE
// ============================================================================
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS';
/**
* Search params input. Strings/numbers/booleans are stringified;
* `null` / `undefined` values are stripped (use `''` for `?key=`).
*/
export type HttpSearchInit =
| string
| URLSearchParams
| Record<string, string | number | boolean | null | undefined>
| ReadonlyArray<readonly [string, string | number | boolean]>;
/**
* Headers input. The hook form is invoked per attempt — useful for auth
* tokens that must be refreshed (return a `Promise<HttpHeadersInit>` for
* async refresh).
*/
export type HttpHeadersInit = Headers | Record<string, string> | ReadonlyArray<readonly [string, string]>;
export type HttpHeadersHook = () => HttpHeadersInit | Promise<HttpHeadersInit>;
/**
* Body input. Native `BodyInit` types (`FormData`, `URLSearchParams`, `Blob`,
* `ArrayBuffer`, `ReadableStream`, string) pass through. Plain objects and
* arrays are JSON-stringified automatically and `Content-Type` is set to
* `application/json` if the caller did not set one.
*/
export type HttpBodyInit = BodyInit | Record<string, unknown> | unknown[] | null | undefined;
// ============================================================================
// SCHEMA INFERENCE
// ============================================================================
/**
* Compute the validated output type when a schema is provided, else `unknown`.
* Mirrors Sium's `InferOutput` for ergonomic chaining.
@ -89,7 +76,7 @@ export type HttpResult<T> =
| { readonly ok: true; readonly value: T; readonly response: Response }
| {
readonly ok: false;
readonly kind: 'http';
readonly kind: typeof HTTP_RESULT_KIND_HTTP;
readonly status: number;
readonly statusText: string;
readonly body: unknown;
@ -97,11 +84,11 @@ export type HttpResult<T> =
}
| {
readonly ok: false;
readonly kind: 'validation';
readonly kind: typeof HTTP_RESULT_KIND_VALIDATION;
readonly issues: ReadonlyArray<StandardSchemaV1.Issue>;
readonly response: Response;
}
| { readonly ok: false; readonly kind: 'network'; readonly error: unknown };
| { readonly ok: false; readonly kind: typeof HTTP_RESULT_KIND_NETWORK; readonly error: unknown };
// ============================================================================
// REQUEST INIT
@ -185,9 +172,7 @@ export interface HookContext {
* response). To rewrite the outgoing request, mutate `ctx.url` or
* `ctx.request` in place — both flow through to `fetch`.
*/
export type BeforeRequestHook = (
ctx: HookContext
) => void | Response | Promise<void | Response>;
export type BeforeRequestHook = (ctx: HookContext) => void | Response | Promise<void | Response>;
/**
* Pre-retry hook: fires between attempts after the failure has been

@ -0,0 +1,55 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { ResourceRef } from '$libs/perm';
import { getPermissionsContext } from './context.ts';
interface Props {
action: string;
resource?: ResourceRef;
context?: Record<string, unknown>;
children?: Snippet;
fallback?: Snippet;
loading?: Snippet;
}
let {
action,
resource = undefined,
context = undefined,
children,
fallback,
loading
}: Props = $props();
const permissions = getPermissionsContext();
let allowed = $state(false);
let pending = $state(true);
$effect(() => {
let cancelled = false;
pending = true;
void permissions
.can({ action, resource, context })
.then((next) => {
if (cancelled) return;
allowed = next;
pending = false;
})
.catch(() => {
if (cancelled) return;
allowed = false;
pending = false;
});
return () => {
cancelled = true;
};
});
</script>
{#if allowed}
{@render children?.()}
{:else if pending && loading}
{@render loading()}
{:else}
{@render fallback?.()}
{/if}

@ -0,0 +1,941 @@
# perm
`perm` is the authorization artifact of the framework.
It is not an RBAC helper. It is a typed authorization runtime built around explicit
decisions:
```ts
actor + action + resource + context -> decision
```
The most important rule is:
> The server decides. The client reflects.
Use `createEnginePermissions()` in the authoritative runtime: server routes, server actions,
API handlers, command handlers, job processors.
Use `createActivePermissions()` in Svelte/UI code only to improve UX: hide buttons, show
disabled states, hydrate snapshots, cache remote checks and render `<Can />`.
Client-side authorization is never a security boundary.
## What This Solves
Most permission systems collapse too early into one of these shapes:
- RBAC: `user has role admin`.
- ABAC: `user.department === resource.department`.
- ReBAC: `user is owner/member/viewer of resource`.
- UI-only helpers: `can('edit', post)`.
Real applications need all of them, often in the same decision.
`perm` models authorization as a policy runtime:
- Roles are actor attributes.
- Ownership and membership are relations.
- Request/session/risk data lives in context.
- Policies return rich decisions, not booleans.
- Deny overrides allow.
- Unknown deny fails closed.
- Decisions can be explained.
- List queries can be filtered or compiled into query plans.
## Public Surface
```ts
import {
createEnginePermissions,
createActivePermissions,
createPermissionHttpHandlers,
definePermSchema,
definePolicies,
allow,
deny,
attr,
actor,
resource,
ctx,
rel,
and,
or,
not,
mask,
redact,
audit,
requireMfa,
createSqlCompiler
} from '$perm';
```
Main APIs:
- `createEnginePermissions(options)` creates the authoritative engine.
- `createActivePermissions(options)` creates a reactive client-side reflector.
- `App.createActivePermissions(options)` creates an App-wired active client with `App.Http` and `App.Logger`.
- `createPermissionHttpHandlers(engine, resolveActor)` exposes `check`, `batch`, `what`, `explain`.
- `<Can />` renders UI based on `Permissions.can(...)`.
## Core Concepts
### Actor
The subject asking for access.
```ts
const actor = {
type: 'user',
id: 'u1',
status: 'active',
role: 'admin',
teamIds: ['team-a']
};
```
Required fields:
- `type`: actor kind, usually `user`, `service`, `token`, `anonymous`.
- `id`: stable identifier.
Everything else is an attribute.
### Action
A string in the form `resource.action`.
```ts
'post.read';
'post.update';
'invoice.approve';
'project.member.invite';
```
Wildcards are supported in policies:
```ts
deny('post.*');
```
Use constants in application code if the action is shared across modules.
### Resource
The thing being accessed.
```ts
const post = {
type: 'post',
id: 'p1',
visibility: 'private',
status: 'draft',
ownerId: 'u1',
teamId: 'team-a'
};
```
Required field:
- `type`: resource kind.
Recommended field:
- `id`: stable identifier.
Everything else is an attribute.
### Context
Per-decision data that is neither actor nor resource.
```ts
const context = {
risk: { mfa: true },
request: { ip: '127.0.0.1' },
tenant: 'acme'
};
```
Typical context values:
- MFA or risk flags.
- Tenant id.
- Request metadata.
- Environment.
- Time window.
- Feature flag snapshot.
## Decisions
`perm` does not return plain booleans from the engine. `check()` returns a decision:
```ts
type PermissionDecision =
| {
effect: 'allow';
policy: string;
reason?: string;
obligations?: ObligationIR[];
advice?: AdviceIR[];
ttl?: number;
}
| { effect: 'deny'; policy?: string; code: string; reason: string; advice?: AdviceIR[] }
| {
effect: 'indeterminate';
reason: string;
fallback: 'deny' | 'allow';
policy?: string;
errors?: unknown[];
}
| { effect: 'not_applicable'; reason?: string };
```
Decision meaning:
- `allow`: access is granted.
- `deny`: access is explicitly denied.
- `indeterminate`: the runtime could not safely decide.
- `not_applicable`: no policy matched.
`can()` is sugar over `check()`:
```ts
await Permissions.can(input); // true only when effect === 'allow'
```
Everything else is false.
## Conflict Rules
The combiner is deny-overrides:
1. A matching `deny` with `true` condition wins.
2. A matching `deny` with `unknown` or `error` returns `indeterminate` with fallback `deny`.
3. A matching `allow` with `true` condition allows.
4. A matching `allow` with `unknown` or `error` returns `indeterminate`.
5. No matching policy returns `not_applicable`.
The important safety rule:
> If a deny policy might apply but cannot be evaluated safely, access is not allowed.
This avoids the classic production bug: "the membership provider failed, therefore the allow
policy won".
## Schema
The schema declares actors, resources, actions, attributes and relations.
```ts
const schema = definePermSchema({
actors: {
user: {
attributes: {
status: 'string',
role: 'string',
teamIds: 'string[]'
}
}
},
resources: {
post: {
actions: ['read', 'update', 'publish', 'delete'],
attributes: {
visibility: 'string',
status: 'string',
ownerId: 'string',
teamId: 'string'
}
}
},
relations: {
'post.owner': { from: 'post', to: 'user' },
'post.team.member': { from: 'post', to: 'user' },
'post.team.admin': { from: 'post', to: 'user' }
},
context: {
risk: { mfa: 'boolean' }
}
});
```
The schema is intentionally lightweight. It is not a database schema and it is not a validation
schema. It is the authorization vocabulary.
## Policies
Policies are built as data. The fluent builder creates an IR that can be evaluated, explained,
serialized and partially compiled.
```ts
const policies = definePolicies(schema, [
deny('post.*')
.id('post.deny.suspended')
.priority(1000)
.when(attr('actor.status').eq('suspended'))
.because('Suspended users cannot access posts', 'user_suspended'),
allow('post.read')
.id('post.read.public-or-member')
.when(or(attr('post.visibility').eq('public'), rel('post.team.member').is(actor())))
.oblige(mask('internalNotes')),
allow('post.update')
.id('post.update.owner')
.when(and(rel('post.owner').is(actor()), attr('post.status').notEq('archived'))),
allow('post.publish')
.id('post.publish.admin-with-mfa')
.when(and(rel('post.team.admin').is(actor()), attr('context.risk.mfa').eq(true)))
.oblige(audit('post.publish', 'medium'))
]);
```
Policy methods:
- `.id(id)` gives the policy a stable id. Always use this in real code.
- `.priority(number)` resolves conflicts inside same effect class.
- `.when(expr)` sets the condition.
- `.because(reason, code?)` attaches denial/explanation metadata.
- `.oblige(...items)` attaches obligations to an allow.
- `.advise(...items)` attaches non-mandatory advice.
- `.meta(record)` stores free-form metadata.
## Expressions
Available expression helpers:
```ts
attr('actor.status').eq('active');
attr('post.visibility').eq('public');
attr('post.status').notEq('archived');
attr('context.risk.mfa').eq(true);
rel('post.owner').is(actor());
rel('post.team.admin').has(actor());
and(exprA, exprB);
or(exprA, exprB);
not(expr);
```
Reference helpers:
- `actor()` references the full actor.
- `actor('id')` references `actor.id`.
- `resource()` references the full resource.
- `resource('ownerId')` references `resource.ownerId`.
- `ctx('risk.mfa')` references `context.risk.mfa`.
- `attr('actor.role')`, `attr('post.visibility')`, `attr('context.risk.mfa')` are convenient path refs.
## Providers
Policies should stay declarative. Providers resolve data that is not already present.
### Relation Provider
Use a relation provider for ownership, membership and graph-like checks.
```ts
const Permissions = createEnginePermissions({
schema,
policies,
providers: {
relations: {
hasRelation({ relation, resource, subject }) {
if (relation === 'post.owner') {
return resource.ownerId === subject.id;
}
if (relation === 'post.team.member') {
return Array.isArray(subject.teamIds) && subject.teamIds.includes(resource.teamId);
}
return 'unknown';
}
}
}
});
```
Return values:
- `true`: relation exists.
- `false`: relation does not exist.
- `'unknown'`: provider cannot answer safely.
Use `'unknown'` instead of guessing. Unknown deny fails closed.
### Attribute Provider
Use an attribute provider when actor/resource/context attributes should be resolved lazily.
```ts
providers: {
attributes: {
getAttribute({ root, path, context }) {
// Example: load an attribute from another source.
return undefined;
}
}
}
```
Most apps can start without an attribute provider by passing needed attributes directly on the
actor, resource or context.
## Engine Usage
Create the server-side runtime:
```ts
export const Permissions = createEnginePermissions({
schema,
policies,
providers,
compilers: [createSqlCompiler()]
});
```
Check a permission:
```ts
const decision = await Permissions.check({
actor,
action: 'post.update',
resource: post,
context: { risk: { mfa: true } }
});
if (decision.effect !== 'allow') {
// return 403, throw, log, explain, etc.
}
```
Assert a permission:
```ts
await Permissions.assert({
actor,
action: 'post.delete',
resource: post
});
```
`assert()` throws `PermissionDeniedError` for every non-allow decision.
Use `can()` only when a boolean is enough:
```ts
if (await Permissions.can({ actor, action: 'post.read', resource: post })) {
return post;
}
```
## Server Route Pattern
The server must enforce permissions before reading or mutating protected data.
```ts
export async function updatePost(event) {
const actor = await resolveActor(event);
const post = await loadPost(event.params.id);
await Permissions.assert({
actor,
action: 'post.update',
resource: post,
context: { tenant: event.locals.tenant }
});
return savePost(post, await event.request.json());
}
```
Do not rely on `<Can />` or `ActivePermissions.can()` for this.
## HTTP Handlers
The active client talks to HTTP handlers.
```ts
import { createPermissionHttpHandlers } from '$perm';
import { Permissions } from '$lib/server/permissions';
const handlers = createPermissionHttpHandlers(Permissions, async (request) => {
const session = await readSession(request);
return {
type: 'user',
id: session.user.id,
status: session.user.status,
role: session.user.role,
teamIds: session.user.teamIds
};
});
```
The returned object contains:
- `check(request)`
- `batch(request)`
- `what(request)`
- `explain(request)`
Expose those from your SvelteKit route however your routing convention prefers.
Conceptual route shape:
```ts
// POST /api/permissions/check
return json(await handlers.check(request));
// POST /api/permissions/batch
return json(await handlers.batch(request));
// POST /api/permissions/what
return json(await handlers.what(request));
// POST /api/permissions/explain
return json(await handlers.explain(request));
```
## Active Client
Create the client directly:
```ts
const Permissions = createActivePermissions({
endpoint: '/api/permissions',
cacheTtlMs: 30_000
});
```
Or through App:
```ts
const App = createActiveApp({
permissions: {
endpoint: '/api/permissions',
cacheTtlMs: 30_000
}
});
const Permissions = App.createActivePermissions();
```
`App.createActivePermissions()` injects:
- `App.Http`
- `App.Logger`
The endpoint remains explicit because the client is remote by design.
Active client API:
```ts
await Permissions.check({ action, resource, context });
await Permissions.can({ action, resource, context });
await Permissions.batch({ checks });
await Permissions.what({ resource, actions, context });
await Permissions.explain({ action, resource, context });
Permissions.hydrate(snapshot);
Permissions.snapshot();
Permissions.invalidate();
Permissions.subscribe((snapshot) => {});
```
Reactive fields:
```ts
Permissions.currentSnapshot;
Permissions.decisions;
Permissions.size;
Permissions.loading;
Permissions.lastError;
```
## Snapshots And Cache
The active client has a small decision cache.
```ts
const Permissions = createActivePermissions({
endpoint: '/api/permissions',
initialSnapshot,
cacheTtlMs: 10_000
});
```
Snapshot shape:
```ts
interface PermissionSnapshot {
actor?: SubjectRef;
version?: string;
decisions?: Record<string, PermissionDecision>;
global?: Record<string, boolean | PermissionDecision>;
expiresAt?: string;
}
```
Use snapshots for SSR hydration or first paint.
Important:
- Cache is for UX.
- Cache is not security.
- Mutations still require server-side `assert()`.
- Call `invalidate()` after actor/session/resource changes.
## The `<Can />` Component
`<Can />` is a small Svelte component that renders its children only when
`Permissions.can(...)` returns `true`.
It reads the active client from Svelte context:
```ts
import { setPermissionsContext } from '$perm';
const Permissions = App.createActivePermissions();
setPermissionsContext(Permissions);
```
Basic usage:
```svelte
<script lang="ts">
import Can from '$perm/Can.svelte';
</script>
<Can action="post.update" resource={post}>
<button>Edit post</button>
{#snippet fallback()}
<span>You cannot edit this post.</span>
{/snippet}
{#snippet loading()}
<span>Checking permissions...</span>
{/snippet}
</Can>
```
Props:
- `action`: permission action, for example `'post.update'`.
- `resource`: optional resource object.
- `context`: optional decision context.
- `children`: rendered on allow.
- `fallback`: rendered on deny, indeterminate, not_applicable or request failure.
- `loading`: rendered while the async check is in progress.
Again: `<Can />` is only UI. It prevents confusing affordances; it does not protect data.
## what()
Use `what()` when a view needs the full action matrix for one resource.
```ts
const actions = await Permissions.what({
actor,
resource: post
});
actions['post.read'];
actions['post.update'];
actions['post.delete'];
```
Client-side:
```ts
const actions = await Permissions.what({
resource: post,
actions: ['post.read', 'post.update']
});
```
This is better than firing many separate checks for a toolbar or detail page.
## explain()
Use `explain()` for debugging, audit panels and tests.
```ts
const result = await Permissions.explain({
actor,
action: 'post.publish',
resource: post,
context: { risk: { mfa: false } }
});
console.log(result.decision);
console.table(result.trace);
console.log(result.dependencies);
```
`explain()` returns:
- `decision`: final combined decision.
- `trace`: every target policy and condition result.
- `dependencies`: actor/resource/context/relation keys used during evaluation.
Do not expose full explanations to untrusted users unless you intentionally want to reveal policy
details.
## filter()
Use `filter(action)` to turn authorization into a list query.
In-memory predicate:
```ts
const canRead = await Permissions.filter('post.read').for(actor).resource('post').toPredicate();
const visible = [];
for (const post of posts) {
if (await canRead(post)) visible.push(post);
}
```
Query plan:
```ts
const plan = await Permissions.filter('post.read')
.for(actor)
.resource('post')
.context({ tenant: 'acme' })
.toPlan('sql');
```
SQL compiler:
```ts
const Permissions = createEnginePermissions({
schema,
policies,
compilers: [
createSqlCompiler({
resourceAlias: 'post',
relation({ relation, resourceAlias, actor, param }) {
if (relation === 'post.owner') {
return `${resourceAlias}.owner_id = ${param(actor.id)}`;
}
return undefined;
}
})
]
});
```
Compilation is conservative:
- Fully compilable policies produce `strategy: 'compiled'`.
- Partially compilable policies produce `strategy: 'partial'` and `residualPolicies`.
- Non-compilable policies produce `strategy: 'not_compilable'`.
Unsupported policies are never silently erased.
## Obligations And Advice
An allow can carry obligations:
```ts
allow('post.read')
.id('post.read.public')
.when(attr('post.visibility').eq('public'))
.oblige(mask('internalNotes'));
```
Common obligations:
- `mask(field, mode?)`
- `redact(field)`
- `audit(event, severity?)`
- `requireMfa(reason?)`
Obligations are returned in the decision. It is the caller's job to enforce them.
Example:
```ts
const decision = await Permissions.check({ actor, action: 'post.read', resource: post });
if (decision.effect === 'allow') {
return applyObligations(post, decision.obligations);
}
```
Advice is similar but non-mandatory.
## Integration With Other Artifacts
### aapp
`App.createActivePermissions()` builds the UI client and injects `App.Http` and `App.Logger`.
```ts
const App = createActiveApp({
permissions: { endpoint: '/api/permissions' }
});
const Permissions = App.createActivePermissions();
```
`App.Permissions` is `undefined` until `createActivePermissions()` is called.
### sess
Session/authentication resolves the actor. `perm` does not log users in and does not own tokens.
Typical flow:
```ts
const actor = actorFromSession(App.Sess.current);
await Permissions.assert({ actor, action, resource });
```
On the server, resolve the actor from server-side session state, not from a client payload.
### http
The active client can use `App.Http`, so existing headers, fetch scoping and hooks apply.
### conn
Realtime channels should use `perm` on the server side before allowing joins, sends or privileged
events. A client-side `<Can />` around a chat button is UX only.
### logr
`createEnginePermissions({ logger })` emits structured logs under category `'perm'` for decisions,
denials and indeterminate decisions.
## Testing
Unit-test policies as data.
```ts
it('denies suspended users', async () => {
const decision = await Permissions.check({
actor: { type: 'user', id: 'u1', status: 'suspended' },
action: 'post.read',
resource: { type: 'post', id: 'p1', visibility: 'public' }
});
expect(decision.effect).toBe('deny');
});
```
Test dangerous cases:
- Deny wins over allow.
- Unknown deny fails closed.
- Missing relation provider does not allow access.
- `what()` returns the expected action matrix.
- `filter().toPlan()` does not erase residual policies.
- Client cache invalidates when actor/resource/context changes.
There is an interactive page at:
```txt
/test/perm
```
It exercises:
- `createEnginePermissions()`
- `App.createActivePermissions()`
- HTTP handlers
- client cache
- `<Can />`
- `check()`
- `what()`
- `explain()`
- SQL query plan
## Security Checklist
- Always enforce permissions on the server.
- Treat `ActivePermissions` and `<Can />` as UI helpers only.
- Never trust actor data sent by the browser.
- Prefer stable policy ids.
- Prefer constants for shared actions, relation names and policy ids.
- Return `'unknown'` from providers when data cannot be resolved safely.
- Review every `indeterminate` as denied unless there is a deliberate exception.
- Do not expose `explain()` details to users unless policy disclosure is acceptable.
- Apply obligations explicitly.
- Invalidate client cache after login, logout, session refresh, role change or resource mutation.
## Minimal Complete Example
```ts
import {
actor,
allow,
and,
attr,
createEnginePermissions,
definePermSchema,
definePolicies,
deny,
rel
} from '$perm';
const schema = definePermSchema({
actors: {
user: { attributes: { status: 'string', role: 'string' } }
},
resources: {
post: { actions: ['read', 'update'] }
},
relations: {
'post.owner': { from: 'post', to: 'user' }
}
});
const policies = definePolicies(schema, [
deny('post.*')
.id('post.deny.suspended')
.priority(1000)
.when(attr('actor.status').eq('suspended')),
allow('post.read').id('post.read.public').when(attr('post.visibility').eq('public')),
allow('post.update')
.id('post.update.owner')
.when(and(rel('post.owner').is(actor()), attr('post.status').notEq('archived')))
]);
export const Permissions = createEnginePermissions({
schema,
policies,
providers: {
relations: {
hasRelation({ relation, resource, subject }) {
if (relation === 'post.owner') return resource.ownerId === subject.id;
return 'unknown';
}
}
}
});
```
Usage:
```ts
await Permissions.assert({
actor: { type: 'user', id: 'u1', status: 'active' },
action: 'post.update',
resource: {
type: 'post',
id: 'p1',
ownerId: 'u1',
status: 'draft',
visibility: 'private'
}
});
```

@ -0,0 +1,70 @@
import { untrack } from 'svelte';
import { createPermissionClient } from './client.ts';
import { PERMISSION_ERROR_MSG_CLIENT_ENDPOINT_REQUIRED } from './consts.ts';
import type { ActivePermissions, ActivePermissionsOptions, PermissionSnapshot } from './types.ts';
export function createActivePermissions(options: ActivePermissionsOptions): ActivePermissions {
if (!options.endpoint) throw new Error(PERMISSION_ERROR_MSG_CLIENT_ENDPOINT_REQUIRED);
const client = createPermissionClient(options);
let snapshotCell = $state<PermissionSnapshot>(client.snapshot());
let loadingCount = $state(0);
let lastErrorCell = $state<unknown | null>(null);
const off = client.subscribe((snapshot) => {
snapshotCell = snapshot;
});
function updateLoading(delta: number): void {
loadingCount = Math.max(0, untrack(() => loadingCount) + delta);
}
async function track<T>(task: () => Promise<T>): Promise<T> {
updateLoading(1);
try {
const value = await task();
lastErrorCell = null;
return value;
} catch (error) {
lastErrorCell = error;
throw error;
} finally {
updateLoading(-1);
}
}
return {
get currentSnapshot() {
return snapshotCell;
},
get decisions() {
return snapshotCell.decisions ?? {};
},
get size() {
return Object.keys(snapshotCell.decisions ?? {}).length;
},
get loading() {
return loadingCount > 0;
},
get lastError() {
return lastErrorCell;
},
check: (input) => track(() => client.check(input)),
can: (input) => track(() => client.can(input)),
batch: (input) => track(() => client.batch(input)),
what: (input) => track(() => client.what(input)),
explain: (input) => track(() => client.explain(input)),
hydrate(snapshot) {
client.hydrate(snapshot);
},
snapshot: () => client.snapshot(),
invalidate(scope) {
client.invalidate(scope);
},
subscribe: (listener) => client.subscribe(listener),
decisionKey: (input) => client.decisionKey(input),
dispose() {
off();
}
};
}

@ -0,0 +1,285 @@
import {
PERMISSION_DECISION_CODE_SNAPSHOT_DENIED,
PERMISSION_EFFECT_ALLOW,
PERMISSION_EFFECT_DENY,
PERMISSION_EFFECT_INDETERMINATE,
PERMISSION_FALLBACK_DENY
} from '$libs/perm';
import { HTTP_CONTENT_TYPE_JSON, HTTP_HEADER_CONTENT_TYPE, HTTP_METHOD_POST } from '$libs/http';
import { permissionDecisionKey } from './keys.ts';
import {
LOGGER_CATEGORY,
PERMISSION_CLIENT_DEFAULT_CACHE_TTL_MS,
PERMISSION_CLIENT_PATH_BATCH,
PERMISSION_CLIENT_PATH_CHECK,
PERMISSION_CLIENT_PATH_EXPLAIN,
PERMISSION_CLIENT_PATH_WHAT,
PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX,
PERMISSION_HTTP_CREDENTIALS_INCLUDE,
PERMISSION_LOG_MSG_REMOTE_BATCH_FAILED,
PERMISSION_LOG_MSG_REMOTE_CHECK_FAILED,
PERMISSION_LOG_MSG_REMOTE_WHAT_FAILED,
PERMISSION_REQUEST_FIELD_ACTION,
PERMISSION_REQUEST_FIELD_CHECKS,
PERMISSION_REQUEST_FIELD_CONTEXT,
PERMISSION_REQUEST_FIELD_RESOURCE,
PERMISSION_RESPONSE_FIELD_ACTIONS,
PERMISSION_RESPONSE_FIELD_DECISIONS,
PERMISSION_SNAPSHOT_GLOBAL_POLICY
} from './consts.ts';
import type {
PermissionClient,
PermissionClientBatchInput,
PermissionClientCheckInput,
PermissionClientOptions,
PermissionSnapshot
} from './types.ts';
import type { ExplainResult, PermissionDecision } from '$libs/perm';
interface CacheEntry {
readonly decision: PermissionDecision;
readonly expiresAt: number;
}
function now(): number {
return Date.now();
}
function joinUrl(base: string, path: string): string {
return `${base.replace(/\/$/, '')}/${path.replace(/^\//, '')}`;
}
async function postJson<T>(
options: PermissionClientOptions,
path: string,
body: unknown
): Promise<T> {
const url = joinUrl(options.endpoint, path);
if (options.http) {
const response = await options.http.post(url, { body: body as Record<string, unknown> });
if (response.ok) return response.value as T;
throw new Error(`${PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX}${url}`);
}
const fetcher = options.fetcher ?? fetch.bind(globalThis);
const response = await fetcher(url, {
method: HTTP_METHOD_POST,
headers: { [HTTP_HEADER_CONTENT_TYPE]: HTTP_CONTENT_TYPE_JSON },
credentials: PERMISSION_HTTP_CREDENTIALS_INCLUDE,
body: JSON.stringify(body)
});
if (!response.ok) {
throw new Error(
`${PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX}${response.status} ${response.statusText}`
);
}
return (await response.json()) as T;
}
export function createPermissionClient(options: PermissionClientOptions): PermissionClient {
const cacheTtlMs = options.cacheTtlMs ?? PERMISSION_CLIENT_DEFAULT_CACHE_TTL_MS;
const cache = new Map<string, CacheEntry>();
const pending = new Map<string, Promise<PermissionDecision>>();
const listeners = new Set<(snapshot: PermissionSnapshot) => void>();
let currentSnapshot: PermissionSnapshot = options.initialSnapshot ?? { decisions: {} };
function emit(): void {
for (const listener of listeners) listener(currentSnapshot);
}
function decisionKey(input: PermissionClientCheckInput): string {
return permissionDecisionKey(input);
}
function snapshotStillValid(snapshot: PermissionSnapshot): boolean {
return snapshot.expiresAt === undefined || Date.parse(snapshot.expiresAt) > now();
}
function readSnapshotDecision(input: PermissionClientCheckInput): PermissionDecision | undefined {
if (!snapshotStillValid(currentSnapshot)) return undefined;
const key = decisionKey(input);
const direct = currentSnapshot.decisions?.[key];
if (direct) return direct;
const global = currentSnapshot.global?.[input.action];
if (typeof global === 'boolean') {
return global
? { effect: PERMISSION_EFFECT_ALLOW, policy: PERMISSION_SNAPSHOT_GLOBAL_POLICY }
: {
effect: PERMISSION_EFFECT_DENY,
code: PERMISSION_DECISION_CODE_SNAPSHOT_DENIED,
reason: PERMISSION_SNAPSHOT_GLOBAL_POLICY
};
}
return global;
}
function setCached(input: PermissionClientCheckInput, decision: PermissionDecision): void {
const key = decisionKey(input);
const ttl =
decision.effect === PERMISSION_EFFECT_ALLOW && decision.ttl ? decision.ttl : cacheTtlMs;
cache.set(key, { decision, expiresAt: now() + ttl });
currentSnapshot = {
...currentSnapshot,
decisions: {
...(currentSnapshot.decisions ?? {}),
[key]: decision
}
};
emit();
}
async function check(input: PermissionClientCheckInput): Promise<PermissionDecision> {
const key = decisionKey(input);
const cached = cache.get(key);
if (cached && cached.expiresAt > now()) return cached.decision;
const snapshotDecision = readSnapshotDecision(input);
if (snapshotDecision) {
cache.set(key, { decision: snapshotDecision, expiresAt: now() + cacheTtlMs });
return snapshotDecision;
}
const inFlight = pending.get(key);
if (inFlight) return inFlight;
const request = postJson<PermissionDecision>(options, PERMISSION_CLIENT_PATH_CHECK, {
[PERMISSION_REQUEST_FIELD_ACTION]: input.action,
[PERMISSION_REQUEST_FIELD_RESOURCE]: input.resource,
[PERMISSION_REQUEST_FIELD_CONTEXT]: input.context
})
.then((decision) => {
setCached(input, decision);
return decision;
})
.catch((error) => {
options.onError?.(error);
options.logger?.error?.(LOGGER_CATEGORY, PERMISSION_LOG_MSG_REMOTE_CHECK_FAILED, {
error,
context: { input }
});
return {
effect: PERMISSION_EFFECT_INDETERMINATE,
reason: PERMISSION_LOG_MSG_REMOTE_CHECK_FAILED,
fallback: PERMISSION_FALLBACK_DENY,
errors: [error]
} satisfies PermissionDecision;
})
.finally(() => {
pending.delete(key);
});
pending.set(key, request);
return request;
}
async function batch(
input: PermissionClientBatchInput
): Promise<Record<string, PermissionDecision>> {
try {
const result = await postJson<{ decisions: Record<string, PermissionDecision> }>(
options,
PERMISSION_CLIENT_PATH_BATCH,
{ [PERMISSION_REQUEST_FIELD_CHECKS]: input.checks }
);
for (const item of input.checks) {
const key = decisionKey(item);
const decision = result[PERMISSION_RESPONSE_FIELD_DECISIONS][key];
if (decision) setCached(item, decision);
}
return result[PERMISSION_RESPONSE_FIELD_DECISIONS];
} catch (error) {
options.onError?.(error);
options.logger?.error?.(LOGGER_CATEGORY, PERMISSION_LOG_MSG_REMOTE_BATCH_FAILED, {
error,
context: { input }
});
const decisions: Record<string, PermissionDecision> = {};
for (const item of input.checks) {
decisions[decisionKey(item)] = {
effect: PERMISSION_EFFECT_INDETERMINATE,
reason: PERMISSION_LOG_MSG_REMOTE_BATCH_FAILED,
fallback: PERMISSION_FALLBACK_DENY,
errors: [error]
};
}
return decisions;
}
}
async function what(input: {
readonly resource?: PermissionClientCheckInput['resource'];
readonly actions?: readonly string[];
readonly context?: PermissionClientCheckInput['context'];
}): Promise<Record<string, PermissionDecision>> {
try {
const result = await postJson<{ actions: Record<string, PermissionDecision> }>(
options,
PERMISSION_CLIENT_PATH_WHAT,
input
);
for (const [action, decision] of Object.entries(result[PERMISSION_RESPONSE_FIELD_ACTIONS])) {
setCached({ action, resource: input.resource, context: input.context }, decision);
}
return result[PERMISSION_RESPONSE_FIELD_ACTIONS];
} catch (error) {
options.onError?.(error);
options.logger?.error?.(LOGGER_CATEGORY, PERMISSION_LOG_MSG_REMOTE_WHAT_FAILED, {
error,
context: { input }
});
return {};
}
}
async function explain(input: PermissionClientCheckInput): Promise<ExplainResult | null> {
try {
return await postJson<ExplainResult>(options, PERMISSION_CLIENT_PATH_EXPLAIN, input);
} catch (error) {
options.onError?.(error);
return null;
}
}
function hydrate(snapshot: PermissionSnapshot): void {
currentSnapshot = snapshot;
cache.clear();
for (const [key, decision] of Object.entries(snapshot.decisions ?? {})) {
cache.set(key, { decision, expiresAt: now() + cacheTtlMs });
}
emit();
}
function invalidate(scope?: string): void {
if (!scope) {
cache.clear();
currentSnapshot = { ...currentSnapshot, decisions: {} };
emit();
return;
}
for (const key of [...cache.keys()]) if (key.includes(scope)) cache.delete(key);
const decisions = { ...(currentSnapshot.decisions ?? {}) };
for (const key of Object.keys(decisions)) if (key.includes(scope)) delete decisions[key];
currentSnapshot = { ...currentSnapshot, decisions };
emit();
}
return {
check,
async can(input) {
return (await check(input)).effect === PERMISSION_EFFECT_ALLOW;
},
batch,
what,
explain,
hydrate,
snapshot: () => currentSnapshot,
invalidate,
subscribe(listener) {
listeners.add(listener);
listener(currentSnapshot);
return () => listeners.delete(listener);
},
decisionKey
};
}

@ -0,0 +1,49 @@
export const LOGGER_CATEGORY = 'perm';
export const PERMISSION_CONTEXT_KEY = 'active.permissions';
export const PERMISSION_CLIENT_PATH_CHECK = '/check';
export const PERMISSION_CLIENT_PATH_BATCH = '/batch';
export const PERMISSION_CLIENT_PATH_WHAT = '/what';
export const PERMISSION_CLIENT_PATH_EXPLAIN = '/explain';
export const PERMISSION_CLIENT_DEFAULT_CACHE_TTL_MS = 30_000;
export const PERMISSION_SNAPSHOT_DECISIONS_KEY = 'decisions';
export const PERMISSION_SNAPSHOT_GLOBAL_POLICY = 'snapshot.global';
export const PERMISSION_CLIENT_KEY_SEPARATOR = ':';
export const PERMISSION_CLIENT_KEY_GLOBAL = 'global';
export const PERMISSION_CLIENT_KEY_NONE = 'none';
export const PERMISSION_CLIENT_CONTEXT_EMPTY = '';
export const PERMISSION_HTTP_STATUS_OK = 200;
export const PERMISSION_HTTP_STATUS_BAD_REQUEST = 400;
export const PERMISSION_HTTP_STATUS_FORBIDDEN = 403;
export const PERMISSION_HTTP_CREDENTIALS_INCLUDE = 'include';
export const PERMISSION_LOG_MSG_REMOTE_CHECK_FAILED = 'remote authorization check failed';
export const PERMISSION_LOG_MSG_REMOTE_BATCH_FAILED = 'remote authorization batch failed';
export const PERMISSION_LOG_MSG_REMOTE_WHAT_FAILED = 'remote authorization what failed';
export const PERMISSION_LOG_MSG_DECISION = 'authorization decision';
export const PERMISSION_LOG_MSG_DENIED = 'authorization denied';
export const PERMISSION_LOG_MSG_INDETERMINATE = 'authorization indeterminate';
export const PERMISSION_ERROR_MSG_REQUEST_FAILED_PREFIX = 'Authorization request failed: ';
export const PERMISSION_ERROR_MSG_BODY_MUST_BE_OBJECT = 'Request body must be an object';
export const PERMISSION_ERROR_MSG_NO_CONTEXT = 'Permission context is not available';
export const PERMISSION_ERROR_MSG_CLIENT_ENDPOINT_REQUIRED =
'createActivePermissions requires an endpoint';
export const PERMISSION_REQUEST_FIELD_ACTION = 'action';
export const PERMISSION_REQUEST_FIELD_RESOURCE = 'resource';
export const PERMISSION_REQUEST_FIELD_CONTEXT = 'context';
export const PERMISSION_REQUEST_FIELD_CHECKS = 'checks';
export const PERMISSION_REQUEST_FIELD_ACTIONS = 'actions';
export const PERMISSION_RESPONSE_FIELD_DECISIONS = 'decisions';
export const PERMISSION_RESPONSE_FIELD_ACTIONS = 'actions';
export const PERMISSION_ACTIVE_EVENT_HYDRATE = 'hydrate';
export const PERMISSION_ACTIVE_EVENT_CHECK = 'check';
export const PERMISSION_ACTIVE_EVENT_BATCH = 'batch';
export const PERMISSION_ACTIVE_EVENT_WHAT = 'what';
export const PERMISSION_ACTIVE_EVENT_INVALIDATE = 'invalidate';

@ -0,0 +1,16 @@
import { getContext, setContext } from 'svelte';
import { PERMISSION_CONTEXT_KEY, PERMISSION_ERROR_MSG_NO_CONTEXT } from './consts.ts';
import type { ActivePermissions } from './types.ts';
const PERMISSION_CONTEXT = Symbol(PERMISSION_CONTEXT_KEY);
export function setPermissionsContext(client: ActivePermissions): ActivePermissions {
setContext(PERMISSION_CONTEXT, client);
return client;
}
export function getPermissionsContext(): ActivePermissions {
const client = getContext<ActivePermissions | undefined>(PERMISSION_CONTEXT);
if (!client) throw new Error(PERMISSION_ERROR_MSG_NO_CONTEXT);
return client;
}

@ -0,0 +1,72 @@
import {
PERMISSION_EFFECT_ALLOW,
PERMISSION_EFFECT_DENY,
PERMISSION_EFFECT_INDETERMINATE,
PermissionDeniedError,
createPermissionRuntime
} from '$libs/perm';
import {
LOGGER_CATEGORY,
PERMISSION_LOG_MSG_DECISION,
PERMISSION_LOG_MSG_DENIED,
PERMISSION_LOG_MSG_INDETERMINATE
} from './consts.ts';
import type { EnginePermissions, EnginePermissionsOptions } from './types.ts';
export function createEnginePermissions(options: EnginePermissionsOptions): EnginePermissions {
const runtime = createPermissionRuntime(options);
let disposed = false;
async function check(input: Parameters<typeof runtime.check>[0]) {
const decision = await runtime.check(input);
if (decision.effect === PERMISSION_EFFECT_DENY) {
options.logger?.warn?.(LOGGER_CATEGORY, PERMISSION_LOG_MSG_DENIED, {
context: {
action: input.action,
resource: input.resource,
decision
}
});
} else if (decision.effect === PERMISSION_EFFECT_INDETERMINATE) {
options.logger?.warn?.(LOGGER_CATEGORY, PERMISSION_LOG_MSG_INDETERMINATE, {
context: {
action: input.action,
resource: input.resource,
decision
}
});
} else {
options.logger?.debug?.(LOGGER_CATEGORY, PERMISSION_LOG_MSG_DECISION, {
context: {
action: input.action,
resource: input.resource,
decision
}
});
}
return decision;
}
return {
schema: options.schema,
policies: options.policies,
compilers: options.compilers ?? [],
check,
async can(input) {
const decision = await check(input);
return decision.effect === PERMISSION_EFFECT_ALLOW;
},
async assert(input) {
const decision = await check(input);
if (decision.effect !== PERMISSION_EFFECT_ALLOW) throw new PermissionDeniedError(decision);
},
explain: (input) => runtime.explain(input),
what: (input) => runtime.what(input),
who: (input) => runtime.who(input),
filter: (action) => runtime.filter(action),
dispose() {
if (disposed) return;
disposed = true;
}
};
}

@ -0,0 +1,95 @@
import {
PERMISSION_ERROR_MSG_BODY_MUST_BE_OBJECT,
PERMISSION_HTTP_STATUS_OK,
PERMISSION_REQUEST_FIELD_ACTION,
PERMISSION_REQUEST_FIELD_ACTIONS,
PERMISSION_REQUEST_FIELD_CHECKS,
PERMISSION_REQUEST_FIELD_CONTEXT,
PERMISSION_REQUEST_FIELD_RESOURCE,
PERMISSION_RESPONSE_FIELD_ACTIONS,
PERMISSION_RESPONSE_FIELD_DECISIONS
} from './consts.ts';
import { permissionDecisionKey } from './keys.ts';
import type {
EnginePermissions,
PermissionActorResolver,
PermissionClientCheckInput,
PermissionHttpRequestLike,
PermissionHttpResponse
} from './types.ts';
import type { PermissionCheckInput } from '$libs/perm';
function assertBodyObject(body: unknown): asserts body is Record<string, unknown> {
if (!body || typeof body !== 'object') throw new Error(PERMISSION_ERROR_MSG_BODY_MUST_BE_OBJECT);
}
function readCheck(body: Record<string, unknown>): PermissionClientCheckInput {
return {
action: String(body[PERMISSION_REQUEST_FIELD_ACTION]),
resource: body[PERMISSION_REQUEST_FIELD_RESOURCE] as PermissionClientCheckInput['resource'],
context: body[PERMISSION_REQUEST_FIELD_CONTEXT] as PermissionClientCheckInput['context']
};
}
export function createPermissionHttpHandlers(
runtime: EnginePermissions,
resolveActor: PermissionActorResolver
) {
return {
async check(request: PermissionHttpRequestLike): Promise<PermissionHttpResponse> {
const body = await request.json();
assertBodyObject(body);
const actor = await resolveActor(request, body);
const check = readCheck(body);
const decision = await runtime.check({ actor, ...check });
return { status: PERMISSION_HTTP_STATUS_OK, body: decision };
},
async batch(request: PermissionHttpRequestLike): Promise<PermissionHttpResponse> {
const body = await request.json();
assertBodyObject(body);
const actor = await resolveActor(request, body);
const checks = Array.isArray(body[PERMISSION_REQUEST_FIELD_CHECKS])
? (body[PERMISSION_REQUEST_FIELD_CHECKS] as Array<Record<string, unknown>>)
: [];
const decisions: Record<string, unknown> = {};
for (const item of checks) {
const check = readCheck(item);
decisions[permissionDecisionKey(check)] = await runtime.check({ actor, ...check });
}
return {
status: PERMISSION_HTTP_STATUS_OK,
body: { [PERMISSION_RESPONSE_FIELD_DECISIONS]: decisions }
};
},
async what(request: PermissionHttpRequestLike): Promise<PermissionHttpResponse> {
const body = await request.json();
assertBodyObject(body);
const actor = await resolveActor(request, body);
const result = await runtime.what({
actor,
resource: body[PERMISSION_REQUEST_FIELD_RESOURCE] as PermissionCheckInput['resource'],
context: body[PERMISSION_REQUEST_FIELD_CONTEXT] as PermissionCheckInput['context'],
actions: Array.isArray(body[PERMISSION_REQUEST_FIELD_ACTIONS])
? (body[PERMISSION_REQUEST_FIELD_ACTIONS] as unknown[]).map(String)
: undefined
});
return {
status: PERMISSION_HTTP_STATUS_OK,
body: { [PERMISSION_RESPONSE_FIELD_ACTIONS]: result.actions }
};
},
async explain(request: PermissionHttpRequestLike): Promise<PermissionHttpResponse> {
const body = await request.json();
assertBodyObject(body);
const actor = await resolveActor(request, body);
const check = readCheck(body);
const result = await runtime.explain({ actor, ...check });
return { status: PERMISSION_HTTP_STATUS_OK, body: result };
}
};
}

@ -0,0 +1,49 @@
export { createEnginePermissions } from './engine-permissions.ts';
export { createActivePermissions } from './active-permissions.svelte.ts';
export { createPermissionClient } from './client.ts';
export { createPermissionHttpHandlers } from './http.ts';
export { getPermissionsContext, setPermissionsContext } from './context.ts';
export { permissionDecisionKey, stablePermissionStringify } from './keys.ts';
export * from './consts.ts';
export * from './types.ts';
export {
actionMatches,
actionResource,
actionsForResource,
actor,
allow,
and,
attr,
audit,
ctx,
definePermSchema,
definePolicies,
deny,
ExprBuilder,
mask,
not,
or,
PERMISSION_EFFECT_ALLOW,
PERMISSION_EFFECT_DENY,
PERMISSION_EFFECT_INDETERMINATE,
PERMISSION_EFFECT_NOT_APPLICABLE,
PERMISSION_QUERY_TARGET_SQL,
PolicyBuilder,
redact,
rel,
RelationBuilder,
requireMfa,
resource,
resourceKey,
val,
createSqlCompiler
} from '$libs/perm';
export type {
CreateSqlCompilerOptions,
SqlCompileResult,
SqlRelationCompiler,
SqlRelationCompilerInput
} from '$libs/perm';

@ -0,0 +1,30 @@
import {
PERMISSION_CLIENT_CONTEXT_EMPTY,
PERMISSION_CLIENT_KEY_GLOBAL,
PERMISSION_CLIENT_KEY_NONE,
PERMISSION_CLIENT_KEY_SEPARATOR
} from './consts.ts';
import type { PermissionClientCheckInput } from './types.ts';
export function stablePermissionStringify(input: unknown): string {
if (input === undefined) return PERMISSION_CLIENT_CONTEXT_EMPTY;
if (input === null || typeof input !== 'object') return JSON.stringify(input);
if (Array.isArray(input)) return `[${input.map(stablePermissionStringify).join(',')}]`;
const sorted = Object.entries(input as Record<string, unknown>).sort(([a], [b]) =>
a.localeCompare(b)
);
return `{${sorted
.map(([key, value]) => `${JSON.stringify(key)}:${stablePermissionStringify(value)}`)
.join(',')}}`;
}
export function permissionDecisionKey(input: PermissionClientCheckInput): string {
const resource = input.resource
? `${input.resource.type}${PERMISSION_CLIENT_KEY_SEPARATOR}${input.resource.id ?? PERMISSION_CLIENT_KEY_NONE}`
: PERMISSION_CLIENT_KEY_GLOBAL;
return [
resource,
input.action,
input.context ? stablePermissionStringify(input.context) : PERMISSION_CLIENT_CONTEXT_EMPTY
].join(PERMISSION_CLIENT_KEY_SEPARATOR);
}

@ -0,0 +1,77 @@
import { describe, expect, it, vi } from 'vitest';
import { PERMISSION_EFFECT_ALLOW } from '$libs/perm';
import {
allow,
attr,
createEnginePermissions,
createPermissionClient,
createPermissionHttpHandlers,
definePermSchema,
definePolicies,
permissionDecisionKey
} from '$perm';
const schema = definePermSchema({
actors: {
user: { attributes: { status: 'string' } }
},
resources: {
post: { actions: ['read'] }
}
});
const policies = definePolicies(schema, [
allow('post.read').id('post.read.public').when(attr('post.visibility').eq('public'))
]);
function jsonRequest(body: unknown) {
return {
method: 'POST',
async json() {
return body;
}
};
}
describe('Permission client + HTTP handlers', () => {
it('checks remotely, caches the decision and keeps the same decision key as batch', async () => {
const runtime = createEnginePermissions({ schema, policies });
const actor = { type: 'user', id: 'u1', status: 'active' };
const handlers = createPermissionHttpHandlers(runtime, () => actor);
const calls: string[] = [];
const fetcher = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
const path = new URL(url).pathname;
calls.push(path);
const body = JSON.parse(String(init?.body));
const handler = path.endsWith('/batch') ? handlers.batch : handlers.check;
const response = await handler(jsonRequest(body));
return new Response(JSON.stringify(response.body), { status: response.status });
}) as typeof fetch;
const client = createPermissionClient({
endpoint: 'https://perm.test/permissions',
fetcher
});
const input = {
action: 'post.read',
resource: { type: 'post', id: 'p1', visibility: 'public' }
};
const first = await client.check(input);
const second = await client.check(input);
expect(first.effect).toBe(PERMISSION_EFFECT_ALLOW);
expect(second.effect).toBe(PERMISSION_EFFECT_ALLOW);
expect(fetcher).toHaveBeenCalledTimes(1);
expect(calls).toEqual(['/permissions/check']);
client.invalidate();
const batch = await client.batch({ checks: [input] });
expect(batch[permissionDecisionKey(input)]?.effect).toBe(PERMISSION_EFFECT_ALLOW);
expect(calls).toEqual(['/permissions/check', '/permissions/batch']);
});
});

@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest';
import {
PERMISSION_EFFECT_ALLOW,
PERMISSION_EFFECT_DENY,
PERMISSION_EFFECT_INDETERMINATE,
PERMISSION_FALLBACK_DENY
} from '$libs/perm';
import {
actor,
allow,
attr,
createEnginePermissions,
definePermSchema,
definePolicies,
deny,
rel
} from '$perm';
const schema = definePermSchema({
actors: {
user: { attributes: { status: 'string' } }
},
resources: {
post: { actions: ['read', 'update'] }
},
relations: {
'post.blocked': { from: 'post', to: 'user' },
'post.owner': { from: 'post', to: 'user' }
}
});
const actorRef = { type: 'user', id: 'u1', status: 'active' };
const publicPost = { type: 'post', id: 'p1', visibility: 'public', ownerId: 'u1' };
describe('EnginePermissions', () => {
it('fails closed when a matching deny policy is indeterminate, even if allow matches', async () => {
const policies = definePolicies(schema, [
deny('post.*').id('post.deny.blocked').priority(100).when(rel('post.blocked').is(actor())),
allow('post.read').id('post.read.public').when(attr('post.visibility').eq('public'))
]);
const Perm = createEnginePermissions({
schema,
policies,
providers: {
relations: {
hasRelation({ relation }) {
if (relation === 'post.blocked') return 'unknown';
return false;
}
}
}
});
const decision = await Perm.check({
actor: actorRef,
action: 'post.read',
resource: publicPost
});
expect(decision.effect).toBe(PERMISSION_EFFECT_INDETERMINATE);
if (decision.effect === PERMISSION_EFFECT_INDETERMINATE) {
expect(decision.fallback).toBe(PERMISSION_FALLBACK_DENY);
expect(decision.policy).toBe('post.deny.blocked');
}
await expect(
Perm.can({ actor: actorRef, action: 'post.read', resource: publicPost })
).resolves.toBe(false);
});
it('keeps explicit deny above allow when both conditions are true', async () => {
const policies = definePolicies(schema, [
deny('post.read').id('post.deny.suspended').when(attr('actor.status').eq('suspended')),
allow('post.read').id('post.read.public').when(attr('post.visibility').eq('public'))
]);
const Perm = createEnginePermissions({ schema, policies });
const decision = await Perm.check({
actor: { ...actorRef, status: 'suspended' },
action: 'post.read',
resource: publicPost
});
expect(decision.effect).toBe(PERMISSION_EFFECT_DENY);
if (decision.effect === PERMISSION_EFFECT_DENY) {
expect(decision.policy).toBe('post.deny.suspended');
}
});
it('answers what() with rich decisions per action', async () => {
const policies = definePolicies(schema, [
allow('post.read').id('post.read.public').when(attr('post.visibility').eq('public')),
allow('post.update').id('post.update.owner').when(rel('post.owner').is(actor()))
]);
const Perm = createEnginePermissions({
schema,
policies,
providers: {
relations: {
hasRelation({ relation, resource, subject }) {
if (relation === 'post.owner') return resource.ownerId === subject.id;
return false;
}
}
}
});
const result = await Perm.what({ actor: actorRef, resource: publicPost });
expect(result.actions['post.read']?.effect).toBe(PERMISSION_EFFECT_ALLOW);
expect(result.actions['post.update']?.effect).toBe(PERMISSION_EFFECT_ALLOW);
});
});

@ -0,0 +1,122 @@
import type { EngineHttp } from '$http';
import type { EngineLogger } from '$logr';
import type {
ExplainResult,
PermSchema,
PermissionCheckInput,
PermissionDecision,
PermissionRuntime,
PermissionRuntimeOptions,
PolicyIR,
QueryCompiler,
ResourceRef,
SubjectRef
} from '$libs/perm';
export type {
AdviceIR,
AttributeProvider,
DependencyKey,
ExplainResult,
ExprIR,
ObligationIR,
PermSchema,
PermissionCheckInput,
PermissionDecision,
PermissionEffect,
PermissionFallback,
PermissionFilterBuilder,
PermissionProviders,
PermissionRuntime,
PermissionRuntimeOptions,
PolicyIR,
QueryCompiler,
QueryPlan,
RelationProvider,
ResourceRef,
ReverseQueryResult,
SubjectRef
} from '$libs/perm';
export interface EnginePermissionsOptions extends PermissionRuntimeOptions {
readonly logger?: EngineLogger;
}
export interface EnginePermissions extends PermissionRuntime {
readonly schema: PermSchema;
readonly policies: readonly PolicyIR[];
readonly compilers: readonly QueryCompiler[];
dispose(): void;
}
export interface PermissionSnapshot {
readonly actor?: SubjectRef;
readonly version?: string;
readonly decisions?: Record<string, PermissionDecision>;
readonly global?: Record<string, boolean | PermissionDecision>;
readonly expiresAt?: string;
}
export interface PermissionClientOptions {
readonly endpoint: string;
readonly fetcher?: typeof fetch;
readonly http?: EngineHttp;
readonly initialSnapshot?: PermissionSnapshot;
readonly cacheTtlMs?: number;
readonly logger?: EngineLogger;
readonly onError?: (error: unknown) => void;
}
export interface PermissionClientCheckInput {
readonly action: string;
readonly resource?: ResourceRef;
readonly context?: Record<string, unknown>;
}
export interface PermissionClientBatchInput {
readonly checks: readonly PermissionClientCheckInput[];
}
export interface PermissionClient {
check(input: PermissionClientCheckInput): Promise<PermissionDecision>;
can(input: PermissionClientCheckInput): Promise<boolean>;
batch(input: PermissionClientBatchInput): Promise<Record<string, PermissionDecision>>;
what(input: {
readonly resource?: ResourceRef;
readonly actions?: readonly string[];
readonly context?: Record<string, unknown>;
}): Promise<Record<string, PermissionDecision>>;
explain(input: PermissionClientCheckInput): Promise<ExplainResult | null>;
hydrate(snapshot: PermissionSnapshot): void;
snapshot(): PermissionSnapshot;
invalidate(scope?: string): void;
subscribe(listener: (snapshot: PermissionSnapshot) => void): () => void;
decisionKey(input: PermissionClientCheckInput): string;
}
export interface ActivePermissions extends PermissionClient {
readonly currentSnapshot: PermissionSnapshot;
readonly decisions: Record<string, PermissionDecision>;
readonly size: number;
readonly loading: boolean;
readonly lastError: unknown | null;
dispose(): void;
}
export type ActivePermissionsOptions = PermissionClientOptions;
export interface PermissionHttpRequestLike {
readonly method: string;
readonly url?: string;
json(): Promise<unknown>;
}
export interface PermissionHttpResponse {
readonly status: number;
readonly body: unknown;
}
export type PermissionActorResolver = (
request: PermissionHttpRequestLike,
body: unknown
) => Promise<PermissionCheckInput['actor']> | PermissionCheckInput['actor'];

@ -83,7 +83,7 @@ combine the trade-offs this artifact targets:
a logout from tab A.
- **Tagged result unions, never `void`.** `revoke({scope:'global'})`
without an `onRevoke` returns `{globalRevoked: false, reason:
'missing_revoke_url'}` — the call site can show truthful UI copy.
'missing_revoke_url'}` — the call site can show truthful UI copy.
Same shape for `adopt()` (validation/invariant failures) and
`refresh()` (skipped/expired/failed).
- **Standard Schema first-class.** Per-generic `schemas.{user,credential,data}`
@ -111,8 +111,8 @@ sess/
├── engine-session.ts createEngineSession() — runes-free core
├── active-session.svelte.ts createActiveSession() — runes wrapper, reactive
│ `current` + `generation` + `identity`
├── auto-refresh.ts withAutoRefresh(engine, opts) — ticker +
│ visibilitychange + jitter
├── auto-refresh.ts withAutoRefresh(engine, opts) — keyed timer
│ support + visibilitychange + jitter
├── http-integration.ts createBeforeErrorHook(engine, {applyAuth?}) —
│ 401 → refresh → retry, with loop guard
├── jwt.ts extractJwtExp(token) — opt-in JWT exp helper
@ -130,7 +130,9 @@ sess/
## Alias
```js
alias: { $sess: 'src/arts/sess' }
alias: {
$sess: 'src/arts/sess';
}
```
---
@ -169,8 +171,8 @@ type Session<TUser, TCredential = undefined, TData = undefined> = {
readonly user: TUser | null; // null when anonymous
readonly issuedAt: number; // strict epoch ms
readonly expiresAt: number; // strict epoch ms
} & SessionCredential<TCredential> // required iff TCredential set
& SessionData<TData>; // required iff TData set
} & SessionCredential<TCredential> & // required iff TCredential set
SessionData<TData>; // required iff TData set
```
Times are **strict epoch milliseconds**. The engine does not normalise
@ -181,7 +183,8 @@ Times are **strict epoch milliseconds**. The engine does not normalise
import { toEpochMs } from '$libs/days';
await Sess.adopt({
user, credential,
user,
credential,
issuedAt: toEpochMs(payload.iat * 1000),
expiresAt: toEpochMs(payload.exp * 1000)
});
@ -207,8 +210,13 @@ answers "what nature does the client driving it have?". A session can be
```ts
interface SessionActor {
readonly kind: 'unknown' | 'human' | 'automated';
readonly source?: 'user_agent' | 'captcha' | 'fingerprint'
| 'api_key' | 'server_assertion' | 'manual'
readonly source?:
| 'user_agent'
| 'captcha'
| 'fingerprint'
| 'api_key'
| 'server_assertion'
| 'manual'
| (string & {}); // open — keep custom literals
readonly confidence?: number; // [0, 1]
}
@ -264,9 +272,9 @@ start leaking product semantics into the runtime layer.
```ts
type SessionIdentityState = 'none' | 'anonymous' | 'identified';
Sess.identity // 'none' when current === null
// 'anonymous' when current.user === null
// 'identified' when current.user !== null
Sess.identity; // 'none' when current === null
// 'anonymous' when current.user === null
// 'identified' when current.user !== null
```
Anonymous sessions are how UC-4 (anonymous cart that becomes a logged-in
@ -311,9 +319,12 @@ type RefreshResult<TUser, TC, TD> =
```ts
type RevokeResult =
| { localRevoked: true; globalRevoked: true; scope: 'global' }
| { localRevoked: true; globalRevoked: false; scope: 'local';
reason?: 'missing_revoke_url' | 'network_error'
| 'server_rejected' | 'no_session' };
| {
localRevoked: true;
globalRevoked: false;
scope: 'local';
reason?: 'missing_revoke_url' | 'network_error' | 'server_rejected' | 'no_session';
};
const r = await Sess.revoke({ scope: 'global' });
if (r.globalRevoked) toast('Signed out everywhere.');
@ -362,8 +373,10 @@ Mismatch this contract and a flaky network logs your users out.
## `RevokeFn` contract — boolean answer
```ts
type RevokeFn<TUser, TC, TD> =
(current: Session<TUser, TC, TD>, ctx: RevokeContext) => Promise<boolean>;
type RevokeFn<TUser, TC, TD> = (
current: Session<TUser, TC, TD>,
ctx: RevokeContext
) => Promise<boolean>;
```
- Resolves `true` → engine sets `scope: 'global'`.
@ -411,9 +424,9 @@ This prevents three race classes:
the external listeners use:
```ts
Sess.current // Session | null
Sess.generation // number
Sess.identity // 'none' | 'anonymous' | 'identified'
Sess.current; // Session | null
Sess.generation; // number
Sess.identity; // 'none' | 'anonymous' | 'identified'
```
```svelte
@ -473,7 +486,8 @@ const stop = withAutoRefresh(Sess, {
tickMs: 30_000,
marginMs: 90_000,
jitterMs: 5_000,
refreshOnVisible: true
refreshOnVisible: true,
timers: App.Timers // optional when using aapp; omit for native interval
});
// ... later
stop();
@ -486,10 +500,7 @@ const http = createEngineHttp({
beforeError: [
createBeforeErrorHook(Sess, {
applyAuth: (request, session) => {
request.headers.set(
'authorization',
`Bearer ${session.credential.accessToken}`
);
request.headers.set('authorization', `Bearer ${session.credential.accessToken}`);
}
})
]
@ -517,7 +528,8 @@ import { extractJwtExp } from '$sess/jwt';
import { toEpochMs } from '$libs/days';
await Sess.adopt({
user, credential,
user,
credential,
issuedAt: Date.now(),
expiresAt: extractJwtExp(accessToken) ?? Date.now() + 3_600_000
});
@ -617,7 +629,9 @@ poisoning the engine state.
## Composition with App
```ts
const App = createActiveApp({ /* ... */ });
const App = createActiveApp({
/* ... */
});
const Sess = App.createActiveSession<User, JwtCredential>({
schemas: { user: UserSchema },
@ -665,7 +679,7 @@ add cross-tab sync to any adapter.
## Errors
| Class | When | Behavior |
| --------------------------- | ------------------------------------------ | ------------- |
| ------------------------- | ---------------------------------------- | -------------------------------------- |
| `SessDisposedError` | mutator called after `dispose()` | **Thrown** + logged via `logger.error` |
| `SessInvalidSessionError` | `adoptServer()` invariant violation | **Thrown** + logged via `logger.error` |
| `SessAlreadyCreatedError` | `App.createActiveSession()` called twice | **Thrown** by App factory |
@ -692,7 +706,13 @@ import { createEngineSession } from '$sess';
import { createMemoryAdapter } from '$stor';
const sess = createEngineSession<User>({
schemas: { user: { '~standard': { /* ... */ } } },
schemas: {
user: {
'~standard': {
/* ... */
}
}
},
storage: { adapter: createMemoryAdapter(), key: 'sess' },
onRefresh: async () => null,
onRevoke: async () => true

@ -1,7 +1,10 @@
import {
AUTO_REFRESH_TIMER_KEY,
BROWSER_EVENT_VISIBILITY_CHANGE,
DEFAULT_AUTO_REFRESH_JITTER_MS,
DEFAULT_AUTO_REFRESH_MARGIN_MS,
DEFAULT_AUTO_REFRESH_TICK_MS
DEFAULT_AUTO_REFRESH_TICK_MS,
DOCUMENT_VISIBILITY_VISIBLE
} from './consts.ts';
import type { AutoRefreshCleanup, AutoRefreshOptions, EngineSession } from './types.ts';
@ -37,37 +40,51 @@ export function withAutoRefresh<TUser, TCredential = undefined, TData = undefine
const marginMs = options.marginMs ?? DEFAULT_AUTO_REFRESH_MARGIN_MS;
const jitterMs = options.jitterMs ?? DEFAULT_AUTO_REFRESH_JITTER_MS;
const refreshOnVisible = options.refreshOnVisible ?? true;
const timerKey = options.timerKey ?? AUTO_REFRESH_TIMER_KEY;
const now = options.now ?? Date.now;
const random = options.random ?? Math.random;
let stopped = false;
let timerId: ReturnType<typeof setInterval> | null = null;
let stopCurrentTicker: (() => void) | null = null;
function maybeRefresh(): void {
if (stopped) return;
const current = engine.current;
if (current === null) return;
const margin = jitterMs > 0 ? marginMs + Math.random() * jitterMs : marginMs;
const remaining = current.expiresAt - Date.now();
const margin = jitterMs > 0 ? marginMs + random() * jitterMs : marginMs;
const remaining = current.expiresAt - now();
if (remaining <= margin) {
void engine.refresh();
}
}
function startTicker(): void {
if (timerId !== null || stopped) return;
timerId = setInterval(maybeRefresh, tickMs);
if (stopCurrentTicker !== null || stopped) return;
if (options.timers !== undefined) {
const handle = options.timers.interval(timerKey, tickMs, maybeRefresh, {
replace: true,
awaitTask: false
});
stopCurrentTicker = () => {
handle.cancel();
};
} else {
const timerId = setInterval(maybeRefresh, tickMs);
(timerId as unknown as { unref?: () => void }).unref?.();
stopCurrentTicker = () => clearInterval(timerId);
}
maybeRefresh();
}
function stopTicker(): void {
if (timerId === null) return;
clearInterval(timerId);
timerId = null;
if (stopCurrentTicker === null) return;
stopCurrentTicker();
stopCurrentTicker = null;
}
function onVisibility(): void {
if (typeof document === 'undefined') return;
if (document.visibilityState === 'visible') {
if (document.visibilityState === DOCUMENT_VISIBILITY_VISIBLE) {
startTicker();
if (refreshOnVisible) maybeRefresh();
} else {
@ -76,8 +93,8 @@ export function withAutoRefresh<TUser, TCredential = undefined, TData = undefine
}
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', onVisibility);
if (document.visibilityState === 'visible') startTicker();
document.addEventListener(BROWSER_EVENT_VISIBILITY_CHANGE, onVisibility);
if (document.visibilityState === DOCUMENT_VISIBILITY_VISIBLE) startTicker();
} else {
startTicker();
}
@ -87,7 +104,7 @@ export function withAutoRefresh<TUser, TCredential = undefined, TData = undefine
stopped = true;
stopTicker();
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', onVisibility);
document.removeEventListener(BROWSER_EVENT_VISIBILITY_CHANGE, onVisibility);
}
};
}

@ -13,8 +13,19 @@ export const DEFAULT_AUTO_REFRESH_MARGIN_MS = 90_000;
/** Default jitter applied to the auto-refresh margin (ms). */
export const DEFAULT_AUTO_REFRESH_JITTER_MS = 5_000;
/** Default timer key used when auto-refresh is driven by App.Timers. */
export const AUTO_REFRESH_TIMER_KEY = 'sess:auto-refresh';
/** Browser events / document states used by auto-refresh and broadcast. */
export const BROWSER_EVENT_MESSAGE = 'message';
export const BROWSER_EVENT_VISIBILITY_CHANGE = 'visibilitychange';
export const DOCUMENT_VISIBILITY_VISIBLE = 'visible';
export const ENGINE_METHOD_ADOPT = 'adopt';
export const ENGINE_METHOD_ADOPT_SERVER = 'adoptServer';
export const ENGINE_METHOD_REFRESH = 'refresh';
export const ENGINE_METHOD_REVOKE = 'revoke';
export const ENGINE_METHOD_CLEAR_LOCAL = 'clearLocal';
/**
* Maximum safe Date timestamp — the canonical "never expires" sentinel for
@ -22,7 +33,6 @@ export const DEFAULT_AUTO_REFRESH_JITTER_MS = 5_000;
*/
export const SESSION_NEVER_EXPIRES = 8_640_000_000_000_000;
/**
* Discriminator on the broadcast payload. Receivers ignore foreign messages
* on the same channel by checking `payload.type === BROADCAST_TYPE`.
@ -94,14 +104,26 @@ export const ACTOR_KIND_UNKNOWN = 'unknown';
export const ACTOR_KIND_HUMAN = 'human';
export const ACTOR_KIND_AUTOMATED = 'automated';
export const ACTOR_SOURCE_USER_AGENT = 'user_agent';
export const ACTOR_SOURCE_CAPTCHA = 'captcha';
export const ACTOR_SOURCE_FINGERPRINT = 'fingerprint';
export const ACTOR_SOURCE_API_KEY = 'api_key';
export const ACTOR_SOURCE_SERVER_ASSERTION = 'server_assertion';
export const ACTOR_SOURCE_MANUAL = 'manual';
/**
* Tuple form for runtime checks (Set lookup) — kept in lockstep with the
* `SessionActorKind` type.
*/
export const ACTOR_KINDS = [
ACTOR_KIND_UNKNOWN,
ACTOR_KIND_HUMAN,
ACTOR_KIND_AUTOMATED
export const ACTOR_KINDS = [ACTOR_KIND_UNKNOWN, ACTOR_KIND_HUMAN, ACTOR_KIND_AUTOMATED] as const;
export const ACTOR_SOURCES = [
ACTOR_SOURCE_USER_AGENT,
ACTOR_SOURCE_CAPTCHA,
ACTOR_SOURCE_FINGERPRINT,
ACTOR_SOURCE_API_KEY,
ACTOR_SOURCE_SERVER_ASSERTION,
ACTOR_SOURCE_MANUAL
] as const;
// ── Session shape — property names ─────────────────────────────────────────
@ -122,11 +144,7 @@ export const FIELD_EXPIRES_AT = 'expiresAt';
* payload to be accepted as a `Session`. Used by both the engine's
* storage-hydration check and the SSR cookie reader.
*/
export const SESSION_REQUIRED_FIELDS = [
FIELD_USER,
FIELD_ISSUED_AT,
FIELD_EXPIRES_AT
] as const;
export const SESSION_REQUIRED_FIELDS = [FIELD_USER, FIELD_ISSUED_AT, FIELD_EXPIRES_AT] as const;
// ── http-integration: 401-retry guard ──────────────────────────────────────
//
@ -139,19 +157,15 @@ export const RETRY_MARKER_VALUE = '1';
// ── Log messages (centralised — no inline literals in the engine) ──────────
export const LOG_MSG_STORED_SESSION_PARSE_FAILED =
'Failed to parse stored session — clearing';
export const LOG_MSG_STORED_SESSION_PERSIST_FAILED =
'Failed to persist session to storage';
export const LOG_MSG_STORED_SESSION_PARSE_FAILED = 'Failed to parse stored session — clearing';
export const LOG_MSG_STORED_SESSION_PERSIST_FAILED = 'Failed to persist session to storage';
export const LOG_MSG_ADAPTER_NO_ONCHANGE_PREFIX = 'Storage adapter ';
export const LOG_MSG_ADAPTER_NO_ONCHANGE_SUFFIX =
' does not support onChange — cross-tab session sync is disabled. Wrap with withBroadcast(...) for multi-tab logout.';
export const LOG_MSG_LISTENER_THREW_PREFIX = 'Listener threw on ';
export const LOG_MSG_LISTENER_THREW_INITIAL = 'Listener threw on INITIAL_SESSION';
export const LOG_MSG_REFRESH_INVARIANT_VIOLATED_PREFIX =
'Refresh result violated invariant ';
export const LOG_MSG_REFRESH_INVARIANT_VIOLATED_SUFFIX =
'; preserving prior session';
export const LOG_MSG_REFRESH_INVARIANT_VIOLATED_PREFIX = 'Refresh result violated invariant ';
export const LOG_MSG_REFRESH_INVARIANT_VIOLATED_SUFFIX = '; preserving prior session';
export const LOG_MSG_REFRESH_USER_SCHEMA_FAILED =
'Refresh result failed user schema; preserving prior session';
export const LOG_MSG_REFRESH_CREDENTIAL_SCHEMA_FAILED =
@ -174,6 +188,9 @@ export const LOG_MSG_REVOKE_GLOBAL_HANDLER_FAILED =
// (`[http] ...`) so log output and stack traces are searchable.
export const ERROR_PREFIX = '[sess] ';
export const ERROR_NAME_DISPOSED = 'SessDisposedError';
export const ERROR_NAME_ALREADY_CREATED = 'SessAlreadyCreatedError';
export const ERROR_NAME_INVALID_SESSION = 'SessInvalidSessionError';
export const ERROR_MSG_DISPOSED_SUFFIX = '() called on a disposed engine';
export const ERROR_MSG_ADOPT_SERVER_PREFIX = 'adoptServer() rejected: invariant ';
@ -194,6 +211,14 @@ export function adapterNoOnChangeMessage(adapterName: string): string {
return `${LOG_MSG_ADAPTER_NO_ONCHANGE_PREFIX}"${adapterName}"${LOG_MSG_ADAPTER_NO_ONCHANGE_SUFFIX}`;
}
export function listenerThrewMessage(event: string): string {
return `${LOG_MSG_LISTENER_THREW_PREFIX}${event}`;
}
export function refreshInvariantViolatedMessage(invariant: string): string {
return `${LOG_MSG_REFRESH_INVARIANT_VIOLATED_PREFIX}${invariant}${LOG_MSG_REFRESH_INVARIANT_VIOLATED_SUFFIX}`;
}
/**
* Format the message thrown when `adoptServer()` is given a session that
* fails an engine invariant.

@ -5,8 +5,14 @@ import {
ACTOR_KINDS,
ADOPT_REASON_INVARIANT_FAILED,
ADOPT_REASON_VALIDATION_FAILED,
BROWSER_EVENT_MESSAGE,
BROADCAST_TYPE,
DEFAULT_BROADCAST_CHANNEL,
ENGINE_METHOD_ADOPT,
ENGINE_METHOD_ADOPT_SERVER,
ENGINE_METHOD_CLEAR_LOCAL,
ENGINE_METHOD_REFRESH,
ENGINE_METHOD_REVOKE,
EVENT_ADOPTED,
EVENT_ADOPTED_SERVER,
EVENT_EXPIRED,
@ -31,12 +37,9 @@ import {
INVARIANT_ISSUED_NOT_FINITE,
LOGGER_CATEGORY,
LOG_MSG_LISTENER_THREW_INITIAL,
LOG_MSG_LISTENER_THREW_PREFIX,
LOG_MSG_REFRESH_CREDENTIAL_SCHEMA_FAILED,
LOG_MSG_REFRESH_ACTOR_SCHEMA_FAILED,
LOG_MSG_REFRESH_DATA_SCHEMA_FAILED,
LOG_MSG_REFRESH_INVARIANT_VIOLATED_PREFIX,
LOG_MSG_REFRESH_INVARIANT_VIOLATED_SUFFIX,
LOG_MSG_REFRESH_TRANSIENT_FAILURE,
LOG_MSG_REFRESH_USER_SCHEMA_FAILED,
LOG_MSG_REVOKE_GLOBAL_HANDLER_FAILED,
@ -60,7 +63,9 @@ import {
SKIP_REASON_STALE_GENERATION,
adapterNoOnChangeMessage,
adoptServerErrorMessage,
disposedErrorMessage
disposedErrorMessage,
listenerThrewMessage,
refreshInvariantViolatedMessage
} from './consts.ts';
import { SessDisposedError, SessInvalidSessionError } from './errors.ts';
import type {
@ -116,7 +121,7 @@ export function createEngineSession<TUser, TCredential = undefined, TData = unde
const promoted = promoteStored<TUser, TCredential, TData>(parsed);
if (promoted !== null) snapshot = promoted;
} catch (err) {
logger?.warn(LOGGER_CATEGORY, LOG_MSG_STORED_SESSION_PARSE_FAILED, {
logger?.warn?.(LOGGER_CATEGORY, LOG_MSG_STORED_SESSION_PARSE_FAILED, {
error: toError(err)
});
storage.adapter.removeItem(storage.key);
@ -140,7 +145,7 @@ export function createEngineSession<TUser, TCredential = undefined, TData = unde
if (detachStorage !== undefined) detachers.push(detachStorage);
if (storage.adapter.onChange === undefined && logger !== undefined) {
logger.warn(LOGGER_CATEGORY, adapterNoOnChangeMessage(storage.adapter.name));
logger.warn?.(LOGGER_CATEGORY, adapterNoOnChangeMessage(storage.adapter.name));
}
}
@ -151,7 +156,7 @@ export function createEngineSession<TUser, TCredential = undefined, TData = unde
if (typeof BroadcastChannel !== 'undefined') {
try {
channel = new BroadcastChannel(channelName);
channel.addEventListener('message', (msg: MessageEvent) => {
channel.addEventListener(BROWSER_EVENT_MESSAGE, (msg: MessageEvent) => {
const data = msg.data as { type?: string };
if (data?.type !== BROADCAST_TYPE) return;
if (storage === undefined) return;
@ -217,7 +222,7 @@ export function createEngineSession<TUser, TCredential = undefined, TData = unde
storage.adapter.setItem(storage.key, JSON.stringify(value));
}
} catch (err) {
logger?.warn(LOGGER_CATEGORY, LOG_MSG_STORED_SESSION_PERSIST_FAILED, {
logger?.warn?.(LOGGER_CATEGORY, LOG_MSG_STORED_SESSION_PERSIST_FAILED, {
error: toError(err)
});
}
@ -228,7 +233,7 @@ export function createEngineSession<TUser, TCredential = undefined, TData = unde
try {
listener(change);
} catch (err) {
logger?.warn(LOGGER_CATEGORY, `${LOG_MSG_LISTENER_THREW_PREFIX}${change.event}`, {
logger?.warn?.(LOGGER_CATEGORY, listenerThrewMessage(change.event), {
error: toError(err)
});
}
@ -283,10 +288,7 @@ export function createEngineSession<TUser, TCredential = undefined, TData = unde
}
function isValidActorKind(value: unknown): value is SessionActorKind {
return (
typeof value === 'string' &&
(ACTOR_KINDS as ReadonlyArray<string>).includes(value)
);
return typeof value === 'string' && (ACTOR_KINDS as ReadonlyArray<string>).includes(value);
}
function commitAdoption(
@ -334,7 +336,7 @@ export function createEngineSession<TUser, TCredential = undefined, TData = unde
function ensureLive(method: string): void {
if (disposed) {
const message = disposedErrorMessage(method);
logger?.error(LOGGER_CATEGORY, message);
logger?.error?.(LOGGER_CATEGORY, message);
throw new SessDisposedError(message);
}
}
@ -351,7 +353,7 @@ export function createEngineSession<TUser, TCredential = undefined, TData = unde
},
async adopt(input) {
ensureLive('adopt');
ensureLive(ENGINE_METHOD_ADOPT);
const invariant = checkInvariants(input);
if (invariant !== null) {
@ -430,11 +432,11 @@ export function createEngineSession<TUser, TCredential = undefined, TData = unde
},
adoptServer(input) {
ensureLive('adoptServer');
ensureLive(ENGINE_METHOD_ADOPT_SERVER);
const invariant = checkInvariants(input);
if (invariant !== null) {
const message = adoptServerErrorMessage(invariant);
logger?.error(LOGGER_CATEGORY, message);
logger?.error?.(LOGGER_CATEGORY, message);
throw new SessInvalidSessionError(invariant, message);
}
const next = freezeSession<TUser, TCredential, TData>({
@ -450,7 +452,7 @@ export function createEngineSession<TUser, TCredential = undefined, TData = unde
},
async refresh() {
ensureLive('refresh');
ensureLive(ENGINE_METHOD_REFRESH);
if (refreshing !== null) return refreshing;
if (snapshot === null) {
@ -481,10 +483,7 @@ export function createEngineSession<TUser, TCredential = undefined, TData = unde
const invariant = checkInvariants(next);
if (invariant !== null) {
logger?.warn(
LOGGER_CATEGORY,
`${LOG_MSG_REFRESH_INVARIANT_VIOLATED_PREFIX}${invariant}${LOG_MSG_REFRESH_INVARIANT_VIOLATED_SUFFIX}`
);
logger?.warn?.(LOGGER_CATEGORY, refreshInvariantViolatedMessage(invariant));
return {
status: REFRESH_STATUS_FAILED,
error: new TypeError(invariant),
@ -497,7 +496,7 @@ export function createEngineSession<TUser, TCredential = undefined, TData = unde
? { ok: true as const, value: null as TUser | null }
: await validateField(schemas?.user, next.user);
if (!userResult.ok) {
logger?.warn(LOGGER_CATEGORY, LOG_MSG_REFRESH_USER_SCHEMA_FAILED);
logger?.warn?.(LOGGER_CATEGORY, LOG_MSG_REFRESH_USER_SCHEMA_FAILED);
return {
status: REFRESH_STATUS_FAILED,
error: userResult.issues,
@ -510,7 +509,7 @@ export function createEngineSession<TUser, TCredential = undefined, TData = unde
? await validateField(schemas?.credential, nextCredential)
: { ok: true as const, value: undefined as TCredential };
if (!credentialResult.ok) {
logger?.warn(LOGGER_CATEGORY, LOG_MSG_REFRESH_CREDENTIAL_SCHEMA_FAILED);
logger?.warn?.(LOGGER_CATEGORY, LOG_MSG_REFRESH_CREDENTIAL_SCHEMA_FAILED);
return {
status: REFRESH_STATUS_FAILED,
error: credentialResult.issues,
@ -523,7 +522,7 @@ export function createEngineSession<TUser, TCredential = undefined, TData = unde
? await validateField(schemas?.data, nextData)
: { ok: true as const, value: undefined as TData };
if (!dataResult.ok) {
logger?.warn(LOGGER_CATEGORY, LOG_MSG_REFRESH_DATA_SCHEMA_FAILED);
logger?.warn?.(LOGGER_CATEGORY, LOG_MSG_REFRESH_DATA_SCHEMA_FAILED);
return {
status: REFRESH_STATUS_FAILED,
error: dataResult.issues,
@ -536,7 +535,7 @@ export function createEngineSession<TUser, TCredential = undefined, TData = unde
? await validateField(schemas?.actor, nextActor)
: { ok: true as const, value: undefined as SessionActor | undefined };
if (!actorResult.ok) {
logger?.warn(LOGGER_CATEGORY, LOG_MSG_REFRESH_ACTOR_SCHEMA_FAILED);
logger?.warn?.(LOGGER_CATEGORY, LOG_MSG_REFRESH_ACTOR_SCHEMA_FAILED);
return {
status: REFRESH_STATUS_FAILED,
error: actorResult.issues,
@ -568,7 +567,7 @@ export function createEngineSession<TUser, TCredential = undefined, TData = unde
reason: SKIP_REASON_STALE_GENERATION
};
}
logger?.warn(LOGGER_CATEGORY, LOG_MSG_REFRESH_TRANSIENT_FAILURE, {
logger?.warn?.(LOGGER_CATEGORY, LOG_MSG_REFRESH_TRANSIENT_FAILURE, {
error: toError(err)
});
dispatch({
@ -592,7 +591,7 @@ export function createEngineSession<TUser, TCredential = undefined, TData = unde
},
async revoke(opts: RevokeOptions = {}) {
ensureLive('revoke');
ensureLive(ENGINE_METHOD_REVOKE);
if (snapshot === null) {
return {
@ -607,8 +606,7 @@ export function createEngineSession<TUser, TCredential = undefined, TData = unde
// cookie-auth apps almost always want the server-side cookie
// cleared along with the local snapshot.
const requestedScope =
opts.scope ??
(onRevoke !== undefined ? REVOKE_SCOPE_GLOBAL : REVOKE_SCOPE_LOCAL);
opts.scope ?? (onRevoke !== undefined ? REVOKE_SCOPE_GLOBAL : REVOKE_SCOPE_LOCAL);
let result: RevokeResult;
if (requestedScope === REVOKE_SCOPE_GLOBAL) {
@ -619,7 +617,7 @@ export function createEngineSession<TUser, TCredential = undefined, TData = unde
scope: REVOKE_SCOPE_LOCAL,
reason: REVOKE_REASON_MISSING_REVOKE_URL
};
logger?.warn(LOGGER_CATEGORY, LOG_MSG_REVOKE_GLOBAL_NO_HANDLER);
logger?.warn?.(LOGGER_CATEGORY, LOG_MSG_REVOKE_GLOBAL_NO_HANDLER);
} else {
try {
const ok = await onRevoke(snapshot, { logger });
@ -636,7 +634,7 @@ export function createEngineSession<TUser, TCredential = undefined, TData = unde
scope: REVOKE_SCOPE_LOCAL,
reason: REVOKE_REASON_SERVER_REJECTED
};
logger?.warn(LOGGER_CATEGORY, LOG_MSG_REVOKE_GLOBAL_SERVER_REJECTED);
logger?.warn?.(LOGGER_CATEGORY, LOG_MSG_REVOKE_GLOBAL_SERVER_REJECTED);
}
} catch (err) {
result = {
@ -645,7 +643,7 @@ export function createEngineSession<TUser, TCredential = undefined, TData = unde
scope: REVOKE_SCOPE_LOCAL,
reason: REVOKE_REASON_NETWORK_ERROR
};
logger?.warn(LOGGER_CATEGORY, LOG_MSG_REVOKE_GLOBAL_HANDLER_FAILED, {
logger?.warn?.(LOGGER_CATEGORY, LOG_MSG_REVOKE_GLOBAL_HANDLER_FAILED, {
error: toError(err)
});
}
@ -663,7 +661,7 @@ export function createEngineSession<TUser, TCredential = undefined, TData = unde
},
clearLocal() {
ensureLive('clearLocal');
ensureLive(ENGINE_METHOD_CLEAR_LOCAL);
const result: RevokeResult =
snapshot === null
? {
@ -694,7 +692,7 @@ export function createEngineSession<TUser, TCredential = undefined, TData = unde
identity: { from: identity, to: identity }
});
} catch (err) {
logger?.warn(LOGGER_CATEGORY, LOG_MSG_LISTENER_THREW_INITIAL, {
logger?.warn?.(LOGGER_CATEGORY, LOG_MSG_LISTENER_THREW_INITIAL, {
error: toError(err)
});
}

@ -8,6 +8,12 @@
* worker boundaries and is stable to test against.
*/
import {
ERROR_NAME_ALREADY_CREATED,
ERROR_NAME_DISPOSED,
ERROR_NAME_INVALID_SESSION
} from './consts.ts';
abstract class SessEngineError extends Error {
abstract readonly name: string;
constructor(message: string, options?: { cause?: unknown }) {
@ -21,7 +27,7 @@ abstract class SessEngineError extends Error {
* paths don't cascade.
*/
export class SessDisposedError extends SessEngineError {
readonly name = 'SessDisposedError' as const;
readonly name = ERROR_NAME_DISPOSED;
}
/**
@ -30,7 +36,7 @@ export class SessDisposedError extends SessEngineError {
* scope (would compose multiple App instances).
*/
export class SessAlreadyCreatedError extends SessEngineError {
readonly name = 'SessAlreadyCreatedError' as const;
readonly name = ERROR_NAME_ALREADY_CREATED;
}
/**
@ -42,7 +48,7 @@ export class SessAlreadyCreatedError extends SessEngineError {
* silently accepted.
*/
export class SessInvalidSessionError extends SessEngineError {
readonly name = 'SessInvalidSessionError' as const;
readonly name = ERROR_NAME_INVALID_SESSION;
readonly invariant: string;
constructor(invariant: string, message: string) {
super(message);
@ -53,17 +59,13 @@ export class SessInvalidSessionError extends SessEngineError {
// ── Type guards ─────────────────────────────────────────────────────────────
export function isSessDisposedError(value: unknown): value is SessDisposedError {
return value instanceof Error && (value as { name?: string }).name === 'SessDisposedError';
return value instanceof Error && (value as { name?: string }).name === ERROR_NAME_DISPOSED;
}
export function isSessAlreadyCreatedError(value: unknown): value is SessAlreadyCreatedError {
return (
value instanceof Error && (value as { name?: string }).name === 'SessAlreadyCreatedError'
);
return value instanceof Error && (value as { name?: string }).name === ERROR_NAME_ALREADY_CREATED;
}
export function isSessInvalidSessionError(value: unknown): value is SessInvalidSessionError {
return (
value instanceof Error && (value as { name?: string }).name === 'SessInvalidSessionError'
);
return value instanceof Error && (value as { name?: string }).name === ERROR_NAME_INVALID_SESSION;
}

@ -1,5 +1,6 @@
import type { BeforeErrorHook, HookRequest } from '$http';
import { RETRY_MARKER_HEADER, RETRY_MARKER_VALUE } from './consts.ts';
import { HTTP_STATUS_UNAUTHORIZED } from '$libs/http';
import { REFRESH_STATUS_REFRESHED, RETRY_MARKER_HEADER, RETRY_MARKER_VALUE } from './consts.ts';
import type { EngineSession, Session } from './types.ts';
/**
@ -56,11 +57,11 @@ export function createBeforeErrorHook<TUser, TCredential = undefined, TData = un
options: BeforeErrorHookOptions<TUser, TCredential, TData> = {}
): BeforeErrorHook {
return async ({ response, request, url, init }) => {
if (response?.status !== 401) return;
if (response?.status !== HTTP_STATUS_UNAUTHORIZED) return;
if (request.headers.get(RETRY_MARKER_HEADER) === RETRY_MARKER_VALUE) return;
const result = await engine.refresh();
if (result.status !== 'refreshed') return;
if (result.status !== REFRESH_STATUS_REFRESHED) return;
options.applyAuth?.(request, result.session);
request.headers.set(RETRY_MARKER_HEADER, RETRY_MARKER_VALUE);

@ -1,34 +1 @@
/**
* Decode a JWT's `exp` claim and return it as **epoch milliseconds**, or
* `null` when the token is malformed / missing the claim. **No signature
* verification, no claim validation** — trust this only for scheduling
* (compute when to refresh), never for authorization decisions.
*
* Opt-in helper, separate file. The artifact does not bundle a JWT
* library; consumers that store opaque tokens never reach this code.
*
* @example
* import { extractJwtExp } from '$sess/jwt';
*
* await Sess.adopt({
* user,
* credential: { accessToken, refreshToken },
* issuedAt: Date.now(),
* expiresAt: extractJwtExp(accessToken) ?? Date.now() + 3_600_000
* });
*/
export function extractJwtExp(token: string): number | null {
const parts = token.split('.');
if (parts.length !== 3) return null;
try {
const payload = parts[1];
const padded = payload + '='.repeat((4 - (payload.length % 4)) % 4);
const json = atob(padded.replace(/-/g, '+').replace(/_/g, '/'));
const claims = JSON.parse(json) as { exp?: unknown };
if (typeof claims.exp === 'number') return claims.exp * 1000;
return null;
} catch {
return null;
}
}
export { extractJwtExp } from '$libs/auth/jwt';

@ -28,12 +28,17 @@
*/
import type { StandardSchemaV1 } from '$libs/standard-schema';
import type { EngineLogger } from '$logr';
import type { SyncStorageAdapter } from '$stor';
import type {
ACTOR_KIND_AUTOMATED,
ACTOR_KIND_HUMAN,
ACTOR_KIND_UNKNOWN,
ACTOR_SOURCE_API_KEY,
ACTOR_SOURCE_CAPTCHA,
ACTOR_SOURCE_FINGERPRINT,
ACTOR_SOURCE_MANUAL,
ACTOR_SOURCE_SERVER_ASSERTION,
ACTOR_SOURCE_USER_AGENT,
ADOPT_REASON_INVARIANT_FAILED,
ADOPT_REASON_VALIDATION_FAILED,
EVENT_ADOPTED,
@ -75,7 +80,6 @@ import type {
// SESSION
// ============================================================================
/**
* Conditional credential slot. Wrapped in `[T]` to suppress union
* distribution: `string | undefined` must NOT be treated as
@ -118,12 +122,12 @@ export type SessionActorKind =
* provenance.
*/
export type SessionActorSource =
| 'user_agent'
| 'captcha'
| 'fingerprint'
| 'api_key'
| 'server_assertion'
| 'manual'
| typeof ACTOR_SOURCE_USER_AGENT
| typeof ACTOR_SOURCE_CAPTCHA
| typeof ACTOR_SOURCE_FINGERPRINT
| typeof ACTOR_SOURCE_API_KEY
| typeof ACTOR_SOURCE_SERVER_ASSERTION
| typeof ACTOR_SOURCE_MANUAL
| (string & {});
/**
@ -246,13 +250,22 @@ export type SessionListener<TUser, TCredential = undefined, TData = undefined> =
// REFRESH
// ============================================================================
/**
* Minimal logger shape consumed by `sess`. Deliberately structural so
* `EngineLogger` from `$logr`, a test spy, or any compatible app logger
* can be injected without making `sess` depend on the logger artifact.
*/
export interface SessionLogger {
warn?(category: string, message: string, input?: unknown): void;
error?(category: string, message: string, input?: unknown): void;
}
/**
* Context passed to the consumer's refresh function. Exposes engine
* primitives without forcing the callback to capture App by closure.
*/
export interface RefreshContext {
readonly logger?: EngineLogger;
readonly signal?: AbortSignal;
readonly logger?: SessionLogger;
}
/**
@ -283,11 +296,10 @@ export type RefreshFn<TUser, TCredential = undefined, TData = undefined> = (
/**
* Context passed to `onRevoke` so the consumer can log through the engine
* logger and short-circuit on cancellation. Mirrors `RefreshContext`.
* logger. Mirrors `RefreshContext`.
*/
export interface RevokeContext {
readonly logger?: EngineLogger;
readonly signal?: AbortSignal;
readonly logger?: SessionLogger;
}
/**
@ -426,10 +438,36 @@ export interface AutoRefreshOptions {
jitterMs?: number;
/** Refresh on `visibilitychange` to `visible`. Default: `true`. */
refreshOnVisible?: boolean;
/**
* Optional scheduler compatible with `arts/timr`. When provided,
* auto-refresh uses a keyed interval instead of native `setInterval`,
* so app-level disposal/debug panels see the timer.
*/
timers?: AutoRefreshScheduler;
/** Timer key used when `timers` is provided. Default: `sess:auto-refresh`. */
timerKey?: string;
/** Injectable clock for deterministic tests. Default: `Date.now`. */
now?: () => number;
/** Injectable random source for deterministic jitter. Default: `Math.random`. */
random?: () => number;
}
export type AutoRefreshCleanup = () => void;
/**
* Minimal `arts/timr`-compatible scheduler. Kept structural to avoid a hard
* import cycle and to make tests/fakes cheap.
*/
export interface AutoRefreshScheduler {
interval(
key: string,
everyMs: number,
task: () => void | Promise<void>,
options?: { readonly replace?: boolean; readonly awaitTask?: boolean }
): { cancel(): boolean };
cancel(key: string): boolean;
}
// ============================================================================
// ENGINE OPTIONS
// ============================================================================
@ -446,7 +484,7 @@ export interface EngineSessionOptions<TUser, TCredential = undefined, TData = un
* consumer's call.
*/
onRevoke?: RevokeFn<TUser, TCredential, TData>;
logger?: EngineLogger;
logger?: SessionLogger;
broadcastChannel?: string;
}
@ -465,9 +503,7 @@ export interface EngineSession<TUser, TCredential = undefined, TData = undefined
session: Session<TUser, TCredential, TData>
): Promise<AdoptResult<TUser, TCredential, TData>>;
adoptServer(
session: Session<TUser, TCredential, TData>
): Session<TUser, TCredential, TData>;
adoptServer(session: Session<TUser, TCredential, TData>): Session<TUser, TCredential, TData>;
refresh(): Promise<RefreshResult<TUser, TCredential, TData>>;

@ -1,4 +1,5 @@
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { ENTRY_CHANGE_SOURCE_EXTERNAL } from './consts';
import { _engineBuses, _entryBusKeys, createEngineStorage } from './engine-storage';
import type {
ActiveStorage,
@ -48,7 +49,7 @@ export function createActiveStorage(options: EngineStorageOptions = {}): ActiveS
// and external changes (cross-tab). Re-read through the engine so
// envelope/migration/serializer logic stays in one place.
const detachBus = bus.subscribe(busKey, (_raw, source) => {
if (source === 'external' && entryOptions.syncTabs === false) return;
if (source === ENTRY_CHANGE_SOURCE_EXTERNAL && entryOptions.syncTabs === false) return;
const prev = current;
const next = base.get();
if (Object.is(prev, next)) return;

@ -1,4 +1,9 @@
import type { SyncStorageAdapter } from '../types';
import {
BROWSER_EVENT_MESSAGE,
DEFAULT_BROADCAST_CHANNEL,
STORAGE_ADAPTER_BROADCAST_SUFFIX
} from '../consts';
/**
* Options for `withBroadcast()`.
@ -74,12 +79,12 @@ export function withBroadcast(
inner: SyncStorageAdapter,
options: BroadcastOptions = {}
): BroadcastAdapter {
const channelName = options.channel ?? 'stor';
const channelName = options.channel ?? DEFAULT_BROADCAST_CHANNEL;
const bc: BroadcastChannel | null = isSupported ? new BroadcastChannel(channelName) : null;
let closed = false;
return {
name: `${inner.name}+broadcast`,
name: `${inner.name}${STORAGE_ADAPTER_BROADCAST_SUFFIX}`,
getItem: (key) => inner.getItem(key),
setItem: (key, value) => {
inner.setItem(key, value);
@ -94,8 +99,8 @@ export function withBroadcast(
const listener = (event: MessageEvent<BroadcastMessage>): void => {
fn(event.data.key, event.data.value);
};
bc.addEventListener('message', listener);
return () => bc.removeEventListener('message', listener);
bc.addEventListener(BROWSER_EVENT_MESSAGE, listener);
return () => bc.removeEventListener(BROWSER_EVENT_MESSAGE, listener);
}
: undefined,
close: () => {

@ -1,5 +1,6 @@
import type { SyncStorageAdapter } from '../types';
import { STORAGE_ERRORS } from '../errors';
import { STORAGE_ADAPTER_COOKIE } from '../consts';
/**
* Cookie attributes applied on every `setItem`. Match the standard
@ -88,7 +89,7 @@ function clientCookieAdapter(options: CookieAdapterOptions = {}): SyncStorageAda
const decode = options.decode ?? decodeURIComponent;
return {
name: 'cookie',
name: STORAGE_ADAPTER_COOKIE,
getItem(key) {
if (!isBrowser) return null;
const jar = parseDocumentCookies(document.cookie);
@ -123,7 +124,7 @@ function serverCookieAdapter(
// Same name as the client variant — from the consumer's perspective
// both are "cookie storage", and `:` would clash with the namespace
// separator in logs. Distinguish via the call site, not via name.
name: 'cookie',
name: STORAGE_ADAPTER_COOKIE,
getItem(key) {
const v = cookies.get(key);
return v === undefined ? null : v;

@ -1,4 +1,5 @@
import type { SyncStorageAdapter } from '../types';
import { BROWSER_EVENT_STORAGE, STORAGE_ADAPTER_LOCAL } from '../consts';
function getLocalStorage(): Storage | null {
if (typeof window === 'undefined') return null;
@ -18,7 +19,7 @@ function getLocalStorage(): Storage | null {
* so consumers fall back to defaults.
*/
export const localAdapter: SyncStorageAdapter = {
name: 'local',
name: STORAGE_ADAPTER_LOCAL,
getItem(key) {
return getLocalStorage()?.getItem(key) ?? null;
},
@ -45,7 +46,7 @@ export const localAdapter: SyncStorageAdapter = {
if (event.key === null) return;
fn(event.key, event.newValue);
};
window.addEventListener('storage', listener);
return () => window.removeEventListener('storage', listener);
window.addEventListener(BROWSER_EVENT_STORAGE, listener);
return () => window.removeEventListener(BROWSER_EVENT_STORAGE, listener);
}
};

@ -1,4 +1,5 @@
import type { SyncStorageAdapter } from '../types';
import { STORAGE_ADAPTER_MEMORY } from '../consts';
/**
* Build an in-memory `SyncStorageAdapter` backed by a private `Map`.
@ -19,7 +20,7 @@ import type { SyncStorageAdapter } from '../types';
export function createMemoryAdapter(seed?: Record<string, string>): SyncStorageAdapter {
const store = new Map<string, string>(seed ? Object.entries(seed) : undefined);
return {
name: 'memory',
name: STORAGE_ADAPTER_MEMORY,
getItem: (key) => (store.has(key) ? store.get(key)! : null),
setItem: (key, value) => {
store.set(key, value);

@ -1,4 +1,5 @@
import type { SyncStorageAdapter } from '../types';
import { STORAGE_ADAPTER_SESSION } from '../consts';
function getSessionStorage(): Storage | null {
if (typeof window === 'undefined') return null;
@ -14,7 +15,7 @@ function getSessionStorage(): Storage | null {
* SSR-safe: every method no-ops outside the browser; reads return `null`.
*/
export const sessionAdapter: SyncStorageAdapter = {
name: 'session',
name: STORAGE_ADAPTER_SESSION,
getItem(key) {
return getSessionStorage()?.getItem(key) ?? null;
},

@ -5,6 +5,42 @@
*/
export const LOGGER_CATEGORY = 'storage';
export const STORAGE_OP_READ = 'read';
export const STORAGE_OP_WRITE = 'write';
export const STORAGE_OP_REMOVE = 'remove';
export const STORAGE_OP_SERIALIZE = 'serialize';
export const STORAGE_OP_DESERIALIZE = 'deserialize';
export const STORAGE_OP_MIGRATE = 'migrate';
export const STORAGE_OP_VALIDATE = 'validate';
export const STORAGE_OPS = [
STORAGE_OP_READ,
STORAGE_OP_WRITE,
STORAGE_OP_REMOVE,
STORAGE_OP_SERIALIZE,
STORAGE_OP_DESERIALIZE,
STORAGE_OP_MIGRATE,
STORAGE_OP_VALIDATE
] as const;
export const ENTRY_CHANGE_SOURCE_LOCAL = 'local';
export const ENTRY_CHANGE_SOURCE_EXTERNAL = 'external';
export const ENTRY_CHANGE_SOURCES = [
ENTRY_CHANGE_SOURCE_LOCAL,
ENTRY_CHANGE_SOURCE_EXTERNAL
] as const;
export const STORAGE_ADAPTER_LOCAL = 'local';
export const STORAGE_ADAPTER_SESSION = 'session';
export const STORAGE_ADAPTER_MEMORY = 'memory';
export const STORAGE_ADAPTER_COOKIE = 'cookie';
export const STORAGE_ADAPTER_BROADCAST_SUFFIX = '+broadcast';
export const DEFAULT_BROADCAST_CHANNEL = 'stor';
export const BROWSER_EVENT_STORAGE = 'storage';
export const BROWSER_EVENT_MESSAGE = 'message';
/**
* Envelope shape — the JSON-serialized object stored when `raw: false`.
*
@ -28,3 +64,8 @@ export const DEFAULT_VERSION = 1;
/** Namespace separator used to compose `<namespace><sep><key>` into `fullKey`. */
export const NAMESPACE_SEPARATOR = ':';
export const ENVELOPE_ERROR_VERSION_NOT_NUMBER = 'envelope.v not a number';
export const defaultStorageErrorMessage = (op: string, fullKey: string): string =>
`[${LOGGER_CATEGORY}] ${op} on "${fullKey}":`;

@ -1,5 +1,18 @@
import { createMemoryAdapter } from './adapters/memory';
import { LOGGER_CATEGORY, NAMESPACE_SEPARATOR, DEFAULT_VERSION } from './consts';
import {
DEFAULT_VERSION,
ENTRY_CHANGE_SOURCE_EXTERNAL,
ENTRY_CHANGE_SOURCE_LOCAL,
NAMESPACE_SEPARATOR,
STORAGE_OP_DESERIALIZE,
STORAGE_OP_MIGRATE,
STORAGE_OP_READ,
STORAGE_OP_REMOVE,
STORAGE_OP_SERIALIZE,
STORAGE_OP_VALIDATE,
STORAGE_OP_WRITE,
defaultStorageErrorMessage
} from './consts';
import { decodeEnvelope, encodeEnvelope } from './envelope';
import { STORAGE_ERRORS } from './errors';
import { autoSelectSerializer } from './serializers';
@ -18,7 +31,7 @@ import type {
import { isPromiseLike, type StandardSchemaV1 } from '$libs/standard-schema';
const defaultOnError: StorageErrorHandler = (ctx) => {
console.error(`[${LOGGER_CATEGORY}] ${ctx.op} on "${ctx.fullKey}":`, ctx.error);
console.error(defaultStorageErrorMessage(ctx.op, ctx.fullKey), ctx.error);
};
function isStandardSchema<T>(v: StorageValidator<T>): v is StandardSchemaV1<unknown, T> {
@ -88,7 +101,7 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS
const id = getAdapterId(target);
if (adapterDisposers.has(id)) return;
const detach = target.onChange((changedKey, newValue) => {
bus.publish(busKeyFor(target, changedKey), newValue, 'external');
bus.publish(busKeyFor(target, changedKey), newValue, ENTRY_CHANGE_SOURCE_EXTERNAL);
});
adapterDisposers.set(id, detach);
}
@ -143,7 +156,7 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS
{
key,
fullKey,
op: 'read',
op: STORAGE_OP_READ,
error: new Error(STORAGE_ERRORS.ENTRY_DEFAULTS_MISMATCH(key))
},
entryAdapter.name
@ -177,7 +190,7 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS
throw new TypeError(STORAGE_ERRORS.ASYNC_VALIDATE_UNSUPPORTED);
}
if ('issues' in result && result.issues !== undefined) {
throw new Error(`Standard Schema validation failed: ${JSON.stringify(result.issues)}`);
throw new Error(STORAGE_ERRORS.STANDARD_SCHEMA_VALIDATION_FAILED(result.issues));
}
return (result as { value: T }).value;
}
@ -206,10 +219,10 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS
try {
entryAdapter.setItem(fullKey, rawString);
} catch (error) {
report({ key, fullKey, op: 'write', error }, entryAdapter.name);
report({ key, fullKey, op: STORAGE_OP_WRITE, error }, entryAdapter.name);
return;
}
bus.publish(busKey, rawString, 'local');
bus.publish(busKey, rawString, ENTRY_CHANGE_SOURCE_LOCAL);
}
function writeValue(value: T): void {
@ -217,7 +230,7 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS
try {
serialized = serializer.stringify(value);
} catch (error) {
report({ key, fullKey, op: 'serialize', error }, entryAdapter.name);
report({ key, fullKey, op: STORAGE_OP_SERIALIZE, error }, entryAdapter.name);
return;
}
const payload = isRaw ? serialized : encodeEnvelope(serialized, version, ttlMs);
@ -230,14 +243,14 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS
try {
migrated = entryOptions.migrate(prev, fromVersion);
} catch (error) {
report({ key, fullKey, op: 'migrate', error }, entryAdapter.name);
report({ key, fullKey, op: STORAGE_OP_MIGRATE, error }, entryAdapter.name);
return resolveDefault();
}
let validated: T;
try {
validated = runValidate(migrated);
} catch (error) {
report({ key, fullKey, op: 'validate', error }, entryAdapter.name);
report({ key, fullKey, op: STORAGE_OP_VALIDATE, error }, entryAdapter.name);
return resolveDefault();
}
if (writeMigrated) writeValue(validated);
@ -250,7 +263,7 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS
const parsed = serializer.parse(rawString);
return runValidate(parsed);
} catch (error) {
report({ key, fullKey, op: 'deserialize', error }, entryAdapter.name);
report({ key, fullKey, op: STORAGE_OP_DESERIALIZE, error }, entryAdapter.name);
return resolveDefault();
}
}
@ -266,7 +279,7 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS
const validated = runValidate(parsed);
return applyMergeDefaults(validated);
} catch (error) {
report({ key, fullKey, op: 'deserialize', error }, entryAdapter.name);
report({ key, fullKey, op: STORAGE_OP_DESERIALIZE, error }, entryAdapter.name);
return resolveDefault();
}
}
@ -275,7 +288,10 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS
case 'legacy':
return migrateAndReturn(decoded.raw, 0);
case 'invalid':
report({ key, fullKey, op: 'deserialize', error: decoded.error }, entryAdapter.name);
report(
{ key, fullKey, op: STORAGE_OP_DESERIALIZE, error: decoded.error },
entryAdapter.name
);
return resolveDefault();
}
}
@ -285,7 +301,7 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS
try {
stored = entryAdapter.getItem(fullKey);
} catch (error) {
report({ key, fullKey, op: 'read', error }, entryAdapter.name);
report({ key, fullKey, op: STORAGE_OP_READ, error }, entryAdapter.name);
return resolveDefault();
}
if (stored === null) return resolveDefault();
@ -359,10 +375,10 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS
try {
entryAdapter.removeItem(fullKey);
} catch (error) {
report({ key, fullKey, op: 'remove', error }, entryAdapter.name);
report({ key, fullKey, op: STORAGE_OP_REMOVE, error }, entryAdapter.name);
return;
}
bus.publish(busKey, null, 'local');
bus.publish(busKey, null, ENTRY_CHANGE_SOURCE_LOCAL);
},
reset() {
writeValue(resolveDefault());
@ -394,10 +410,10 @@ export function createEngineStorage(options: EngineStorageOptions = {}): EngineS
try {
target.removeItem(fullKey);
} catch (error) {
report({ key, fullKey, op: 'remove', error }, target.name);
report({ key, fullKey, op: STORAGE_OP_REMOVE, error }, target.name);
continue;
}
bus.publish(busKey, null, 'local');
bus.publish(busKey, null, ENTRY_CHANGE_SOURCE_LOCAL);
}
}

@ -1,3 +1,5 @@
import type { ENTRY_CHANGE_SOURCES } from './consts';
/**
* Source of a published change. Local writes (`set`, `update`, `remove`,
* `reset` from any entry inside this process) publish as `'local'`; values
@ -7,7 +9,7 @@
* Entries that need to react asymmetrically (e.g. avoid re-writing on
* external changes to prevent loops) read `source` from the listener.
*/
export type ChangeSource = 'local' | 'external';
export type ChangeSource = (typeof ENTRY_CHANGE_SOURCES)[number];
export type EntryListener = (raw: string | null, source: ChangeSource) => void;

@ -1,5 +1,6 @@
import {
DEFAULT_VERSION,
ENVELOPE_ERROR_VERSION_NOT_NUMBER,
ENVELOPE_KEY_DATA,
ENVELOPE_KEY_TTL,
ENVELOPE_KEY_VERSION
@ -39,7 +40,7 @@ export function decodeEnvelope(stored: string, now: number = Date.now()): Envelo
let parsed: unknown;
try {
parsed = JSON.parse(stored);
} catch (error) {
} catch {
// Not JSON — could be a legacy raw value like `forest` (no quotes).
return { kind: 'legacy', raw: stored };
}
@ -54,7 +55,11 @@ export function decodeEnvelope(stored: string, now: number = Date.now()): Envelo
const env = parsed as Record<string, unknown>;
const version = env[ENVELOPE_KEY_VERSION];
if (typeof version !== 'number') {
return { kind: 'invalid', raw: stored, error: new TypeError('envelope.v not a number') };
return {
kind: 'invalid',
raw: stored,
error: new TypeError(ENVELOPE_ERROR_VERSION_NOT_NUMBER)
};
}
const expiresAt = env[ENVELOPE_KEY_TTL];
if (typeof expiresAt === 'number' && expiresAt <= now) {

@ -15,5 +15,17 @@ export const STORAGE_ERRORS = {
'[storage] frontend.persist was requested but no persistent storage adapter is configured. Values will reset on reload. Configure createActiveApp({ storage: { adapter: localAdapter } }) or pass an adapter via persist.adapter.',
COOKIE_SERVER_REQUIRED:
'[storage] cookieAdapter() runs in the browser only. For SSR use cookieAdapter.fromCookies(event.cookies, options).'
'[storage] cookieAdapter() runs in the browser only. For SSR use cookieAdapter.fromCookies(event.cookies, options).',
NUMBER_SERIALIZER_INVALID: (value: string): string =>
`[storage] numberSerializer could not parse "${value}" as a number.`,
BOOLEAN_SERIALIZER_INVALID: (value: string): string =>
`[storage] booleanSerializer could not parse "${value}" as a boolean.`,
DATE_SERIALIZER_INVALID: (value: string): string =>
`[storage] dateSerializer could not parse "${value}" as a date.`,
STANDARD_SCHEMA_VALIDATION_FAILED: (issues: unknown): string =>
`[storage] Standard Schema validation failed: ${JSON.stringify(issues)}`
} as const;

@ -1,4 +1,5 @@
import type { StorageSerializer } from './types';
import { STORAGE_ERRORS } from './errors';
/**
* Identity serializer for plain strings — no quoting, no JSON wrap.
@ -13,7 +14,7 @@ export const numberSerializer: StorageSerializer<number> = {
stringify: (v) => String(v),
parse: (s) => {
const n = Number(s);
if (Number.isNaN(n)) throw new TypeError(`numberSerializer: not a number: "${s}"`);
if (Number.isNaN(n)) throw new TypeError(STORAGE_ERRORS.NUMBER_SERIALIZER_INVALID(s));
return n;
}
};
@ -23,7 +24,7 @@ export const booleanSerializer: StorageSerializer<boolean> = {
parse: (s) => {
if (s === 'true') return true;
if (s === 'false') return false;
throw new TypeError(`booleanSerializer: not a boolean: "${s}"`);
throw new TypeError(STORAGE_ERRORS.BOOLEAN_SERIALIZER_INVALID(s));
}
};
@ -31,7 +32,7 @@ export const dateSerializer: StorageSerializer<Date> = {
stringify: (v) => v.toISOString(),
parse: (s) => {
const d = new Date(s);
if (Number.isNaN(d.getTime())) throw new TypeError(`dateSerializer: not a date: "${s}"`);
if (Number.isNaN(d.getTime())) throw new TypeError(STORAGE_ERRORS.DATE_SERIALIZER_INVALID(s));
return d;
}
};

@ -1,4 +1,5 @@
import type { StandardSchemaV1 } from '$libs/standard-schema';
import type { STORAGE_OPS } from './consts';
// ── Adapter ────────────────────────────────────────────────────────────────
@ -32,14 +33,7 @@ export interface StorageSerializer<T> {
// ── Error context ──────────────────────────────────────────────────────────
export type StorageOp =
| 'read'
| 'write'
| 'remove'
| 'serialize'
| 'deserialize'
| 'migrate'
| 'validate';
export type StorageOp = (typeof STORAGE_OPS)[number];
export interface StorageErrorContext {
key: string;

@ -96,7 +96,9 @@ timr/
## Alias
```js
alias: { $timr: 'src/arts/timr' }
alias: {
$timr: 'src/arts/timr';
}
```
---
@ -155,7 +157,7 @@ Any mismatch makes the callback a silent no-op. This guards against four
race classes that `clearTimeout` + `AbortSignal` alone do not cover:
| Race | What happens | Why the guard catches it |
|---|---|---|
| ---------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------- |
| Native timeout fires while `cancel()` runs | `clearTimeout` was called but the callback was already in the event loop | Cancel deletes the entry → `entries.get(key) === undefined` → no-op |
| Async task resolves after `replace:true` | First entry's task continuation runs after a new entry took its key | `entry.id` doesn't match the captured `id` → no-op |
| Interval next-tick scheduled after `dispose()` | Tick was armed in branch A before dispose ran | Cancel-via-dispose increments version → no-op |
@ -195,10 +197,10 @@ Reactive wrapper around the engine.
```ts
const Timers = createActiveTimers({ clock, logger });
Timers.size // reactive
Timers.keysSnapshot // reactive readonly string[]
Timers.entriesSnapshot // reactive readonly TimerEntrySnapshot[]
Timers.scopes // reactive readonly string[] (deduplicated)
Timers.size; // reactive
Timers.keysSnapshot; // reactive readonly string[]
Timers.entriesSnapshot; // reactive readonly TimerEntrySnapshot[]
Timers.scopes; // reactive readonly string[] (deduplicated)
```
### Scheduling
@ -278,7 +280,7 @@ for (let attempt = 0; ; attempt++) {
factor: 1.8,
jitterMs: 500
});
await new Promise(r => setTimeout(r, delay));
await new Promise((r) => setTimeout(r, delay));
}
```
@ -290,7 +292,7 @@ third arg for deterministic tests.
## `awaitTask: true` vs `awaitTask: false`
| | `awaitTask: true` (default) | `awaitTask: false` |
|---|---|---|
| ----------------------- | ---------------------------------------- | ------------------------------------ |
| Next tick scheduled… | …after the current task settles | …before the current task settles |
| Task overlap | Impossible | Possible (each tick fire-and-forget) |
| Failure stops interval? | No, but next tick respects task duration | No |
@ -307,7 +309,7 @@ stop, they cancel explicitly from inside the task or use
## Errors
| Class | Thrown when | Type guard |
|---|---|---|
| ------------------------ | --------------------------------------- | -------------------------- |
| `TimrDisposedError` | mutator called after `dispose()` | `isTimrDisposedError` |
| `TimrInvalidKeyError` | key is not a non-empty string | `isTimrInvalidKeyError` |
| `TimrDuplicateKeyError` | key already exists, no `replace:true` | `isTimrDuplicateKeyError` |
@ -328,8 +330,8 @@ scheduling during SSR should be intentional. If a request-scoped
EngineTimers schedules anything, the request must `dispose()` it
before completion — otherwise the timer leaks across requests.
There is no module-global singleton. `App.Timers` (Fase 3) is owned by
the `ActiveApp` instance and torn down by `App.dispose()`.
There is no module-global singleton. `App.Timers` is owned by the
`ActiveApp` instance and torn down by `App.dispose()`.
---
@ -345,11 +347,21 @@ function createFakeClock(start = 0): FakeClock {
let nextId = 1;
return {
now: () => now,
setTimeout(fn, delayMs) { /* enqueue with dueAt = now + delayMs */ },
clearTimeout(handle) { /* mark cancelled */ },
async advanceBy(ms) { /* fire all entries up to now + ms in order */ },
async advanceTo(t) { /* … */ },
pendingCount() { /* … */ }
setTimeout(fn, delayMs) {
/* enqueue with dueAt = now + delayMs */
},
clearTimeout(handle) {
/* mark cancelled */
},
async advanceBy(ms) {
/* fire all entries up to now + ms in order */
},
async advanceTo(t) {
/* … */
},
pendingCount() {
/* … */
}
};
}
@ -383,17 +395,15 @@ const stop = withAutoRefresh(Sess, {
```ts
// reconnect with backoff
timers.schedule(`conn:${name}:reconnect`,
computeBackoffDelay(attempt, opts),
() => connection.reconnect());
timers.schedule(`conn:${name}:reconnect`, computeBackoffDelay(attempt, opts), () =>
connection.reconnect()
);
// heartbeat
timers.interval(`conn:${name}:heartbeat`, 25_000,
() => connection.send('conn.ping'));
timers.interval(`conn:${name}:heartbeat`, 25_000, () => connection.send('conn.ping'));
// ack timeout per outgoing message
timers.schedule(`conn:${name}:ack:${id}`, 10_000,
() => resolveAckTimeout(id));
timers.schedule(`conn:${name}:ack:${id}`, 10_000, () => resolveAckTimeout(id));
// when the reply arrives
timers.cancel(`conn:${name}:ack:${id}`);
@ -421,7 +431,7 @@ timers.schedule('authn:login-attempt:cooldown', cooldown, unlockUI);
## Bundle profile
| Layer | Approx. size (min) |
|---|---|
| ------------------------------------ | ------------------ |
| Engine + types + errors + consts | ~3.5 KB |
| Active wrapper (runes) | +0.5 KB |
| Backoff helper | +0.2 KB |

@ -1,52 +1,2 @@
import {
DEFAULT_BACKOFF_FACTOR,
DEFAULT_BACKOFF_JITTER_MS,
DEFAULT_BACKOFF_MAX_DELAY_MS,
DEFAULT_BACKOFF_MIN_DELAY_MS
} from './consts.ts';
import type { BackoffOptions } from './types.ts';
/**
* Exponential backoff with full jitter, returning a delay in
* milliseconds for the given attempt index.
*
* Pure function — no scheduling, no side effects. Pair with
* `EngineTimers.schedule(...)` when you actually want to wait.
*
* Algorithm:
*
* base = min(maxDelayMs, minDelayMs * factor ** attempt)
* jitter = uniform(-jitterMs, +jitterMs)
* delay = clamp(0, maxDelayMs, base + jitter)
*
* `attempt` is **0-indexed** — the first try uses `minDelayMs` (± jitter).
*
* `random` is injectable so tests can pin the result.
*
* @example
* for (let attempt = 0; ; attempt++) {
* const ok = await tryConnect();
* if (ok) break;
* await new Promise(r => setTimeout(r, computeBackoffDelay(attempt)));
* }
*/
export function computeBackoffDelay(
attempt: number,
options: BackoffOptions = {},
random: () => number = Math.random
): number {
const minDelayMs = options.minDelayMs ?? DEFAULT_BACKOFF_MIN_DELAY_MS;
const maxDelayMs = options.maxDelayMs ?? DEFAULT_BACKOFF_MAX_DELAY_MS;
const factor = options.factor ?? DEFAULT_BACKOFF_FACTOR;
const jitterMs = options.jitterMs ?? DEFAULT_BACKOFF_JITTER_MS;
const safeAttempt = Number.isFinite(attempt) && attempt >= 0 ? attempt : 0;
const base = Math.min(maxDelayMs, minDelayMs * Math.pow(factor, safeAttempt));
// random ∈ [0, 1) → uniform in [-jitterMs, +jitterMs)
const jitter = jitterMs > 0 ? (random() * 2 - 1) * jitterMs : 0;
const delay = base + jitter;
if (delay < 0) return 0;
if (delay > maxDelayMs) return maxDelayMs;
return delay;
}
export { computeBackoffDelay } from '$libs/timers';
export type { BackoffOptions } from '$libs/timers';

@ -11,6 +11,11 @@
/** Logger category emitted by the engine. */
export const LOGGER_CATEGORY = 'timr';
export const ENGINE_METHOD_SCHEDULE = 'schedule';
export const ENGINE_METHOD_SCHEDULE_AT = 'scheduleAt';
export const ENGINE_METHOD_CANCEL = 'cancel';
export const ENGINE_METHOD_CANCEL_ALL = 'cancelAll';
// ── Timer status ───────────────────────────────────────────────────────────
//
// Discrete values of `TimerStatus`. The engine moves an entry through
@ -58,10 +63,12 @@ export const DEFAULT_TIMER_SCOPE_SEPARATOR = ':';
// ── Backoff defaults ───────────────────────────────────────────────────────
export const DEFAULT_BACKOFF_MIN_DELAY_MS = 500;
export const DEFAULT_BACKOFF_MAX_DELAY_MS = 15_000;
export const DEFAULT_BACKOFF_FACTOR = 1.8;
export const DEFAULT_BACKOFF_JITTER_MS = 500;
export {
DEFAULT_BACKOFF_FACTOR,
DEFAULT_BACKOFF_JITTER_MS,
DEFAULT_BACKOFF_MAX_DELAY_MS,
DEFAULT_BACKOFF_MIN_DELAY_MS
} from '$libs/timers';
// ── Log messages (centralised — no inline literals in the engine) ──────────
@ -76,23 +83,45 @@ export const LOG_MSG_LISTENER_THREW_PREFIX = 'Listener threw on event ';
// `arts/http` so log output and stack traces are searchable.
export const ERROR_PREFIX = '[timr] ';
export const ERROR_NAME_DISPOSED = 'TimrDisposedError';
export const ERROR_NAME_INVALID_KEY = 'TimrInvalidKeyError';
export const ERROR_NAME_DUPLICATE_KEY = 'TimrDuplicateKeyError';
export const ERROR_NAME_INVALID_DELAY = 'TimrInvalidDelayError';
export const ERROR_NAME_INACTIVE_TIMER = 'TimrInactiveTimerError';
export const ERROR_MSG_DISPOSED_SUFFIX = '() called on a disposed engine';
export const ERROR_MSG_INVALID_KEY_PREFIX = 'invalid key: expected non-empty string, got ';
export const ERROR_MSG_DUPLICATE_KEY_PREFIX = 'duplicate key "';
export const ERROR_MSG_DUPLICATE_KEY_SUFFIX = '" — pass { replace: true } to overwrite';
export const ERROR_MSG_INVALID_DELAY_PREFIX = 'invalid delay: expected finite number >= 0, got ';
export const ERROR_MSG_INACTIVE_TIMER_PREFIX = 'reschedule("';
export const ERROR_MSG_INACTIVE_TIMER_SUFFIX = '") called on an inactive timer';
export function disposedErrorMessage(method: string): string {
return `${ERROR_PREFIX}${method}() called on a disposed engine`;
return `${ERROR_PREFIX}${method}${ERROR_MSG_DISPOSED_SUFFIX}`;
}
export function invalidKeyErrorMessage(key: unknown): string {
return `${ERROR_PREFIX}invalid key: expected non-empty string, got ${typeof key === 'string' ? `"${key}"` : typeof key}`;
const value = typeof key === 'string' ? `"${key}"` : typeof key;
return `${ERROR_PREFIX}${ERROR_MSG_INVALID_KEY_PREFIX}${value}`;
}
export function duplicateKeyErrorMessage(key: string): string {
return `${ERROR_PREFIX}duplicate key "${key}" — pass { replace: true } to overwrite`;
return `${ERROR_PREFIX}${ERROR_MSG_DUPLICATE_KEY_PREFIX}${key}${ERROR_MSG_DUPLICATE_KEY_SUFFIX}`;
}
export function invalidDelayErrorMessage(delayMs: unknown): string {
return `${ERROR_PREFIX}invalid delay: expected finite number >= 0, got ${String(delayMs)}`;
return `${ERROR_PREFIX}${ERROR_MSG_INVALID_DELAY_PREFIX}${String(delayMs)}`;
}
export function inactiveTimerErrorMessage(key: string): string {
return `${ERROR_PREFIX}reschedule("${key}") called on an inactive timer`;
return `${ERROR_PREFIX}${ERROR_MSG_INACTIVE_TIMER_PREFIX}${key}${ERROR_MSG_INACTIVE_TIMER_SUFFIX}`;
}
export function listenerThrewMessage(eventType: string): string {
return `${LOG_MSG_LISTENER_THREW_PREFIX}${eventType}`;
}
export function taskFailedMessage(key: string): string {
return `${LOG_MSG_TASK_FAILED_PREFIX}"${key}"`;
}

@ -26,12 +26,14 @@ import { createSystemTimerClock } from './clock.ts';
import {
disposedErrorMessage,
duplicateKeyErrorMessage,
ENGINE_METHOD_CANCEL,
ENGINE_METHOD_CANCEL_ALL,
ENGINE_METHOD_SCHEDULE,
ENGINE_METHOD_SCHEDULE_AT,
inactiveTimerErrorMessage,
invalidDelayErrorMessage,
invalidKeyErrorMessage,
LOG_MSG_LISTENER_THREW_PREFIX,
LOG_MSG_SCHEDULE_AT_PAST,
LOG_MSG_TASK_FAILED_PREFIX,
LOGGER_CATEGORY,
DEFAULT_TIMER_SCOPE_SEPARATOR,
TIMER_EVENT_CANCELLED,
@ -46,7 +48,9 @@ import {
TIMER_STATUS_COMPLETED,
TIMER_STATUS_FAILED,
TIMER_STATUS_PENDING,
TIMER_STATUS_RUNNING
TIMER_STATUS_RUNNING,
listenerThrewMessage,
taskFailedMessage
} from './consts.ts';
import {
TimrDisposedError,
@ -157,11 +161,7 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim
try {
listener(event);
} catch (err) {
logger?.error?.(
LOGGER_CATEGORY,
`${LOG_MSG_LISTENER_THREW_PREFIX}${event.type}`,
{ error: err }
);
logger?.error?.(LOGGER_CATEGORY, listenerThrewMessage(event.type), { error: err });
}
}
}
@ -172,11 +172,7 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim
* Look up an entry only if the captured tuple still matches. This is
* the core of the race-safety contract — every callback uses it.
*/
function getLiveEntry(
id: number,
key: string,
version: number
): InternalTimerEntry | null {
function getLiveEntry(id: number, key: string, version: number): InternalTimerEntry | null {
const current = entries.get(key);
if (current === undefined) return null;
if (current.id !== id) return null;
@ -232,7 +228,7 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim
// Branch A — interval with awaitTask=false: arm the next tick BEFORE
// running so we honour cadence regardless of how long the task
// takes. Stale-callback guard still applies to next tick.
if (isInterval && !awaitTask) {
if (isInterval && !awaitTask && !hasReachedMaxRuns(entry)) {
scheduleNextIntervalTick(entry);
}
@ -271,7 +267,7 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim
} else {
entry.lastErrorAt = now;
entry.error = error;
logger?.error?.(LOGGER_CATEGORY, `${LOG_MSG_TASK_FAILED_PREFIX}"${entry.key}"`, {
logger?.error?.(LOGGER_CATEGORY, taskFailedMessage(entry.key), {
error
});
emit({ type: TIMER_EVENT_FAILED, entry: snapshotOf(entry), error });
@ -281,7 +277,7 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim
// `entry.status` may already be TIMER_STATUS_PENDING if branch A
// already armed the next tick; otherwise schedule it now.
if (entry.status === TIMER_STATUS_RUNNING) {
if (entry.maxRuns !== undefined && entry.runCount >= entry.maxRuns) {
if (hasReachedMaxRuns(entry)) {
entry.status = error === null ? TIMER_STATUS_COMPLETED : TIMER_STATUS_FAILED;
entries.delete(entry.key);
return;
@ -298,7 +294,7 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim
function scheduleNextIntervalTick(entry: InternalTimerEntry): void {
if (entry.intervalMs === null) return;
if (entry.maxRuns !== undefined && entry.runCount >= entry.maxRuns) {
if (hasReachedMaxRuns(entry)) {
entry.status = TIMER_STATUS_COMPLETED;
entries.delete(entry.key);
return;
@ -327,6 +323,10 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim
// ── Public scheduling ───────────────────────────────────────────────────
function hasReachedMaxRuns(entry: InternalTimerEntry): boolean {
return entry.maxRuns !== undefined && entry.runCount >= entry.maxRuns;
}
function scheduleCommon(
key: string,
delayMs: number,
@ -337,7 +337,7 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim
awaitTask: boolean,
maxRuns: number | undefined
): TimerHandle {
ensureLive('schedule');
ensureLive(ENGINE_METHOD_SCHEDULE);
validateKey(key);
validateDelay(delayMs);
@ -369,8 +369,7 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim
lastCompletedAt: null,
lastErrorAt: null,
error: null,
removeOnComplete:
kind === TIMER_KIND_INTERVAL ? false : options?.removeOnComplete !== false,
removeOnComplete: kind === TIMER_KIND_INTERVAL ? false : options?.removeOnComplete !== false,
meta: options?.meta
};
@ -440,7 +439,7 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim
},
scheduleAt(key, dueAt, task, opts) {
ensureLive('scheduleAt');
ensureLive(ENGINE_METHOD_SCHEDULE_AT);
const now = clock.now();
let delayMs = dueAt - now;
if (delayMs < 0) {
@ -470,7 +469,7 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim
},
cancel(key) {
ensureLive('cancel');
ensureLive(ENGINE_METHOD_CANCEL);
const entry = entries.get(key);
if (entry === undefined) return false;
cancelEntry(entry);
@ -478,7 +477,7 @@ export function createEngineTimers(options: EngineTimersOptions = {}): EngineTim
},
cancelAll(scope) {
ensureLive('cancelAll');
ensureLive(ENGINE_METHOD_CANCEL_ALL);
let count = 0;
// Snapshot keys to avoid mutating during iteration.
const keys: string[] = [];

@ -7,6 +7,14 @@
* across worker boundaries and is stable to test against.
*/
import {
ERROR_NAME_DISPOSED,
ERROR_NAME_DUPLICATE_KEY,
ERROR_NAME_INACTIVE_TIMER,
ERROR_NAME_INVALID_DELAY,
ERROR_NAME_INVALID_KEY
} from './consts.ts';
abstract class TimrEngineError extends Error {
abstract readonly name: string;
constructor(message: string, options?: { cause?: unknown }) {
@ -15,15 +23,15 @@ abstract class TimrEngineError extends Error {
}
export class TimrDisposedError extends TimrEngineError {
readonly name = 'TimrDisposedError' as const;
readonly name = ERROR_NAME_DISPOSED;
}
export class TimrInvalidKeyError extends TimrEngineError {
readonly name = 'TimrInvalidKeyError' as const;
readonly name = ERROR_NAME_INVALID_KEY;
}
export class TimrDuplicateKeyError extends TimrEngineError {
readonly name = 'TimrDuplicateKeyError' as const;
readonly name = ERROR_NAME_DUPLICATE_KEY;
readonly key: string;
constructor(message: string, key: string) {
super(message);
@ -32,11 +40,11 @@ export class TimrDuplicateKeyError extends TimrEngineError {
}
export class TimrInvalidDelayError extends TimrEngineError {
readonly name = 'TimrInvalidDelayError' as const;
readonly name = ERROR_NAME_INVALID_DELAY;
}
export class TimrInactiveTimerError extends TimrEngineError {
readonly name = 'TimrInactiveTimerError' as const;
readonly name = ERROR_NAME_INACTIVE_TIMER;
readonly key: string;
constructor(message: string, key: string) {
super(message);
@ -47,23 +55,21 @@ export class TimrInactiveTimerError extends TimrEngineError {
// ── Type guards ────────────────────────────────────────────────────────────
export function isTimrDisposedError(value: unknown): value is TimrDisposedError {
return value instanceof Error && (value as { name?: string }).name === 'TimrDisposedError';
return value instanceof Error && (value as { name?: string }).name === ERROR_NAME_DISPOSED;
}
export function isTimrInvalidKeyError(value: unknown): value is TimrInvalidKeyError {
return value instanceof Error && (value as { name?: string }).name === 'TimrInvalidKeyError';
return value instanceof Error && (value as { name?: string }).name === ERROR_NAME_INVALID_KEY;
}
export function isTimrDuplicateKeyError(value: unknown): value is TimrDuplicateKeyError {
return value instanceof Error && (value as { name?: string }).name === 'TimrDuplicateKeyError';
return value instanceof Error && (value as { name?: string }).name === ERROR_NAME_DUPLICATE_KEY;
}
export function isTimrInvalidDelayError(value: unknown): value is TimrInvalidDelayError {
return value instanceof Error && (value as { name?: string }).name === 'TimrInvalidDelayError';
return value instanceof Error && (value as { name?: string }).name === ERROR_NAME_INVALID_DELAY;
}
export function isTimrInactiveTimerError(value: unknown): value is TimrInactiveTimerError {
return (
value instanceof Error && (value as { name?: string }).name === 'TimrInactiveTimerError'
);
return value instanceof Error && (value as { name?: string }).name === ERROR_NAME_INACTIVE_TIMER;
}

@ -27,6 +27,8 @@ import type {
TIMER_STATUS_RUNNING
} from './consts.ts';
export type { BackoffOptions } from '$libs/timers';
// ============================================================================
// CLOCK
// ============================================================================
@ -207,19 +209,9 @@ export interface TimerLogger {
export interface TimerScheduler {
readonly size: number;
schedule(
key: string,
delayMs: number,
task: TimerTask,
options?: TimerOptions
): TimerHandle;
schedule(key: string, delayMs: number, task: TimerTask, options?: TimerOptions): TimerHandle;
scheduleAt(
key: string,
dueAt: number,
task: TimerTask,
options?: TimerOptions
): TimerHandle;
scheduleAt(key: string, dueAt: number, task: TimerTask, options?: TimerOptions): TimerHandle;
interval(
key: string,
@ -251,14 +243,3 @@ export interface EngineTimers extends TimerScheduler {
entry(key: string): TimerEntrySnapshot | null;
onChange(listener: TimerListener): () => void;
}
// ============================================================================
// BACKOFF (pure helper, no scheduling)
// ============================================================================
export interface BackoffOptions {
readonly minDelayMs?: number;
readonly maxDelayMs?: number;
readonly factor?: number;
readonly jitterMs?: number;
}

@ -0,0 +1,33 @@
/**
* Decode a JWT's `exp` claim and return it as **epoch milliseconds**, or
* `null` when the token is malformed / missing the claim. **No signature
* verification, no claim validation** — trust this only for scheduling
* (compute when to refresh), never for authorization decisions.
*
* Opt-in helper, separate file. The artifact does not bundle a JWT
* library; consumers that store opaque tokens never reach this code.
*
* @example
* import { extractJwtExp } from '$sess/jwt';
*
* await Sess.adopt({
* user,
* accessToken,
* expiresAt: extractJwtExp(accessToken) ?? Date.now() + 3_600_000
* });
*/
export function extractJwtExp(token: string): number | null {
const parts = token.split('.');
if (parts.length !== 3) return null;
try {
const payload = parts[1];
const padded = payload + '='.repeat((4 - (payload.length % 4)) % 4);
const json = atob(padded.replace(/-/g, '+').replace(/_/g, '/'));
const claims = JSON.parse(json) as { exp?: unknown };
if (typeof claims.exp === 'number') return claims.exp * 1000;
return null;
} catch {
return null;
}
}

@ -0,0 +1,102 @@
import {
HTTP_CONTENT_TYPE_JSON,
HTTP_HEADER_CONTENT_TYPE,
HTTP_METHOD_HEAD,
HTTP_NULL_BODY_STATUSES
} from './consts';
import type { HttpBodyInit, HttpHeadersHook, HttpHeadersInit } from './types';
const JSON_CONTENT_TYPE_PATTERN = /\/(?:.*[.+-])?json(?:;|$)/i;
export function isJsonContentType(value: string): boolean {
return JSON_CONTENT_TYPE_PATTERN.test(value);
}
export function isJSONSerializable(value: unknown): value is Record<string, unknown> | unknown[] {
if (value === null || value === undefined) return false;
if (typeof value !== 'object') return false;
if (Array.isArray(value)) return true;
const obj = value as { buffer?: unknown; constructor?: { name?: string }; toJSON?: unknown };
if (obj.buffer !== undefined && obj.buffer instanceof ArrayBuffer) return false;
if (
value instanceof FormData ||
value instanceof URLSearchParams ||
value instanceof Blob ||
value instanceof ArrayBuffer ||
(typeof ReadableStream !== 'undefined' && value instanceof ReadableStream)
) {
return false;
}
const ctor = obj.constructor?.name;
if (ctor === 'Object' || ctor === undefined) return true;
if (typeof obj.toJSON === 'function') return true;
return false;
}
export function serializeBody(body: HttpBodyInit): {
body: BodyInit | undefined;
contentType?: string;
} {
if (body === null || body === undefined) return { body: undefined };
if (isJSONSerializable(body)) {
return { body: JSON.stringify(body), contentType: HTTP_CONTENT_TYPE_JSON };
}
return { body: body as BodyInit };
}
export async function parseBody(response: Response, method: string): Promise<unknown> {
if (HTTP_NULL_BODY_STATUSES.has(response.status)) return undefined;
if (method.toUpperCase() === HTTP_METHOD_HEAD) return undefined;
const contentType = response.headers.get(HTTP_HEADER_CONTENT_TYPE) ?? '';
if (isJsonContentType(contentType)) {
try {
return await response.json();
} catch {
return undefined;
}
}
try {
const text = await response.text();
return text === '' ? undefined : text;
} catch {
return undefined;
}
}
export async function mergeHeaders(
defaults: HttpHeadersInit | HttpHeadersHook | undefined,
override: HttpHeadersInit | HttpHeadersHook | undefined
): Promise<Headers> {
const result = new Headers();
if (defaults !== undefined) await applyHeaders(result, defaults);
if (override !== undefined) await applyHeaders(result, override);
return result;
}
export async function applyHeaders(
target: Headers,
source: HttpHeadersInit | HttpHeadersHook
): Promise<void> {
const resolved = typeof source === 'function' ? await source() : source;
if (resolved instanceof Headers) {
resolved.forEach((value, key) => target.set(key, value));
return;
}
if (Array.isArray(resolved)) {
for (const [key, value] of resolved) target.set(key, value);
return;
}
for (const [key, value] of Object.entries(resolved)) target.set(key, value);
}

@ -0,0 +1,83 @@
export const HTTP_METHOD_GET = 'GET';
export const HTTP_METHOD_HEAD = 'HEAD';
export const HTTP_METHOD_POST = 'POST';
export const HTTP_METHOD_PUT = 'PUT';
export const HTTP_METHOD_PATCH = 'PATCH';
export const HTTP_METHOD_DELETE = 'DELETE';
export const HTTP_METHOD_OPTIONS = 'OPTIONS';
export const HTTP_METHODS = [
HTTP_METHOD_GET,
HTTP_METHOD_HEAD,
HTTP_METHOD_POST,
HTTP_METHOD_PUT,
HTTP_METHOD_PATCH,
HTTP_METHOD_DELETE,
HTTP_METHOD_OPTIONS
] as const;
export const HTTP_SAFE_METHODS = [HTTP_METHOD_GET, HTTP_METHOD_HEAD, HTTP_METHOD_OPTIONS] as const;
export const HTTP_IDEMPOTENT_METHODS = [
HTTP_METHOD_GET,
HTTP_METHOD_HEAD,
HTTP_METHOD_PUT,
HTTP_METHOD_DELETE,
HTTP_METHOD_OPTIONS
] as const;
export const HTTP_BODY_METHODS = [HTTP_METHOD_POST, HTTP_METHOD_PUT, HTTP_METHOD_PATCH] as const;
export const HTTP_HEADER_ACCEPT = 'accept';
export const HTTP_HEADER_AUTHORIZATION = 'authorization';
export const HTTP_HEADER_CONTENT_TYPE = 'content-type';
export const HTTP_HEADER_RETRY_AFTER = 'retry-after';
export const HTTP_HEADER_RATELIMIT_RESET = 'ratelimit-reset';
export const HTTP_HEADER_X_RATELIMIT_RESET = 'x-ratelimit-reset';
export const HTTP_HEADER_X_RATE_LIMIT_RESET = 'x-rate-limit-reset';
export const HTTP_HEADER_X_RATELIMIT_RETRY_AFTER = 'x-ratelimit-retry-after';
export const HTTP_RETRY_AFTER_HEADERS = [
HTTP_HEADER_RETRY_AFTER,
HTTP_HEADER_RATELIMIT_RESET,
HTTP_HEADER_X_RATELIMIT_RESET,
HTTP_HEADER_X_RATE_LIMIT_RESET,
HTTP_HEADER_X_RATELIMIT_RETRY_AFTER
] as const;
export const HTTP_CONTENT_TYPE_JSON = 'application/json';
export const HTTP_STATUS_SWITCHING_PROTOCOLS = 101;
export const HTTP_STATUS_NO_CONTENT = 204;
export const HTTP_STATUS_RESET_CONTENT = 205;
export const HTTP_STATUS_NOT_MODIFIED = 304;
export const HTTP_STATUS_BAD_REQUEST = 400;
export const HTTP_STATUS_UNAUTHORIZED = 401;
export const HTTP_STATUS_FORBIDDEN = 403;
export const HTTP_STATUS_NOT_FOUND = 404;
export const HTTP_STATUS_REQUEST_TIMEOUT = 408;
export const HTTP_STATUS_TOO_EARLY = 425;
export const HTTP_STATUS_TOO_MANY_REQUESTS = 429;
export const HTTP_STATUS_INTERNAL_SERVER_ERROR = 500;
export const HTTP_STATUS_BAD_GATEWAY = 502;
export const HTTP_STATUS_SERVICE_UNAVAILABLE = 503;
export const HTTP_STATUS_GATEWAY_TIMEOUT = 504;
export const HTTP_NULL_BODY_STATUSES: ReadonlySet<number> = new Set([
HTTP_STATUS_SWITCHING_PROTOCOLS,
HTTP_STATUS_NO_CONTENT,
HTTP_STATUS_RESET_CONTENT,
HTTP_STATUS_NOT_MODIFIED
]);
export const HTTP_DEFAULT_RETRY_STATUSES = [
HTTP_STATUS_REQUEST_TIMEOUT,
HTTP_STATUS_TOO_EARLY,
HTTP_STATUS_TOO_MANY_REQUESTS,
HTTP_STATUS_INTERNAL_SERVER_ERROR,
HTTP_STATUS_BAD_GATEWAY,
HTTP_STATUS_SERVICE_UNAVAILABLE,
HTTP_STATUS_GATEWAY_TIMEOUT
] as const;
export const RETRY_AFTER_DATE_CEILING_SECONDS = 1_700_000_000;

@ -0,0 +1,5 @@
export * from './consts';
export * from './types';
export * from './body';
export * from './search';
export * from './retry';

@ -0,0 +1,24 @@
import { HTTP_RETRY_AFTER_HEADERS, RETRY_AFTER_DATE_CEILING_SECONDS } from './consts';
export function parseRetryAfter(headers: Headers, now: number = Date.now()): number | undefined {
for (const name of HTTP_RETRY_AFTER_HEADERS) {
const raw = headers.get(name);
if (raw === null) continue;
const trimmed = raw.trim();
if (trimmed === '') continue;
const numeric = Number(trimmed);
if (Number.isFinite(numeric)) {
if (numeric < 0) return 0;
if (numeric > RETRY_AFTER_DATE_CEILING_SECONDS) {
return Math.max(0, numeric * 1000 - now);
}
return numeric * 1000;
}
const parsed = Date.parse(trimmed);
if (Number.isFinite(parsed)) return Math.max(0, parsed - now);
}
return undefined;
}

@ -0,0 +1,39 @@
import type { HttpSearchInit } from './types';
export function normalizeSearch(input: HttpSearchInit | undefined): URLSearchParams | undefined {
if (input === undefined) return undefined;
if (input instanceof URLSearchParams) return new URLSearchParams(input);
if (typeof input === 'string') {
return new URLSearchParams(input.startsWith('?') ? input.slice(1) : input);
}
const out = new URLSearchParams();
if (Array.isArray(input)) {
for (const [key, value] of input) out.append(key, String(value));
return out;
}
for (const [key, value] of Object.entries(input)) {
if (value === null || value === undefined) continue;
out.append(key, String(value));
}
return out;
}
export function appendSearch(url: string, params: URLSearchParams | undefined): string {
if (params === undefined || params.size === 0) return url;
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}${params.toString()}`;
}
export function resolveUrl(url: string, baseUrl: string | undefined): string {
if (baseUrl === undefined || baseUrl === '') return url;
if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(url)) return url;
const trimmedBase = baseUrl.split('?')[0].split('#')[0].replace(/\/+$/, '');
const trimmedUrl = url.replace(/^\/+/, '');
return `${trimmedBase}/${trimmedUrl}`;
}

@ -0,0 +1,18 @@
import type { HTTP_METHODS } from './consts';
export type HttpMethod = (typeof HTTP_METHODS)[number];
export type HttpSearchInit =
| string
| URLSearchParams
| Record<string, string | number | boolean | null | undefined>
| ReadonlyArray<readonly [string, string | number | boolean]>;
export type HttpHeadersInit =
| Headers
| Record<string, string>
| ReadonlyArray<readonly [string, string]>;
export type HttpHeadersHook = () => HttpHeadersInit | Promise<HttpHeadersInit>;
export type HttpBodyInit = BodyInit | Record<string, unknown> | unknown[] | null | undefined;

@ -0,0 +1,328 @@
import {
PERMISSION_EFFECT_ALLOW,
PERMISSION_EFFECT_DENY,
PERMISSION_ACTION_WILDCARD_SUFFIX,
PERMISSION_ERROR_MSG_MFA_REQUIRED,
PERMISSION_ERROR_MSG_POLICY_UNKNOWN_ACTION_PREFIX,
PERMISSION_ERROR_MSG_POLICY_UNKNOWN_RESOURCE_PREFIX,
PERMISSION_EXPR_AND,
PERMISSION_EXPR_CONST,
PERMISSION_EXPR_CONTAINS,
PERMISSION_EXPR_EQ,
PERMISSION_EXPR_GT,
PERMISSION_EXPR_GTE,
PERMISSION_EXPR_IN,
PERMISSION_EXPR_LT,
PERMISSION_EXPR_LTE,
PERMISSION_EXPR_NEQ,
PERMISSION_EXPR_NOT,
PERMISSION_EXPR_OR,
PERMISSION_EXPR_REF,
PERMISSION_EXPR_REL,
PERMISSION_MASK_MODE_FULL,
PERMISSION_OBLIGATION_AUDIT,
PERMISSION_OBLIGATION_MASK,
PERMISSION_OBLIGATION_REDACT,
PERMISSION_OBLIGATION_REQUIRE_MFA,
PERMISSION_PATH_SEPARATOR,
PERMISSION_POLICY_DEFAULT_PRIORITY,
PERMISSION_ROOT_ACTOR,
PERMISSION_ROOT_CONTEXT,
PERMISSION_ROOT_RESOURCE,
PERMISSION_SEVERITY_HIGH,
PERMISSION_SEVERITY_LOW,
PERMISSION_SEVERITY_MEDIUM,
permissionAutoPolicyId
} from './consts.ts';
import { PermissionSchemaError } from './errors.ts';
import { stripResourcePrefix } from './path.ts';
import { actionResource } from './schema.ts';
import type { AdviceIR, ExprIR, ObligationIR, PermSchema, PolicyIR } from './types.ts';
let autoPolicyCounter = 0;
type ExprInput =
| ExprBuilder
| ExprIR
| string
| number
| boolean
| null
| undefined
| Record<string, unknown>
| readonly unknown[];
function toIR(input: ExprInput): ExprIR {
if (input instanceof ExprBuilder) return input.ir;
if (isExprIR(input)) return input;
return { op: PERMISSION_EXPR_CONST, value: input };
}
function isExprIR(input: unknown): input is ExprIR {
return Boolean(input && typeof input === 'object' && 'op' in input);
}
function refForAttribute(path: string): ExprIR {
const parts = path.split(PERMISSION_PATH_SEPARATOR).filter(Boolean);
if (parts[0] === PERMISSION_ROOT_ACTOR) {
return {
op: PERMISSION_EXPR_REF,
root: PERMISSION_ROOT_ACTOR,
path: parts.slice(1).join(PERMISSION_PATH_SEPARATOR)
};
}
if (parts[0] === PERMISSION_ROOT_CONTEXT) {
return {
op: PERMISSION_EXPR_REF,
root: PERMISSION_ROOT_CONTEXT,
path: parts.slice(1).join(PERMISSION_PATH_SEPARATOR)
};
}
if (parts[0] === PERMISSION_ROOT_RESOURCE) {
return {
op: PERMISSION_EXPR_REF,
root: PERMISSION_ROOT_RESOURCE,
path: parts.slice(1).join(PERMISSION_PATH_SEPARATOR)
};
}
return {
op: PERMISSION_EXPR_REF,
root: PERMISSION_ROOT_RESOURCE,
path: stripResourcePrefix(path)
};
}
export class ExprBuilder {
constructor(readonly ir: ExprIR) {}
eq(value: ExprInput): ExprBuilder {
return new ExprBuilder({ op: PERMISSION_EXPR_EQ, left: this.ir, right: toIR(value) });
}
notEq(value: ExprInput): ExprBuilder {
return new ExprBuilder({ op: PERMISSION_EXPR_NEQ, left: this.ir, right: toIR(value) });
}
gt(value: ExprInput): ExprBuilder {
return new ExprBuilder({ op: PERMISSION_EXPR_GT, left: this.ir, right: toIR(value) });
}
gte(value: ExprInput): ExprBuilder {
return new ExprBuilder({ op: PERMISSION_EXPR_GTE, left: this.ir, right: toIR(value) });
}
lt(value: ExprInput): ExprBuilder {
return new ExprBuilder({ op: PERMISSION_EXPR_LT, left: this.ir, right: toIR(value) });
}
lte(value: ExprInput): ExprBuilder {
return new ExprBuilder({ op: PERMISSION_EXPR_LTE, left: this.ir, right: toIR(value) });
}
in(set: ExprInput): ExprBuilder {
return new ExprBuilder({ op: PERMISSION_EXPR_IN, value: this.ir, set: toIR(set) });
}
contains(value: ExprInput): ExprBuilder {
return new ExprBuilder({ op: PERMISSION_EXPR_CONTAINS, set: this.ir, value: toIR(value) });
}
not(): ExprBuilder {
return new ExprBuilder({ op: PERMISSION_EXPR_NOT, expr: this.ir });
}
}
export class RelationBuilder {
constructor(private readonly path: string) {}
has(subject: ExprInput): ExprBuilder {
return new ExprBuilder({ op: PERMISSION_EXPR_REL, path: this.path, subject: toIR(subject) });
}
is(subject: ExprInput): ExprBuilder {
return this.has(subject);
}
}
export class PolicyBuilder {
private conditionIR: ExprIR = { op: PERMISSION_EXPR_CONST, value: true };
private policyId: string | undefined;
private policyPriority = PERMISSION_POLICY_DEFAULT_PRIORITY;
private policyReason: string | undefined;
private policyCode: string | undefined;
private policyObligations: ObligationIR[] = [];
private policyAdvice: AdviceIR[] = [];
private policyMetadata: Record<string, unknown> | undefined;
constructor(
private readonly policyEffect: typeof PERMISSION_EFFECT_ALLOW | typeof PERMISSION_EFFECT_DENY,
private readonly policyAction: string
) {}
id(id: string): this {
this.policyId = id;
return this;
}
priority(priority: number): this {
this.policyPriority = priority;
return this;
}
when(expr: ExprInput): this {
this.conditionIR = toIR(expr);
return this;
}
because(reason: string, code?: string): this {
this.policyReason = reason;
this.policyCode = code;
return this;
}
oblige(...obligations: ObligationIR[]): this {
this.policyObligations.push(...obligations);
return this;
}
advise(...advice: AdviceIR[]): this {
this.policyAdvice.push(...advice);
return this;
}
meta(metadata: Record<string, unknown>): this {
this.policyMetadata = metadata;
return this;
}
build(): PolicyIR {
const resource = actionResource(this.policyAction);
const id =
this.policyId ??
permissionAutoPolicyId(this.policyEffect, this.policyAction, ++autoPolicyCounter);
const policy: PolicyIR = {
id,
effect: this.policyEffect,
priority: this.policyPriority,
target: resource ? { action: this.policyAction, resource } : { action: this.policyAction },
condition: this.conditionIR
};
if (this.policyReason !== undefined) (policy as { reason?: string }).reason = this.policyReason;
if (this.policyCode !== undefined) (policy as { code?: string }).code = this.policyCode;
if (this.policyObligations.length) {
(policy as { obligations?: readonly ObligationIR[] }).obligations = this.policyObligations;
}
if (this.policyAdvice.length) {
(policy as { advice?: readonly AdviceIR[] }).advice = this.policyAdvice;
}
if (this.policyMetadata !== undefined) {
(policy as { metadata?: Record<string, unknown> }).metadata = this.policyMetadata;
}
return policy;
}
}
export function allow(action: string): PolicyBuilder {
return new PolicyBuilder(PERMISSION_EFFECT_ALLOW, action);
}
export function deny(action: string): PolicyBuilder {
return new PolicyBuilder(PERMISSION_EFFECT_DENY, action);
}
export function attr(path: string): ExprBuilder {
return new ExprBuilder(refForAttribute(path));
}
export function ctx(path: string): ExprBuilder {
return new ExprBuilder({ op: PERMISSION_EXPR_REF, root: PERMISSION_ROOT_CONTEXT, path });
}
export function actor(path = ''): ExprBuilder {
return new ExprBuilder({ op: PERMISSION_EXPR_REF, root: PERMISSION_ROOT_ACTOR, path });
}
export function resource(path = ''): ExprBuilder {
return new ExprBuilder({ op: PERMISSION_EXPR_REF, root: PERMISSION_ROOT_RESOURCE, path });
}
export function val(value: unknown): ExprBuilder {
return new ExprBuilder({ op: PERMISSION_EXPR_CONST, value });
}
export function rel(path: string): RelationBuilder {
return new RelationBuilder(path);
}
export function and(...args: ExprInput[]): ExprBuilder {
return new ExprBuilder({ op: PERMISSION_EXPR_AND, args: args.map(toIR) });
}
export function or(...args: ExprInput[]): ExprBuilder {
return new ExprBuilder({ op: PERMISSION_EXPR_OR, args: args.map(toIR) });
}
export function not(expr: ExprInput): ExprBuilder {
return new ExprBuilder({ op: PERMISSION_EXPR_NOT, expr: toIR(expr) });
}
export function mask(field: string, mode: string = PERMISSION_MASK_MODE_FULL): ObligationIR {
return { type: PERMISSION_OBLIGATION_MASK, field, mode };
}
export function redact(field: string): ObligationIR {
return { type: PERMISSION_OBLIGATION_REDACT, field };
}
export function requireMfa(reason = PERMISSION_ERROR_MSG_MFA_REQUIRED): ObligationIR {
return { type: PERMISSION_OBLIGATION_REQUIRE_MFA, reason };
}
export function audit(
event: string,
severity:
| typeof PERMISSION_SEVERITY_LOW
| typeof PERMISSION_SEVERITY_MEDIUM
| typeof PERMISSION_SEVERITY_HIGH = PERMISSION_SEVERITY_LOW
): ObligationIR {
return { type: PERMISSION_OBLIGATION_AUDIT, event, severity };
}
export function definePolicies(
schema: PermSchema,
policies: readonly (PolicyBuilder | PolicyIR)[]
): PolicyIR[] {
const built = policies.map((policy) =>
policy instanceof PolicyBuilder ? policy.build() : policy
);
validatePolicies(schema, built);
return built;
}
export function validatePolicies(schema: PermSchema, policies: readonly PolicyIR[]): void {
for (const policy of policies) {
const resource = policy.target.resource ?? actionResource(policy.target.action);
if (resource !== undefined && schema.resources[resource] === undefined) {
throw new PermissionSchemaError(
`${PERMISSION_ERROR_MSG_POLICY_UNKNOWN_RESOURCE_PREFIX}${policy.id}`
);
}
const action = policy.target.action.includes(PERMISSION_PATH_SEPARATOR)
? policy.target.action.split(PERMISSION_PATH_SEPARATOR)[1]
: policy.target.action;
if (
resource !== undefined &&
!policy.target.action.endsWith(PERMISSION_ACTION_WILDCARD_SUFFIX) &&
!schema.resources[resource]?.actions.includes(action)
) {
throw new PermissionSchemaError(
`${PERMISSION_ERROR_MSG_POLICY_UNKNOWN_ACTION_PREFIX}${policy.id}`
);
}
}
}
export type { ExprInput };

@ -0,0 +1,143 @@
import {
PERMISSION_DECISION_CODE_DENIED,
PERMISSION_EFFECT_ALLOW,
PERMISSION_EFFECT_DENY,
PERMISSION_EFFECT_INDETERMINATE,
PERMISSION_EFFECT_NOT_APPLICABLE,
PERMISSION_ERROR_MSG_DENY_INDETERMINATE,
PERMISSION_ERROR_MSG_EVALUATION_FAILED_DECISION,
PERMISSION_ERROR_MSG_EVALUATION_UNKNOWN_DECISION,
PERMISSION_ERROR_MSG_NOT_APPLICABLE,
PERMISSION_EVAL_ERROR,
PERMISSION_EVAL_UNKNOWN,
PERMISSION_FALLBACK_DENY,
permissionDeniedByPolicyReason
} from './consts.ts';
import type {
EvalResult,
PermissionDecision,
PermissionFallback,
PolicyIR,
TraceEntry
} from './types.ts';
export interface EvaluatedPolicy {
readonly policy: PolicyIR;
readonly targetMatched: boolean;
readonly evaluation?: EvalResult;
}
function byPriorityDesc(a: EvaluatedPolicy, b: EvaluatedPolicy): number {
return b.policy.priority - a.policy.priority;
}
function matched(entry: EvaluatedPolicy): boolean {
return entry.targetMatched;
}
function evaluatedTrue(entry: EvaluatedPolicy): boolean {
return entry.evaluation?.value === true;
}
function evaluatedUnknownOrError(entry: EvaluatedPolicy): boolean {
return (
entry.evaluation?.value === PERMISSION_EVAL_UNKNOWN ||
entry.evaluation?.value === PERMISSION_EVAL_ERROR
);
}
export function combineEvaluatedPolicies(
evaluated: readonly EvaluatedPolicy[],
defaultFallback: PermissionFallback = PERMISSION_FALLBACK_DENY
): PermissionDecision {
const applicable = evaluated.filter((entry) => matched(entry) && evaluatedTrue(entry));
const deny = applicable
.filter((entry) => entry.policy.effect === PERMISSION_EFFECT_DENY)
.sort(byPriorityDesc)[0];
if (deny) {
return {
effect: PERMISSION_EFFECT_DENY,
policy: deny.policy.id,
code: deny.policy.code ?? PERMISSION_DECISION_CODE_DENIED,
reason: deny.policy.reason ?? permissionDeniedByPolicyReason(deny.policy.id),
advice: deny.policy.advice
};
}
const denyIndeterminate = evaluated
.filter(
(entry) =>
matched(entry) &&
entry.policy.effect === PERMISSION_EFFECT_DENY &&
evaluatedUnknownOrError(entry)
)
.sort(byPriorityDesc)[0];
if (denyIndeterminate?.evaluation) {
return {
effect: PERMISSION_EFFECT_INDETERMINATE,
policy: denyIndeterminate.policy.id,
reason:
denyIndeterminate.evaluation.reason ??
denyIndeterminate.policy.reason ??
PERMISSION_ERROR_MSG_DENY_INDETERMINATE,
fallback: PERMISSION_FALLBACK_DENY,
errors: denyIndeterminate.evaluation.error ? [denyIndeterminate.evaluation.error] : undefined
};
}
const allow = applicable
.filter((entry) => entry.policy.effect === PERMISSION_EFFECT_ALLOW)
.sort(byPriorityDesc)[0];
if (allow) {
return {
effect: PERMISSION_EFFECT_ALLOW,
policy: allow.policy.id,
reason: allow.policy.reason,
obligations: allow.policy.obligations,
advice: allow.policy.advice
};
}
const errored = evaluated.find(
(entry) => matched(entry) && entry.evaluation?.value === PERMISSION_EVAL_ERROR
);
if (errored?.evaluation) {
return {
effect: PERMISSION_EFFECT_INDETERMINATE,
reason: errored.evaluation.reason ?? PERMISSION_ERROR_MSG_EVALUATION_FAILED_DECISION,
fallback: defaultFallback,
errors: errored.evaluation.error ? [errored.evaluation.error] : undefined
};
}
const unknown = evaluated.find(
(entry) => matched(entry) && entry.evaluation?.value === PERMISSION_EVAL_UNKNOWN
);
if (unknown?.evaluation) {
return {
effect: PERMISSION_EFFECT_INDETERMINATE,
reason: unknown.evaluation.reason ?? PERMISSION_ERROR_MSG_EVALUATION_UNKNOWN_DECISION,
fallback: defaultFallback
};
}
return {
effect: PERMISSION_EFFECT_NOT_APPLICABLE,
reason: PERMISSION_ERROR_MSG_NOT_APPLICABLE
};
}
export function toTrace(evaluated: readonly EvaluatedPolicy[]): TraceEntry[] {
return evaluated.map((entry) => ({
policy: entry.policy.id,
effect: entry.policy.effect,
targetMatched: entry.targetMatched,
condition: entry.targetMatched ? entry.policy.condition : undefined,
result: entry.evaluation?.value,
reason: entry.evaluation?.reason,
dependencies: entry.evaluation?.dependencies ?? []
}));
}

@ -0,0 +1,234 @@
import {
PERMISSION_EFFECT_ALLOW,
PERMISSION_EFFECT_DENY,
PERMISSION_ERROR_MSG_NO_SQL_ALLOW,
PERMISSION_ERROR_MSG_SQL_RESIDUAL,
PERMISSION_EXPR_AND,
PERMISSION_EXPR_CONST,
PERMISSION_EXPR_CONTAINS,
PERMISSION_EXPR_EQ,
PERMISSION_EXPR_EXISTS,
PERMISSION_EXPR_GT,
PERMISSION_EXPR_GTE,
PERMISSION_EXPR_IN,
PERMISSION_EXPR_LT,
PERMISSION_EXPR_LTE,
PERMISSION_EXPR_NEQ,
PERMISSION_EXPR_NOT,
PERMISSION_EXPR_OR,
PERMISSION_EXPR_REF,
PERMISSION_EXPR_REL,
PERMISSION_QUERY_STRATEGY_COMPILED,
PERMISSION_QUERY_STRATEGY_NOT_COMPILABLE,
PERMISSION_QUERY_STRATEGY_PARTIAL,
PERMISSION_QUERY_TARGET_SQL,
PERMISSION_ROOT_ACTOR,
PERMISSION_ROOT_CONTEXT,
PERMISSION_ROOT_RESOURCE,
PERMISSION_SQL_AND,
PERMISSION_SQL_DEFAULT_RESOURCE_ALIAS,
PERMISSION_SQL_FALSE,
PERMISSION_SQL_OR,
PERMISSION_SQL_PARAM_PREFIX,
PERMISSION_SQL_TRUE
} from '../consts.ts';
import { actionMatches } from '../match.ts';
import type { ExprIR, PolicyIR, QueryCompiler, QueryPlan, SubjectRef } from '../types.ts';
export interface SqlCompileResult {
readonly sql: string;
readonly params: Record<string, unknown>;
}
export interface SqlRelationCompilerInput {
readonly relation: string;
readonly resourceAlias: string;
readonly actor: SubjectRef;
param(value: unknown): string;
}
export type SqlRelationCompiler = (input: SqlRelationCompilerInput) => string | undefined;
export interface CreateSqlCompilerOptions {
readonly resourceAlias?: string;
readonly relation?: SqlRelationCompiler;
readonly columnName?: (path: string) => string;
}
function defaultColumnName(path: string): string {
return path.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
}
function relevantPolicies(
policies: readonly PolicyIR[],
action: string,
resourceType: string
): PolicyIR[] {
return policies.filter((policy) => {
const resourceMatches = !policy.target.resource || policy.target.resource === resourceType;
return resourceMatches && actionMatches(policy.target.action, action);
});
}
export function createSqlCompiler(options: CreateSqlCompilerOptions = {}): QueryCompiler {
const alias = options.resourceAlias ?? PERMISSION_SQL_DEFAULT_RESOURCE_ALIAS;
const columnName = options.columnName ?? defaultColumnName;
return {
target: PERMISSION_QUERY_TARGET_SQL,
compile(input): QueryPlan {
const policies = relevantPolicies(input.policies, input.action, input.resourceType);
const allowPolicies = policies.filter((policy) => policy.effect === PERMISSION_EFFECT_ALLOW);
const denyPolicies = policies.filter((policy) => policy.effect === PERMISSION_EFFECT_DENY);
const params: Record<string, unknown> = {};
let counter = 0;
const warnings: string[] = [];
const residualPolicies: PolicyIR[] = [];
const param = (value: unknown): string => {
const key = `${PERMISSION_SQL_PARAM_PREFIX}${++counter}`;
params[key] = value;
return `:${key}`;
};
const compileExpr = (expr: ExprIR): string | undefined => {
switch (expr.op) {
case PERMISSION_EXPR_CONST:
if (typeof expr.value === 'boolean') {
return expr.value ? PERMISSION_SQL_TRUE : PERMISSION_SQL_FALSE;
}
return param(expr.value);
case PERMISSION_EXPR_REF:
if (expr.root === PERMISSION_ROOT_RESOURCE) return `${alias}.${columnName(expr.path)}`;
if (expr.root === PERMISSION_ROOT_ACTOR) {
const value = expr.path
? (input.actor as Record<string, unknown>)[expr.path]
: input.actor;
return param(value);
}
if (expr.root === PERMISSION_ROOT_CONTEXT) {
const value = expr.path ? input.context?.[expr.path] : input.context;
return param(value);
}
return undefined;
case PERMISSION_EXPR_EQ:
case PERMISSION_EXPR_NEQ:
case PERMISSION_EXPR_GT:
case PERMISSION_EXPR_GTE:
case PERMISSION_EXPR_LT:
case PERMISSION_EXPR_LTE: {
const left = compileExpr(expr.left);
const right = compileExpr(expr.right);
if (!left || !right) return undefined;
const op =
expr.op === PERMISSION_EXPR_EQ
? '='
: expr.op === PERMISSION_EXPR_NEQ
? '<>'
: expr.op === PERMISSION_EXPR_GT
? '>'
: expr.op === PERMISSION_EXPR_GTE
? '>='
: expr.op === PERMISSION_EXPR_LT
? '<'
: '<=';
return `(${left} ${op} ${right})`;
}
case PERMISSION_EXPR_AND: {
const parts = expr.args.map(compileExpr);
if (parts.some((part) => !part)) return undefined;
return `(${parts.join(PERMISSION_SQL_AND)})`;
}
case PERMISSION_EXPR_OR: {
const parts = expr.args.map(compileExpr);
if (parts.some((part) => !part)) return undefined;
return `(${parts.join(PERMISSION_SQL_OR)})`;
}
case PERMISSION_EXPR_NOT: {
const inner = compileExpr(expr.expr);
return inner ? `(NOT ${inner})` : undefined;
}
case PERMISSION_EXPR_IN: {
const value = compileExpr(expr.value);
if (!value) return undefined;
if (expr.set.op === PERMISSION_EXPR_CONST && Array.isArray(expr.set.value)) {
const list = expr.set.value.map(param).join(', ');
return `(${value} IN (${list}))`;
}
const set = compileExpr(expr.set);
return set ? `(${value} IN ${set})` : undefined;
}
case PERMISSION_EXPR_CONTAINS: {
const value = compileExpr(expr.value);
if (!value) return undefined;
if (expr.set.op === PERMISSION_EXPR_CONST && Array.isArray(expr.set.value)) {
const list = expr.set.value.map(param).join(', ');
return `(${value} IN (${list}))`;
}
return undefined;
}
case PERMISSION_EXPR_REL: {
const sql = options.relation?.({
relation: expr.path,
resourceAlias: alias,
actor: input.actor,
param
});
return sql ? `(${sql})` : undefined;
}
case PERMISSION_EXPR_EXISTS:
return undefined;
}
};
const allowSql: string[] = [];
for (const policy of allowPolicies) {
const compiled = compileExpr(policy.condition);
if (compiled) allowSql.push(compiled);
else residualPolicies.push(policy);
}
const denySql: string[] = [];
for (const policy of denyPolicies) {
const compiled = compileExpr(policy.condition);
if (compiled) denySql.push(compiled);
else residualPolicies.push(policy);
}
if (allowSql.length === 0 && residualPolicies.length > 0) {
return {
strategy: PERMISSION_QUERY_STRATEGY_NOT_COMPILABLE,
target: PERMISSION_QUERY_TARGET_SQL,
residualPolicies,
warnings: [PERMISSION_ERROR_MSG_NO_SQL_ALLOW]
};
}
let sql =
allowSql.length > 0 ? `(${allowSql.join(PERMISSION_SQL_OR)})` : PERMISSION_SQL_FALSE;
if (denySql.length > 0) sql = `(${sql}) AND NOT (${denySql.join(PERMISSION_SQL_OR)})`;
if (residualPolicies.length > 0) warnings.push(PERMISSION_ERROR_MSG_SQL_RESIDUAL);
return {
strategy:
residualPolicies.length > 0
? PERMISSION_QUERY_STRATEGY_PARTIAL
: PERMISSION_QUERY_STRATEGY_COMPILED,
target: PERMISSION_QUERY_TARGET_SQL,
predicate: { sql, params } satisfies SqlCompileResult,
residualPolicies: residualPolicies.length ? residualPolicies : undefined,
warnings: warnings.length ? warnings : undefined
};
}
};
}

@ -0,0 +1,162 @@
export const PERMISSION_EFFECT_ALLOW = 'allow';
export const PERMISSION_EFFECT_DENY = 'deny';
export const PERMISSION_EFFECT_INDETERMINATE = 'indeterminate';
export const PERMISSION_EFFECT_NOT_APPLICABLE = 'not_applicable';
export const PERMISSION_EFFECTS = [
PERMISSION_EFFECT_ALLOW,
PERMISSION_EFFECT_DENY,
PERMISSION_EFFECT_INDETERMINATE,
PERMISSION_EFFECT_NOT_APPLICABLE
] as const;
export const PERMISSION_FALLBACK_DENY = 'deny';
export const PERMISSION_FALLBACK_ALLOW = 'allow';
export const PERMISSION_FALLBACKS = [PERMISSION_FALLBACK_DENY, PERMISSION_FALLBACK_ALLOW] as const;
export const PERMISSION_EVAL_TRUE = true;
export const PERMISSION_EVAL_FALSE = false;
export const PERMISSION_EVAL_UNKNOWN = 'unknown';
export const PERMISSION_EVAL_ERROR = 'error';
export const PERMISSION_DEP_ACTOR = 'actor';
export const PERMISSION_DEP_RESOURCE = 'resource';
export const PERMISSION_DEP_CONTEXT = 'context';
export const PERMISSION_DEP_RELATION = 'relation';
export const PERMISSION_DEPENDENCY_KINDS = [
PERMISSION_DEP_ACTOR,
PERMISSION_DEP_RESOURCE,
PERMISSION_DEP_CONTEXT,
PERMISSION_DEP_RELATION
] as const;
export const PERMISSION_ROOT_ACTOR = 'actor';
export const PERMISSION_ROOT_RESOURCE = 'resource';
export const PERMISSION_ROOT_CONTEXT = 'context';
export const PERMISSION_REF_ROOTS = [
PERMISSION_ROOT_ACTOR,
PERMISSION_ROOT_RESOURCE,
PERMISSION_ROOT_CONTEXT
] as const;
export const PERMISSION_EXPR_CONST = 'const';
export const PERMISSION_EXPR_REF = 'ref';
export const PERMISSION_EXPR_EQ = 'eq';
export const PERMISSION_EXPR_NEQ = 'neq';
export const PERMISSION_EXPR_GT = 'gt';
export const PERMISSION_EXPR_GTE = 'gte';
export const PERMISSION_EXPR_LT = 'lt';
export const PERMISSION_EXPR_LTE = 'lte';
export const PERMISSION_EXPR_IN = 'in';
export const PERMISSION_EXPR_CONTAINS = 'contains';
export const PERMISSION_EXPR_AND = 'and';
export const PERMISSION_EXPR_OR = 'or';
export const PERMISSION_EXPR_NOT = 'not';
export const PERMISSION_EXPR_REL = 'rel';
export const PERMISSION_EXPR_EXISTS = 'exists';
export const PERMISSION_ACTION_WILDCARD_SUFFIX = '.*';
export const PERMISSION_ACTION_SEPARATOR = '.';
export const PERMISSION_PATH_SEPARATOR = '.';
export const PERMISSION_RESOURCE_KEY_SEPARATOR = ':';
export const PERMISSION_RESOURCE_KEY_NONE = 'none';
export const PERMISSION_RESOURCE_KEY_UNKNOWN = 'unknown';
export const PERMISSION_DEFAULT_SUBJECT_TYPE = 'user';
export const PERMISSION_DEFAULT_RESOURCE_TYPE = 'resource';
export const PERMISSION_POLICY_DEFAULT_PRIORITY = 0;
export const PERMISSION_POLICY_AUTO_SEPARATOR = '.';
export const PERMISSION_DECISION_CODE_DENIED = 'permission_denied';
export const PERMISSION_DECISION_CODE_SNAPSHOT_DENIED = 'snapshot_denied';
export const PERMISSION_OBLIGATION_MASK = 'mask';
export const PERMISSION_OBLIGATION_REDACT = 'redact';
export const PERMISSION_OBLIGATION_REQUIRE_MFA = 'require_mfa';
export const PERMISSION_OBLIGATION_AUDIT = 'audit';
export const PERMISSION_MASK_MODE_FULL = 'full';
export const PERMISSION_MASK_MODE_PARTIAL = 'partial';
export const PERMISSION_SEVERITY_LOW = 'low';
export const PERMISSION_SEVERITY_MEDIUM = 'medium';
export const PERMISSION_SEVERITY_HIGH = 'high';
export const PERMISSION_QUERY_STRATEGY_COMPILED = 'compiled';
export const PERMISSION_QUERY_STRATEGY_PARTIAL = 'partial';
export const PERMISSION_QUERY_STRATEGY_NOT_COMPILABLE = 'not_compilable';
export const PERMISSION_QUERY_TARGET_MEMORY = 'memory';
export const PERMISSION_QUERY_TARGET_SQL = 'sql';
export const PERMISSION_REVERSE_SOURCE_RELATION = 'relation';
export const PERMISSION_REVERSE_SOURCE_RELATION_EXPANSION = 'relationExpansion';
export const PERMISSION_REVERSE_SOURCE_POLICY = 'policy';
export const PERMISSION_REVERSE_CONSTRAINT_CONTEXT = 'context';
export const PERMISSION_REVERSE_CONSTRAINT_ATTRIBUTE = 'attribute';
export const PERMISSION_REVERSE_CONSTRAINT_UNKNOWN = 'unknown';
export const PERMISSION_SQL_DEFAULT_RESOURCE_ALIAS = 'resource';
export const PERMISSION_SQL_PARAM_PREFIX = 'p';
export const PERMISSION_SQL_TRUE = 'TRUE';
export const PERMISSION_SQL_FALSE = 'FALSE';
export const PERMISSION_SQL_AND = ' AND ';
export const PERMISSION_SQL_OR = ' OR ';
export const PERMISSION_ERROR_NAME_DENIED = 'PermissionDeniedError';
export const PERMISSION_ERROR_NAME_SCHEMA = 'PermissionSchemaError';
export const PERMISSION_ERROR_NAME_RUNTIME = 'PermissionRuntimeError';
export const PERMISSION_ERROR_MSG_DENIED_PREFIX = 'Permission denied: ';
export const PERMISSION_ERROR_MSG_SCHEMA_REQUIRES_RESOURCE =
'permission schema requires at least one resource';
export const PERMISSION_ERROR_MSG_RESOURCE_REQUIRES_ACTIONS_PREFIX =
'resource requires actions[]: ';
export const PERMISSION_ERROR_MSG_RELATION_UNKNOWN_FROM_PREFIX =
'relation points from unknown resource: ';
export const PERMISSION_ERROR_MSG_RELATION_UNKNOWN_TO_PREFIX =
'relation points to unknown actor/resource: ';
export const PERMISSION_ERROR_MSG_EVALUATION_FAILED = 'Expression evaluation failed';
export const PERMISSION_ERROR_MSG_COMPARE_PREFIX = 'Cannot compare values using ';
export const PERMISSION_ERROR_MSG_IN_REQUIRES_ARRAY = 'Right side of in() is not an array';
export const PERMISSION_ERROR_MSG_CONTAINS_REQUIRES_ARRAY = 'contains() target is not an array';
export const PERMISSION_ERROR_MSG_RELATION_REQUIRES_RESOURCE_PREFIX =
'Relation requires a resource: ';
export const PERMISSION_ERROR_MSG_RELATION_REQUIRES_SUBJECT_PREFIX =
'Relation requires a subject: ';
export const PERMISSION_ERROR_MSG_RELATION_PROVIDER_MISSING_PREFIX =
'No relation provider configured for ';
export const PERMISSION_ERROR_MSG_RELATION_UNKNOWN_PREFIX = 'Relation is unknown: ';
export const PERMISSION_ERROR_MSG_EXISTS_REQUIRES_PROVIDER =
'exists() requires a query-capable provider';
export const PERMISSION_ERROR_MSG_EVALUATION_FAILED_DECISION =
'Authorization policy evaluation failed';
export const PERMISSION_ERROR_MSG_EVALUATION_UNKNOWN_DECISION =
'Authorization policy evaluation is unknown';
export const PERMISSION_ERROR_MSG_DENY_INDETERMINATE = 'Deny policy could not be evaluated safely';
export const PERMISSION_ERROR_MSG_NOT_APPLICABLE =
'No matching policy allowed or denied the request';
export const PERMISSION_ERROR_MSG_FILTER_REQUIRES_ACTOR =
'filter().for(actor) is required before this operation';
export const PERMISSION_ERROR_MSG_NO_COMPILER_PREFIX = 'No compiler configured for target: ';
export const PERMISSION_ERROR_MSG_NO_SQL_ALLOW = 'No allow policy could be compiled to SQL.';
export const PERMISSION_ERROR_MSG_SQL_RESIDUAL =
'Some policies were not compilable and require residual checks.';
export const PERMISSION_ERROR_MSG_REVERSE_NO_RELATION =
'No invertible relation was found. The policy may depend only on attributes or custom predicates.';
export const PERMISSION_ERROR_MSG_MFA_REQUIRED = 'MFA required';
export const PERMISSION_ERROR_MSG_POLICY_UNKNOWN_RESOURCE_PREFIX =
'policy targets unknown resource: ';
export const PERMISSION_ERROR_MSG_POLICY_UNKNOWN_ACTION_PREFIX = 'policy targets unknown action: ';
export const permissionAutoPolicyId = (effect: string, action: string, index: number): string =>
`${effect}${PERMISSION_POLICY_AUTO_SEPARATOR}${action}${PERMISSION_POLICY_AUTO_SEPARATOR}${index}`;
export const permissionDependencyKey = (kind: string, key: string): string => `${kind}:${key}`;
export const permissionDeniedByPolicyReason = (policy: string): string =>
`Denied by policy ${policy}`;

@ -0,0 +1,48 @@
import {
PERMISSION_EFFECT_DENY,
PERMISSION_ERROR_MSG_DENIED_PREFIX,
PERMISSION_ERROR_NAME_DENIED,
PERMISSION_ERROR_NAME_RUNTIME,
PERMISSION_ERROR_NAME_SCHEMA
} from './consts.ts';
import type { PermissionDecision } from './types.ts';
export class PermissionDeniedError extends Error {
readonly decision: PermissionDecision;
constructor(decision: PermissionDecision) {
super(
decision.effect === PERMISSION_EFFECT_DENY
? decision.reason
: `${PERMISSION_ERROR_MSG_DENIED_PREFIX}${decision.effect}`
);
this.name = PERMISSION_ERROR_NAME_DENIED;
this.decision = decision;
}
}
export class PermissionSchemaError extends Error {
constructor(message: string) {
super(message);
this.name = PERMISSION_ERROR_NAME_SCHEMA;
}
}
export class PermissionRuntimeError extends Error {
constructor(message: string) {
super(message);
this.name = PERMISSION_ERROR_NAME_RUNTIME;
}
}
export function isPermissionDeniedError(error: unknown): error is PermissionDeniedError {
return error instanceof PermissionDeniedError;
}
export function isPermissionSchemaError(error: unknown): error is PermissionSchemaError {
return error instanceof PermissionSchemaError;
}
export function isPermissionRuntimeError(error: unknown): error is PermissionRuntimeError {
return error instanceof PermissionRuntimeError;
}

@ -0,0 +1,333 @@
import {
PERMISSION_DEP_RELATION,
PERMISSION_ERROR_MSG_COMPARE_PREFIX,
PERMISSION_ERROR_MSG_CONTAINS_REQUIRES_ARRAY,
PERMISSION_ERROR_MSG_EVALUATION_FAILED,
PERMISSION_ERROR_MSG_EXISTS_REQUIRES_PROVIDER,
PERMISSION_ERROR_MSG_IN_REQUIRES_ARRAY,
PERMISSION_ERROR_MSG_RELATION_PROVIDER_MISSING_PREFIX,
PERMISSION_ERROR_MSG_RELATION_REQUIRES_RESOURCE_PREFIX,
PERMISSION_ERROR_MSG_RELATION_REQUIRES_SUBJECT_PREFIX,
PERMISSION_ERROR_MSG_RELATION_UNKNOWN_PREFIX,
PERMISSION_EVAL_ERROR,
PERMISSION_EVAL_UNKNOWN,
PERMISSION_EXPR_AND,
PERMISSION_EXPR_CONST,
PERMISSION_EXPR_CONTAINS,
PERMISSION_EXPR_EQ,
PERMISSION_EXPR_EXISTS,
PERMISSION_EXPR_GT,
PERMISSION_EXPR_GTE,
PERMISSION_EXPR_IN,
PERMISSION_EXPR_LT,
PERMISSION_EXPR_LTE,
PERMISSION_EXPR_NEQ,
PERMISSION_EXPR_NOT,
PERMISSION_EXPR_OR,
PERMISSION_EXPR_REF,
PERMISSION_EXPR_REL,
PERMISSION_PATH_SEPARATOR,
PERMISSION_ROOT_ACTOR,
PERMISSION_ROOT_RESOURCE,
permissionDependencyKey
} from './consts.ts';
import { getPath } from './path.ts';
import type {
DependencyKey,
EvalResult,
EvalValue,
ExprIR,
PermissionProviders,
PermissionRequestContext,
ResourceRef,
SubjectRef
} from './types.ts';
function ok(
value: EvalValue,
dependencies: readonly DependencyKey[] = [],
reason?: string,
error?: unknown
): EvalResult {
const result: EvalResult = { value, dependencies };
if (reason !== undefined) (result as { reason?: string }).reason = reason;
if (error !== undefined) (result as { error?: unknown }).error = error;
return result;
}
function dep(kind: string, key: string): DependencyKey {
return permissionDependencyKey(kind, key) as DependencyKey;
}
function dedupeDeps(deps: readonly DependencyKey[]): DependencyKey[] {
return [...new Set(deps)];
}
function compare(
op: ExprIR['op'],
left: unknown,
right: unknown
): boolean | typeof PERMISSION_EVAL_UNKNOWN {
switch (op) {
case PERMISSION_EXPR_EQ:
return left === right;
case PERMISSION_EXPR_NEQ:
return left !== right;
case PERMISSION_EXPR_GT:
return typeof left === 'number' && typeof right === 'number'
? left > right
: PERMISSION_EVAL_UNKNOWN;
case PERMISSION_EXPR_GTE:
return typeof left === 'number' && typeof right === 'number'
? left >= right
: PERMISSION_EVAL_UNKNOWN;
case PERMISSION_EXPR_LT:
return typeof left === 'number' && typeof right === 'number'
? left < right
: PERMISSION_EVAL_UNKNOWN;
case PERMISSION_EXPR_LTE:
return typeof left === 'number' && typeof right === 'number'
? left <= right
: PERMISSION_EVAL_UNKNOWN;
default:
return PERMISSION_EVAL_UNKNOWN;
}
}
export class DefaultPermissionEvaluator {
constructor(private readonly providers: PermissionProviders = {}) {}
async evaluate(expr: ExprIR, context: PermissionRequestContext): Promise<EvalResult> {
try {
return await this.evaluateInternal(expr, context);
} catch (error) {
return ok(PERMISSION_EVAL_ERROR, [], PERMISSION_ERROR_MSG_EVALUATION_FAILED, error);
}
}
private async rawValue(
expr: ExprIR,
context: PermissionRequestContext
): Promise<{ value: unknown; dependencies: readonly DependencyKey[]; unknown?: string }> {
if (expr.op === PERMISSION_EXPR_CONST) return { value: expr.value, dependencies: [] };
if (expr.op === PERMISSION_EXPR_REF) {
const key = expr.path ? `${expr.root}${PERMISSION_PATH_SEPARATOR}${expr.path}` : expr.root;
const dependency = dep(expr.root, key);
if (this.providers.attributes) {
const provided = await this.providers.attributes.getAttribute({
root: expr.root,
path: expr.path,
context
});
return { value: provided, dependencies: [dependency] };
}
const root =
expr.root === PERMISSION_ROOT_ACTOR
? context.actor
: expr.root === PERMISSION_ROOT_RESOURCE
? context.resource
: context.context;
return { value: getPath(root, expr.path), dependencies: [dependency] };
}
if (expr.op === PERMISSION_EXPR_REL) {
const evaluated = await this.evaluateInternal(expr, context);
const out: { value: unknown; dependencies: readonly DependencyKey[]; unknown?: string } = {
value: evaluated.value === true,
dependencies: evaluated.dependencies
};
if (evaluated.value === PERMISSION_EVAL_UNKNOWN && evaluated.reason !== undefined) {
out.unknown = evaluated.reason;
}
return out;
}
const evaluated = await this.evaluateInternal(expr, context);
if (evaluated.value === PERMISSION_EVAL_UNKNOWN || evaluated.value === PERMISSION_EVAL_ERROR) {
return {
value: undefined,
dependencies: evaluated.dependencies,
unknown: evaluated.reason ?? String(evaluated.value)
};
}
return { value: evaluated.value, dependencies: evaluated.dependencies };
}
private async evaluateInternal(
expr: ExprIR,
context: PermissionRequestContext
): Promise<EvalResult> {
switch (expr.op) {
case PERMISSION_EXPR_CONST:
return ok(Boolean(expr.value));
case PERMISSION_EXPR_REF: {
const { value, dependencies } = await this.rawValue(expr, context);
return ok(Boolean(value), dependencies);
}
case PERMISSION_EXPR_EQ:
case PERMISSION_EXPR_NEQ:
case PERMISSION_EXPR_GT:
case PERMISSION_EXPR_GTE:
case PERMISSION_EXPR_LT:
case PERMISSION_EXPR_LTE: {
const left = await this.rawValue(expr.left, context);
const right = await this.rawValue(expr.right, context);
const dependencies = dedupeDeps([...left.dependencies, ...right.dependencies]);
if (left.unknown || right.unknown) {
return ok(PERMISSION_EVAL_UNKNOWN, dependencies, left.unknown ?? right.unknown);
}
const value = compare(expr.op, left.value, right.value);
return value === PERMISSION_EVAL_UNKNOWN
? ok(
PERMISSION_EVAL_UNKNOWN,
dependencies,
`${PERMISSION_ERROR_MSG_COMPARE_PREFIX}${expr.op}`
)
: ok(value, dependencies);
}
case PERMISSION_EXPR_IN: {
const value = await this.rawValue(expr.value, context);
const set = await this.rawValue(expr.set, context);
const dependencies = dedupeDeps([...value.dependencies, ...set.dependencies]);
if (value.unknown || set.unknown) {
return ok(PERMISSION_EVAL_UNKNOWN, dependencies, value.unknown ?? set.unknown);
}
if (!Array.isArray(set.value)) {
return ok(PERMISSION_EVAL_UNKNOWN, dependencies, PERMISSION_ERROR_MSG_IN_REQUIRES_ARRAY);
}
return ok(set.value.includes(value.value), dependencies);
}
case PERMISSION_EXPR_CONTAINS: {
const set = await this.rawValue(expr.set, context);
const value = await this.rawValue(expr.value, context);
const dependencies = dedupeDeps([...value.dependencies, ...set.dependencies]);
if (value.unknown || set.unknown) {
return ok(PERMISSION_EVAL_UNKNOWN, dependencies, value.unknown ?? set.unknown);
}
if (!Array.isArray(set.value)) {
return ok(
PERMISSION_EVAL_UNKNOWN,
dependencies,
PERMISSION_ERROR_MSG_CONTAINS_REQUIRES_ARRAY
);
}
return ok(set.value.includes(value.value), dependencies);
}
case PERMISSION_EXPR_AND: {
const dependencies: DependencyKey[] = [];
let unknownReason: string | undefined;
for (const arg of expr.args) {
const result = await this.evaluateInternal(arg, context);
dependencies.push(...result.dependencies);
if (result.value === false) return ok(false, dedupeDeps(dependencies));
if (result.value === PERMISSION_EVAL_ERROR) {
return ok(PERMISSION_EVAL_ERROR, dedupeDeps(dependencies), result.reason, result.error);
}
if (result.value === PERMISSION_EVAL_UNKNOWN) unknownReason ??= result.reason;
}
return unknownReason
? ok(PERMISSION_EVAL_UNKNOWN, dedupeDeps(dependencies), unknownReason)
: ok(true, dedupeDeps(dependencies));
}
case PERMISSION_EXPR_OR: {
const dependencies: DependencyKey[] = [];
let unknownReason: string | undefined;
for (const arg of expr.args) {
const result = await this.evaluateInternal(arg, context);
dependencies.push(...result.dependencies);
if (result.value === true) return ok(true, dedupeDeps(dependencies));
if (result.value === PERMISSION_EVAL_ERROR) {
return ok(PERMISSION_EVAL_ERROR, dedupeDeps(dependencies), result.reason, result.error);
}
if (result.value === PERMISSION_EVAL_UNKNOWN) unknownReason ??= result.reason;
}
return unknownReason
? ok(PERMISSION_EVAL_UNKNOWN, dedupeDeps(dependencies), unknownReason)
: ok(false, dedupeDeps(dependencies));
}
case PERMISSION_EXPR_NOT: {
const result = await this.evaluateInternal(expr.expr, context);
if (result.value === true) return ok(false, result.dependencies);
if (result.value === false) return ok(true, result.dependencies);
return result;
}
case PERMISSION_EXPR_REL: {
const relationKey = dep(PERMISSION_DEP_RELATION, expr.path);
const resourceValue = expr.resource
? await this.rawValue(expr.resource, context)
: { value: context.resource, dependencies: [] };
const subjectValue = await this.rawValue(expr.subject, context);
const dependencies = dedupeDeps([
relationKey,
...resourceValue.dependencies,
...subjectValue.dependencies
]);
if (resourceValue.unknown || subjectValue.unknown) {
return ok(
PERMISSION_EVAL_UNKNOWN,
dependencies,
resourceValue.unknown ?? subjectValue.unknown
);
}
const resource = resourceValue.value as ResourceRef | undefined;
const subject = subjectValue.value as SubjectRef | undefined;
if (!resource || typeof resource !== 'object' || !resource.type) {
return ok(
PERMISSION_EVAL_UNKNOWN,
dependencies,
`${PERMISSION_ERROR_MSG_RELATION_REQUIRES_RESOURCE_PREFIX}${expr.path}`
);
}
if (!subject || typeof subject !== 'object' || !subject.type || !subject.id) {
return ok(
PERMISSION_EVAL_UNKNOWN,
dependencies,
`${PERMISSION_ERROR_MSG_RELATION_REQUIRES_SUBJECT_PREFIX}${expr.path}`
);
}
if (!this.providers.relations) {
return ok(
PERMISSION_EVAL_UNKNOWN,
dependencies,
`${PERMISSION_ERROR_MSG_RELATION_PROVIDER_MISSING_PREFIX}${expr.path}`
);
}
const relationResult = await this.providers.relations.hasRelation({
relation: expr.path,
resource,
subject,
context
});
if (relationResult === PERMISSION_EVAL_UNKNOWN) {
return ok(
PERMISSION_EVAL_UNKNOWN,
dependencies,
`${PERMISSION_ERROR_MSG_RELATION_UNKNOWN_PREFIX}${expr.path}`
);
}
return ok(relationResult, dependencies);
}
case PERMISSION_EXPR_EXISTS:
return ok(
PERMISSION_EVAL_UNKNOWN,
[dep(PERMISSION_DEP_RELATION, expr.relation)],
PERMISSION_ERROR_MSG_EXISTS_REQUIRES_PROVIDER
);
}
}
}

@ -0,0 +1,12 @@
export * from './consts.ts';
export * from './types.ts';
export * from './errors.ts';
export * from './path.ts';
export * from './schema.ts';
export * from './builder.ts';
export * from './match.ts';
export * from './evaluator.ts';
export * from './combiner.ts';
export * from './reverse.ts';
export * from './runtime.ts';
export * from './compilers/sql.ts';

@ -0,0 +1,28 @@
import { PERMISSION_ACTION_WILDCARD_SUFFIX, PERMISSION_PATH_SEPARATOR } from './consts.ts';
import { actionResource } from './schema.ts';
import type { PermissionRequestContext, PolicyIR } from './types.ts';
export function actionMatches(pattern: string, action: string): boolean {
if (pattern === action) return true;
if (pattern.endsWith(PERMISSION_ACTION_WILDCARD_SUFFIX)) {
const prefix = pattern.slice(0, -1);
return action.startsWith(prefix);
}
return false;
}
export function targetMatches(policy: PolicyIR, context: PermissionRequestContext): boolean {
if (!actionMatches(policy.target.action, context.action)) return false;
const policyResource = policy.target.resource ?? actionResource(policy.target.action);
const requestResource = context.resource?.type ?? actionResource(context.action);
if (!policyResource || !requestResource) return true;
return policyResource === requestResource;
}
export function actionsForResource(resourceType: string, actions: readonly string[]): string[] {
return actions.map((action) =>
action.includes(PERMISSION_PATH_SEPARATOR)
? action
: `${resourceType}${PERMISSION_PATH_SEPARATOR}${action}`
);
}

@ -0,0 +1,43 @@
import type { Dict } from './types.ts';
import {
PERMISSION_PATH_SEPARATOR,
PERMISSION_ROOT_ACTOR,
PERMISSION_ROOT_CONTEXT,
PERMISSION_ROOT_RESOURCE
} from './consts.ts';
export function getPath(input: unknown, path: string): unknown {
if (path === '' || path === '.') return input;
const parts = path.split(PERMISSION_PATH_SEPARATOR).filter(Boolean);
let current: unknown = input;
for (const part of parts) {
if (current == null || typeof current !== 'object') return undefined;
current = (current as Dict)[part];
}
return current;
}
export function setPath(input: Record<string, unknown>, path: string, value: unknown): void {
const parts = path.split(PERMISSION_PATH_SEPARATOR).filter(Boolean);
if (parts.length === 0) return;
let current: Record<string, unknown> = input;
for (const part of parts.slice(0, -1)) {
const next = current[part];
if (!next || typeof next !== 'object') current[part] = {};
current = current[part] as Record<string, unknown>;
}
current[parts[parts.length - 1]!] = value;
}
export function stripResourcePrefix(path: string): string {
const parts = path.split(PERMISSION_PATH_SEPARATOR).filter(Boolean);
if (parts.length <= 1) return path;
if (
parts[0] === PERMISSION_ROOT_ACTOR ||
parts[0] === PERMISSION_ROOT_RESOURCE ||
parts[0] === PERMISSION_ROOT_CONTEXT
) {
return parts.slice(1).join(PERMISSION_PATH_SEPARATOR);
}
return parts.slice(1).join(PERMISSION_PATH_SEPARATOR);
}

@ -0,0 +1,111 @@
import {
PERMISSION_ERROR_MSG_REVERSE_NO_RELATION,
PERMISSION_EFFECT_ALLOW,
PERMISSION_EXPR_AND,
PERMISSION_EXPR_CONTAINS,
PERMISSION_EXPR_CONST,
PERMISSION_EXPR_EQ,
PERMISSION_EXPR_EXISTS,
PERMISSION_EXPR_GT,
PERMISSION_EXPR_GTE,
PERMISSION_EXPR_IN,
PERMISSION_EXPR_LT,
PERMISSION_EXPR_LTE,
PERMISSION_EXPR_NEQ,
PERMISSION_EXPR_NOT,
PERMISSION_EXPR_OR,
PERMISSION_EXPR_REF,
PERMISSION_EXPR_REL,
PERMISSION_PATH_SEPARATOR,
PERMISSION_REVERSE_CONSTRAINT_ATTRIBUTE,
PERMISSION_REVERSE_CONSTRAINT_CONTEXT,
PERMISSION_REVERSE_SOURCE_RELATION,
PERMISSION_REVERSE_SOURCE_RELATION_EXPANSION,
PERMISSION_ROOT_CONTEXT
} from './consts.ts';
import type { ExprIR, PolicyIR, ResourceRef, ReverseQueryResult } from './types.ts';
function collect(
expr: ExprIR,
result: ReverseQueryResult,
policy: PolicyIR,
resource: ResourceRef
): void {
switch (expr.op) {
case PERMISSION_EXPR_REL:
result.sources.push({
type: expr.path.includes(PERMISSION_PATH_SEPARATOR)
? PERMISSION_REVERSE_SOURCE_RELATION_EXPANSION
: PERMISSION_REVERSE_SOURCE_RELATION,
path: expr.path,
resource
});
return;
case PERMISSION_EXPR_AND:
case PERMISSION_EXPR_OR:
for (const arg of expr.args) collect(arg, result, policy, resource);
return;
case PERMISSION_EXPR_NOT:
collect(expr.expr, result, policy, resource);
return;
case PERMISSION_EXPR_EQ:
case PERMISSION_EXPR_NEQ:
case PERMISSION_EXPR_GT:
case PERMISSION_EXPR_GTE:
case PERMISSION_EXPR_LT:
case PERMISSION_EXPR_LTE:
if (expr.left.op === PERMISSION_EXPR_REF && expr.right.op === PERMISSION_EXPR_CONST) {
if (expr.left.root === PERMISSION_ROOT_CONTEXT) {
result.constraints.push({
type: PERMISSION_REVERSE_CONSTRAINT_CONTEXT,
path: expr.left.path,
required: expr.right.value,
policy: policy.id
});
return;
}
result.constraints.push({
type: PERMISSION_REVERSE_CONSTRAINT_ATTRIBUTE,
path: `${expr.left.root}${PERMISSION_PATH_SEPARATOR}${expr.left.path}`,
required: expr.right.value,
policy: policy.id
});
}
return;
case PERMISSION_EXPR_IN:
case PERMISSION_EXPR_CONTAINS:
case PERMISSION_EXPR_EXISTS:
case PERMISSION_EXPR_CONST:
case PERMISSION_EXPR_REF:
return;
}
}
export function reversePolicies(input: {
readonly policies: readonly PolicyIR[];
readonly action: string;
readonly resource: ResourceRef;
readonly subjectType: string;
}): ReverseQueryResult {
const result: ReverseQueryResult = {
action: input.action,
resource: input.resource,
subjectType: input.subjectType,
sources: [],
constraints: [],
warnings: []
};
for (const policy of input.policies) {
if (policy.effect !== PERMISSION_EFFECT_ALLOW) continue;
collect(policy.condition, result, policy, input.resource);
}
if (result.sources.length === 0) result.warnings.push(PERMISSION_ERROR_MSG_REVERSE_NO_RELATION);
return result;
}

@ -0,0 +1,199 @@
import {
PERMISSION_ERROR_MSG_FILTER_REQUIRES_ACTOR,
PERMISSION_ERROR_MSG_NO_COMPILER_PREFIX,
PERMISSION_ACTION_WILDCARD_SUFFIX,
PERMISSION_DEFAULT_RESOURCE_TYPE,
PERMISSION_DEFAULT_SUBJECT_TYPE,
PERMISSION_EFFECT_ALLOW,
PERMISSION_FALLBACK_DENY,
PERMISSION_QUERY_STRATEGY_COMPILED,
PERMISSION_QUERY_STRATEGY_NOT_COMPILABLE,
PERMISSION_QUERY_TARGET_MEMORY
} from './consts.ts';
import { PermissionDeniedError } from './errors.ts';
import { DefaultPermissionEvaluator } from './evaluator.ts';
import { combineEvaluatedPolicies, toTrace, type EvaluatedPolicy } from './combiner.ts';
import { actionsForResource, targetMatches } from './match.ts';
import { reversePolicies } from './reverse.ts';
import { actionResource } from './schema.ts';
import type {
DependencyKey,
ExplainResult,
PermissionCheckInput,
PermissionFilterBuilder,
PermissionRequestContext,
PermissionRuntime,
PermissionRuntimeOptions,
PermissionWhatResult,
QueryPlan,
ResourceRef,
ReverseQueryResult,
SubjectRef
} from './types.ts';
function dedupe<T>(values: readonly T[]): T[] {
return [...new Set(values)];
}
function buildContext(input: PermissionCheckInput): PermissionRequestContext {
return {
actor: input.actor,
action: input.action,
resource: input.resource,
context: input.context,
requestId: input.requestId
};
}
export function createPermissionRuntime(options: PermissionRuntimeOptions): PermissionRuntime {
const evaluator = new DefaultPermissionEvaluator(options.providers);
const defaultFallback = options.defaultFallback ?? PERMISSION_FALLBACK_DENY;
async function evaluatePolicies(context: PermissionRequestContext): Promise<EvaluatedPolicy[]> {
const ordered = [...options.policies].sort((a, b) => b.priority - a.priority);
const entries: EvaluatedPolicy[] = [];
for (const policy of ordered) {
const matched = targetMatches(policy, context);
if (!matched) {
entries.push({ policy, targetMatched: false });
continue;
}
const evaluation = await evaluator.evaluate(policy.condition, context);
entries.push({ policy, targetMatched: true, evaluation });
}
return entries;
}
async function explain(input: PermissionCheckInput): Promise<ExplainResult> {
const context = buildContext(input);
const evaluated = await evaluatePolicies(context);
const decision = combineEvaluatedPolicies(evaluated, defaultFallback);
const trace = toTrace(evaluated);
const dependencies = dedupe(trace.flatMap((entry) => entry.dependencies)) as DependencyKey[];
return {
actor: input.actor,
action: input.action,
resource: input.resource,
decision,
trace,
dependencies
};
}
async function check(input: PermissionCheckInput) {
return (await explain(input)).decision;
}
async function can(input: PermissionCheckInput): Promise<boolean> {
const decision = await check(input);
return decision.effect === PERMISSION_EFFECT_ALLOW;
}
async function assertAllowed(input: PermissionCheckInput): Promise<void> {
const decision = await check(input);
if (decision.effect !== PERMISSION_EFFECT_ALLOW) throw new PermissionDeniedError(decision);
}
async function what(
input: Omit<PermissionCheckInput, 'action'> & { readonly actions?: readonly string[] }
): Promise<PermissionWhatResult> {
const resourceType = input.resource?.type;
const actions =
input.actions ??
(resourceType && options.schema.resources[resourceType]
? actionsForResource(resourceType, options.schema.resources[resourceType]!.actions)
: dedupe(
options.policies
.map((policy) => policy.target.action)
.filter((action) => !action.endsWith(PERMISSION_ACTION_WILDCARD_SUFFIX))
));
const decisions: PermissionWhatResult['actions'] = {};
for (const action of actions) decisions[action] = await check({ ...input, action });
return { resource: input.resource, actions: decisions };
}
async function who(
input: PermissionCheckInput & { readonly resource: ResourceRef; readonly subjectType?: string }
): Promise<ReverseQueryResult> {
const context = buildContext(input);
const matchingPolicies = options.policies.filter((policy) => targetMatches(policy, context));
return reversePolicies({
policies: matchingPolicies,
action: input.action,
resource: input.resource,
subjectType: input.subjectType ?? PERMISSION_DEFAULT_SUBJECT_TYPE
});
}
function filter(action: string): PermissionFilterBuilder {
let actor: SubjectRef | undefined;
let resourceType = actionResource(action) ?? PERMISSION_DEFAULT_RESOURCE_TYPE;
let filterContext: Record<string, unknown> | undefined;
const builder: PermissionFilterBuilder = {
for(nextActor: SubjectRef) {
actor = nextActor;
return builder;
},
resource(nextResourceType: string) {
resourceType = nextResourceType;
return builder;
},
context(nextContext: Record<string, unknown>) {
filterContext = nextContext;
return builder;
},
async toPlan(target = PERMISSION_QUERY_TARGET_MEMORY): Promise<QueryPlan> {
if (!actor) throw new Error(PERMISSION_ERROR_MSG_FILTER_REQUIRES_ACTOR);
if (target === PERMISSION_QUERY_TARGET_MEMORY) {
return {
strategy: PERMISSION_QUERY_STRATEGY_COMPILED,
target: PERMISSION_QUERY_TARGET_MEMORY,
predicate: PERMISSION_QUERY_TARGET_MEMORY
};
}
const compiler = options.compilers?.find((candidate) => candidate.target === target);
if (!compiler) {
return {
strategy: PERMISSION_QUERY_STRATEGY_NOT_COMPILABLE,
target,
warnings: [`${PERMISSION_ERROR_MSG_NO_COMPILER_PREFIX}${target}`]
};
}
return compiler.compile({
schema: options.schema,
policies: options.policies,
action,
actor,
resourceType,
context: filterContext
});
},
async toPredicate(): Promise<(resource: ResourceRef) => Promise<boolean>> {
if (!actor) throw new Error(PERMISSION_ERROR_MSG_FILTER_REQUIRES_ACTOR);
return async (resource: ResourceRef) =>
can({ actor: actor!, action, resource, context: filterContext });
}
};
return builder;
}
return {
check,
can,
assert: assertAllowed,
explain,
what,
who,
filter
};
}

@ -0,0 +1,57 @@
import {
PERMISSION_ACTION_SEPARATOR,
PERMISSION_ERROR_MSG_RELATION_UNKNOWN_FROM_PREFIX,
PERMISSION_ERROR_MSG_RELATION_UNKNOWN_TO_PREFIX,
PERMISSION_ERROR_MSG_RESOURCE_REQUIRES_ACTIONS_PREFIX,
PERMISSION_ERROR_MSG_SCHEMA_REQUIRES_RESOURCE,
PERMISSION_RESOURCE_KEY_NONE,
PERMISSION_RESOURCE_KEY_SEPARATOR,
PERMISSION_RESOURCE_KEY_UNKNOWN
} from './consts.ts';
import { PermissionSchemaError } from './errors.ts';
import type { Action, PermSchema, ResourceRef } from './types.ts';
export function definePermSchema<const T extends PermSchema>(schema: T): T {
validatePermSchema(schema);
return schema;
}
export function validatePermSchema(schema: PermSchema): void {
if (!schema.resources || Object.keys(schema.resources).length === 0) {
throw new PermissionSchemaError(PERMISSION_ERROR_MSG_SCHEMA_REQUIRES_RESOURCE);
}
for (const [resource, def] of Object.entries(schema.resources)) {
if (!Array.isArray(def.actions) || def.actions.length === 0) {
throw new PermissionSchemaError(
`${PERMISSION_ERROR_MSG_RESOURCE_REQUIRES_ACTIONS_PREFIX}${resource}`
);
}
}
for (const [relation, def] of Object.entries(schema.relations ?? {})) {
if (!schema.resources[def.from]) {
throw new PermissionSchemaError(
`${PERMISSION_ERROR_MSG_RELATION_UNKNOWN_FROM_PREFIX}${relation}`
);
}
const toIsResource = Boolean(schema.resources[def.to]);
const toIsActor = Boolean(schema.actors?.[def.to]);
if (!toIsResource && !toIsActor) {
throw new PermissionSchemaError(
`${PERMISSION_ERROR_MSG_RELATION_UNKNOWN_TO_PREFIX}${relation}`
);
}
}
}
export function actionResource(action: Action): string | undefined {
const [resource] = String(action).split(PERMISSION_ACTION_SEPARATOR);
return resource || undefined;
}
export function resourceKey(resource?: ResourceRef | string): string {
if (!resource) return PERMISSION_RESOURCE_KEY_NONE;
if (typeof resource === 'string') return resource;
return `${resource.type}${PERMISSION_RESOURCE_KEY_SEPARATOR}${resource.id ?? PERMISSION_RESOURCE_KEY_UNKNOWN}`;
}

@ -0,0 +1,347 @@
import type {
PERMISSION_DEPENDENCY_KINDS,
PERMISSION_EFFECT_ALLOW,
PERMISSION_EFFECT_DENY,
PERMISSION_EFFECT_INDETERMINATE,
PERMISSION_EFFECT_NOT_APPLICABLE,
PERMISSION_FALLBACK_ALLOW,
PERMISSION_FALLBACK_DENY,
PERMISSION_QUERY_STRATEGY_COMPILED,
PERMISSION_QUERY_STRATEGY_NOT_COMPILABLE,
PERMISSION_QUERY_STRATEGY_PARTIAL,
PERMISSION_REVERSE_CONSTRAINT_ATTRIBUTE,
PERMISSION_REVERSE_CONSTRAINT_CONTEXT,
PERMISSION_REVERSE_CONSTRAINT_UNKNOWN,
PERMISSION_REVERSE_SOURCE_POLICY,
PERMISSION_REVERSE_SOURCE_RELATION,
PERMISSION_REVERSE_SOURCE_RELATION_EXPANSION,
PERMISSION_ROOT_ACTOR,
PERMISSION_ROOT_CONTEXT,
PERMISSION_ROOT_RESOURCE,
PERMISSION_SEVERITY_HIGH,
PERMISSION_SEVERITY_LOW,
PERMISSION_SEVERITY_MEDIUM
} from './consts.ts';
export type Primitive = string | number | boolean | null;
export type JsonValue = Primitive | JsonValue[] | { readonly [key: string]: JsonValue };
export type Dict<T = unknown> = Record<string, T>;
export type ResourceType = string;
export type ActorType = string;
export type Action = `${string}.${string}` | `${string}.*` | string;
export interface SubjectRef {
readonly type: ActorType;
readonly id: string;
readonly [key: string]: unknown;
}
export interface ResourceRef {
readonly type: ResourceType;
readonly id?: string;
readonly [key: string]: unknown;
}
export interface PermissionRequestContext {
readonly actor: SubjectRef;
readonly action: string;
readonly resource?: ResourceRef;
readonly context?: Dict;
readonly requestId?: string;
}
export type PermissionEffect =
| typeof PERMISSION_EFFECT_ALLOW
| typeof PERMISSION_EFFECT_DENY
| typeof PERMISSION_EFFECT_INDETERMINATE
| typeof PERMISSION_EFFECT_NOT_APPLICABLE;
export type PermissionFallback = typeof PERMISSION_FALLBACK_DENY | typeof PERMISSION_FALLBACK_ALLOW;
export interface ObligationIR {
readonly type: string;
readonly field?: string;
readonly mode?: string;
readonly event?: string;
readonly severity?:
| typeof PERMISSION_SEVERITY_LOW
| typeof PERMISSION_SEVERITY_MEDIUM
| typeof PERMISSION_SEVERITY_HIGH;
readonly reason?: string;
readonly meta?: Dict;
}
export interface AdviceIR {
readonly type: string;
readonly message?: string;
readonly meta?: Dict;
}
export type PermissionDecision =
| {
readonly effect: typeof PERMISSION_EFFECT_ALLOW;
readonly policy: string;
readonly reason?: string;
readonly obligations?: readonly ObligationIR[];
readonly advice?: readonly AdviceIR[];
readonly ttl?: number;
}
| {
readonly effect: typeof PERMISSION_EFFECT_DENY;
readonly policy?: string;
readonly code: string;
readonly reason: string;
readonly advice?: readonly AdviceIR[];
}
| {
readonly effect: typeof PERMISSION_EFFECT_INDETERMINATE;
readonly reason: string;
readonly fallback: PermissionFallback;
readonly policy?: string;
readonly errors?: readonly unknown[];
}
| {
readonly effect: typeof PERMISSION_EFFECT_NOT_APPLICABLE;
readonly reason?: string;
};
export type PermissionRefRoot =
| typeof PERMISSION_ROOT_ACTOR
| typeof PERMISSION_ROOT_RESOURCE
| typeof PERMISSION_ROOT_CONTEXT;
export type ExprIR =
| { readonly op: 'const'; readonly value: unknown }
| { readonly op: 'ref'; readonly root: PermissionRefRoot; readonly path: string }
| {
readonly op: 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte';
readonly left: ExprIR;
readonly right: ExprIR;
}
| { readonly op: 'in'; readonly value: ExprIR; readonly set: ExprIR }
| { readonly op: 'contains'; readonly set: ExprIR; readonly value: ExprIR }
| { readonly op: 'and' | 'or'; readonly args: readonly ExprIR[] }
| { readonly op: 'not'; readonly expr: ExprIR }
| {
readonly op: 'rel';
readonly path: string;
readonly subject: ExprIR;
readonly resource?: ExprIR;
}
| { readonly op: 'exists'; readonly relation: string; readonly where?: ExprIR };
export interface PolicyTargetIR {
readonly action: string;
readonly resource?: string;
}
export interface PolicyIR {
readonly id: string;
readonly effect: typeof PERMISSION_EFFECT_ALLOW | typeof PERMISSION_EFFECT_DENY;
readonly priority: number;
readonly target: PolicyTargetIR;
readonly condition: ExprIR;
readonly reason?: string;
readonly code?: string;
readonly obligations?: readonly ObligationIR[];
readonly advice?: readonly AdviceIR[];
readonly metadata?: Dict;
}
export type EvalValue = true | false | 'unknown' | 'error';
export interface EvalResult {
readonly value: EvalValue;
readonly reason?: string;
readonly error?: unknown;
readonly dependencies: readonly DependencyKey[];
}
export type DependencyKind = (typeof PERMISSION_DEPENDENCY_KINDS)[number];
export type DependencyKey = `${DependencyKind}:${string}`;
export interface TraceEntry {
readonly policy: string;
readonly effect: typeof PERMISSION_EFFECT_ALLOW | typeof PERMISSION_EFFECT_DENY;
readonly targetMatched: boolean;
readonly condition?: ExprIR;
readonly result?: EvalValue;
readonly reason?: string;
readonly dependencies: readonly DependencyKey[];
}
export interface ExplainResult {
readonly actor: SubjectRef;
readonly action: string;
readonly resource?: ResourceRef;
readonly decision: PermissionDecision;
readonly trace: readonly TraceEntry[];
readonly dependencies: readonly DependencyKey[];
}
export interface ResourceSchema {
readonly actions: readonly string[];
readonly attributes?: Dict;
}
export interface ActorSchema {
readonly actions?: readonly string[];
readonly attributes?: Dict;
}
export interface RelationSchema {
readonly from: string;
readonly to: string;
readonly via?: string;
readonly table?: string;
readonly metadata?: Dict;
}
export interface PermSchema {
readonly actors?: Record<string, ActorSchema>;
readonly resources: Record<string, ResourceSchema>;
readonly relations?: Record<string, RelationSchema>;
readonly context?: Dict;
}
export type QueryPlanStrategy =
| typeof PERMISSION_QUERY_STRATEGY_COMPILED
| typeof PERMISSION_QUERY_STRATEGY_PARTIAL
| typeof PERMISSION_QUERY_STRATEGY_NOT_COMPILABLE;
export interface QueryPlan {
readonly strategy: QueryPlanStrategy;
readonly target: string;
readonly predicate?: unknown;
readonly residualPolicies?: readonly PolicyIR[];
readonly dependencies?: readonly DependencyKey[];
readonly warnings?: readonly string[];
}
export type RelationResult = boolean | 'unknown';
export interface RelationProvider {
hasRelation(input: {
readonly relation: string;
readonly resource: ResourceRef;
readonly subject: SubjectRef;
readonly context: PermissionRequestContext;
}): Promise<RelationResult> | RelationResult;
listSubjects?(input: {
readonly relation: string;
readonly resource: ResourceRef;
readonly subjectType?: string;
readonly context: PermissionRequestContext;
}): Promise<readonly SubjectRef[]> | readonly SubjectRef[];
listResources?(input: {
readonly relation: string;
readonly subject: SubjectRef;
readonly resourceType: string;
readonly context: PermissionRequestContext;
}): Promise<readonly ResourceRef[]> | readonly ResourceRef[];
}
export interface AttributeProvider {
getAttribute(input: {
readonly root: PermissionRefRoot;
readonly path: string;
readonly context: PermissionRequestContext;
}): Promise<unknown> | unknown;
}
export interface SubscriptionProvider {
subscribe(dependencies: readonly DependencyKey[], cb: () => void): () => void;
}
export interface PermissionProviders {
readonly attributes?: AttributeProvider;
readonly relations?: RelationProvider;
readonly subscriptions?: SubscriptionProvider;
}
export interface QueryCompiler {
readonly target: string;
compile(input: {
readonly schema: PermSchema;
readonly policies: readonly PolicyIR[];
readonly action: string;
readonly actor: SubjectRef;
readonly resourceType: string;
readonly context?: Record<string, unknown>;
}): Promise<QueryPlan> | QueryPlan;
}
export interface PermissionRuntimeOptions {
readonly schema: PermSchema;
readonly policies: readonly PolicyIR[];
readonly providers?: PermissionProviders;
readonly compilers?: readonly QueryCompiler[];
readonly defaultFallback?: PermissionFallback;
}
export interface PermissionEvaluator {
evaluate(expr: ExprIR, context: PermissionRequestContext): Promise<EvalResult>;
}
export interface PermissionCheckInput {
readonly actor: SubjectRef;
readonly action: string;
readonly resource?: ResourceRef;
readonly context?: Record<string, unknown>;
readonly requestId?: string;
}
export interface PermissionWhatResult {
readonly resource?: ResourceRef;
readonly actions: Record<string, PermissionDecision>;
}
export interface ReverseQueryResult {
readonly action: string;
readonly resource: ResourceRef;
readonly subjectType: string;
readonly sources: Array<{
readonly type:
| typeof PERMISSION_REVERSE_SOURCE_RELATION
| typeof PERMISSION_REVERSE_SOURCE_RELATION_EXPANSION
| typeof PERMISSION_REVERSE_SOURCE_POLICY;
readonly path?: string;
readonly policy?: string;
readonly resource?: ResourceRef;
}>;
readonly constraints: Array<{
readonly type:
| typeof PERMISSION_REVERSE_CONSTRAINT_CONTEXT
| typeof PERMISSION_REVERSE_CONSTRAINT_ATTRIBUTE
| typeof PERMISSION_REVERSE_CONSTRAINT_UNKNOWN;
readonly path?: string;
readonly required?: unknown;
readonly policy?: string;
}>;
readonly warnings: string[];
}
export interface PermissionFilterBuilder {
for(actor: SubjectRef): PermissionFilterBuilder;
resource(resourceType: string): PermissionFilterBuilder;
context(context: Record<string, unknown>): PermissionFilterBuilder;
toPlan(target?: string): Promise<QueryPlan>;
toPredicate(): Promise<(resource: ResourceRef) => Promise<boolean>>;
}
export interface PermissionRuntime {
check(input: PermissionCheckInput): Promise<PermissionDecision>;
can(input: PermissionCheckInput): Promise<boolean>;
assert(input: PermissionCheckInput): Promise<void>;
explain(input: PermissionCheckInput): Promise<ExplainResult>;
what(
input: Omit<PermissionCheckInput, 'action'> & { readonly actions?: readonly string[] }
): Promise<PermissionWhatResult>;
who(
input: PermissionCheckInput & { readonly resource: ResourceRef; readonly subjectType?: string }
): Promise<ReverseQueryResult>;
filter(action: string): PermissionFilterBuilder;
}

@ -0,0 +1,31 @@
export const DEFAULT_BACKOFF_MIN_DELAY_MS = 500;
export const DEFAULT_BACKOFF_MAX_DELAY_MS = 15_000;
export const DEFAULT_BACKOFF_FACTOR = 1.8;
export const DEFAULT_BACKOFF_JITTER_MS = 500;
export interface BackoffOptions {
readonly minDelayMs?: number;
readonly maxDelayMs?: number;
readonly factor?: number;
readonly jitterMs?: number;
}
export function computeBackoffDelay(
attempt: number,
options: BackoffOptions = {},
random: () => number = Math.random
): number {
const minDelayMs = options.minDelayMs ?? DEFAULT_BACKOFF_MIN_DELAY_MS;
const maxDelayMs = options.maxDelayMs ?? DEFAULT_BACKOFF_MAX_DELAY_MS;
const factor = options.factor ?? DEFAULT_BACKOFF_FACTOR;
const jitterMs = options.jitterMs ?? DEFAULT_BACKOFF_JITTER_MS;
const safeAttempt = Number.isFinite(attempt) && attempt >= 0 ? attempt : 0;
const base = Math.min(maxDelayMs, minDelayMs * Math.pow(factor, safeAttempt));
const jitter = jitterMs > 0 ? (random() * 2 - 1) * jitterMs : 0;
const delay = base + jitter;
if (delay < 0) return 0;
if (delay > maxDelayMs) return maxDelayMs;
return delay;
}

@ -1 +1,2 @@
export * from './debounce.ts';
export * from './backoff.ts';

@ -0,0 +1,54 @@
# perm-authz-ts
Implementación base de un sistema de autorización tipado para aplicaciones TypeScript con dos runtimes:
- `@perm/core`: tipos, schema, builders e IR. No depende de DB, DOM ni framework.
- `@perm/server`: runtime autoritativo para backend: `check`, `assert`, `explain`, `filter`, adapters y endpoints HTTP.
- `@perm/client`: cliente remoto para SPA/SSR: snapshot, cache, batch checks, invalidación.
- `@perm/svelte`: integración Svelte/SvelteKit: context, stores y componente `<Can>`.
La regla de seguridad es: el servidor decide; el cliente refleja. El cliente sirve para UX y reactividad, no como frontera de seguridad.
## Ejemplo rápido
```ts
import { allow, and, attr, definePolicies, definePermSchema, rel, actor } from "@perm/core";
import { createPermissions } from "@perm/server";
const schema = definePermSchema({
resources: {
post: { actions: ["read", "update", "delete"] }
},
actors: {
user: { actions: [] }
},
relations: {
"post.owner": { from: "post", to: "user" }
}
});
const policies = definePolicies(schema, [
allow("post.read").when(attr("post.visibility").eq("public")),
allow("post.update").when(rel("post.owner").is(actor())),
allow("post.delete").when(and(rel("post.owner").is(actor()), attr("actor.role").eq("admin")))
]);
const Perm = createPermissions({
schema,
policies,
providers: {
relations: {
async hasRelation({ relation, resource, subject }) {
if (relation === "post.owner") return resource.ownerId === subject.id;
return "unknown";
}
}
}
});
await Perm.assert({ actor: { type: "user", id: "u1" }, action: "post.update", resource: post });
```
## Estado
Es una primera implementación funcional de arquitectura, no una librería productiva cerrada. Incluye el core, el evaluador, el combinador deny-overrides, cliente remoto, integración Svelte y compilers base. Faltan adapters productivos para Prisma/Drizzle/OpenFGA/SpiceDB, persistencia de auditoría y hardening completo de seguridad.

@ -0,0 +1,91 @@
import { actor, allow, and, attr, audit, definePermSchema, definePolicies, deny, rel } from "@perm/core";
import { createPermissions, createSqlCompiler } from "@perm/server";
export const schema = definePermSchema({
actors: {
user: { attributes: { status: "string", role: "string" } }
},
resources: {
post: {
actions: ["read", "update", "delete", "publish"],
attributes: {
visibility: "string",
ownerId: "string",
teamId: "string",
status: "string"
}
}
},
relations: {
"post.owner": { from: "post", to: "user" },
"post.team.member": { from: "post", to: "user" },
"post.team.admin": { from: "post", to: "user" }
},
context: {
risk: { mfa: "boolean" }
}
});
export const policies = definePolicies(schema, [
deny("post.*")
.id("post.deny.suspended")
.priority(1000)
.when(attr("actor.status").eq("suspended"))
.because("Suspended users cannot access posts", "user_suspended"),
allow("post.read")
.id("post.read.public-or-member")
.when(
// A minimal starter. In production, keep relations backed by DB/OpenFGA/SpiceDB.
attr("post.visibility").eq("public")
),
allow("post.update")
.id("post.update.owner")
.when(
and(
rel("post.owner").is(actor()),
attr("post.status").notEq("archived")
)
),
allow("post.publish")
.id("post.publish.admin-with-mfa")
.when(
and(
rel("post.team.admin").has(actor()),
attr("context.risk.mfa").eq(true)
)
)
.oblige(audit("post.publish", "medium"))
]);
export const Perm = createPermissions({
schema,
policies,
providers: {
relations: {
async hasRelation({ relation, resource, subject }) {
if (relation === "post.owner") return resource.ownerId === subject.id;
if (relation === "post.team.admin") return subject.role === "admin";
if (relation === "post.team.member") return Array.isArray(subject.teamIds) && subject.teamIds.includes(resource.teamId);
return "unknown";
}
}
},
compilers: [
createSqlCompiler({
resourceAlias: "post",
relation({ relation, resourceAlias, actor, param }) {
if (relation === "post.owner") return `${resourceAlias}.owner_id = ${param(actor.id)}`;
if (relation === "post.team.member") {
return `EXISTS (SELECT 1 FROM team_members tm WHERE tm.team_id = ${resourceAlias}.team_id AND tm.user_id = ${param(actor.id)})`;
}
if (relation === "post.team.admin") {
return `EXISTS (SELECT 1 FROM team_members tm WHERE tm.team_id = ${resourceAlias}.team_id AND tm.user_id = ${param(actor.id)} AND tm.role = 'admin')`;
}
return undefined;
}
})
]
});

@ -0,0 +1,13 @@
import { Perm } from "./permissions.js";
export async function updatePostRoute({ user, post, body }: { user: any; post: any; body: any }) {
await Perm.assert({ actor: user, action: "post.update", resource: post });
// db.posts.update(post.id, body)
return { ok: true, updated: body };
}
export async function listPostsRoute({ user }: { user: any }) {
const plan = await Perm.filter("post.read").for(user).resource("post").toPlan("sql");
// db.select().from(posts).where(plan.predicate.sql, plan.predicate.params)
return plan;
}

@ -0,0 +1,15 @@
<script lang="ts">
import Can from "@perm/svelte/Can.svelte";
export let post;
</script>
<Can action="post.update" resource={post}>
<button>Edit</button>
<svelte:fragment slot="fallback">
<button disabled>Edit</button>
</svelte:fragment>
</Can>
<Can action="post.delete" resource={post}>
<button>Delete</button>
</Can>

@ -0,0 +1,6 @@
import { createSveltePermissions } from "@perm/svelte";
export const perm = createSveltePermissions({
endpoint: "/api/authz",
cacheTtlMs: 30_000
});

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save

Powered by TurnKey Linux.