docs: make the component sections executable in one pass
Expands 3.1 and 3.2 so the work can be done without re-deriving anything. 3.1 now records what 0.8.6 already fixed, separated into the ten components that were miswired and the five that gained outputs they had been firing undeclared, with the caveat that Map's three were converted but never confirmed in a browser. For the 22 that remain it adds the finding that changes the decision: all nine are pure scaffolds with no state, functions or handlers, and five of them duplicate a component that already works -- FileUpload against FileInput and FileUploadProgress, Toast and ToastNotifications against Toaster, AdvancedDatePicker against DatePicker, AdvancedRangeSlider against RangeSlider. Superseding those is a migration entry rather than new code, and leaves Chart, TreeView, Confetti and CopyMarkup as the only ones needing to be built. 3.2 corrects the scaffold count from 23 to 28; the earlier figure used a looser rule. Nine of the 28 are the 3.1 components, so the two items must be planned together, and several of the rest are primitives that need only their styles moved out of ui.css rather than any behaviour. Also corrects the dead-output component count from 11 to 9 in both documents. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -107,15 +107,14 @@
|
|||||||
},
|
},
|
||||||
"examples/inter-app-api-showcase": {
|
"examples/inter-app-api-showcase": {
|
||||||
"name": "inter-app-api-showcase",
|
"name": "inter-app-api-showcase",
|
||||||
"version": "0.8.6",
|
|
||||||
"dependencies": {
|
|
||||||
"@wrnexus/core": "workspace:*",
|
|
||||||
"@wrnexus/rpc": "workspace:*",
|
|
||||||
"@wrnexus/validation": "workspace:*",
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@eslint/js": "^9.0.0",
|
||||||
"typescript": "^5.9.2",
|
"@types/bun": "latest",
|
||||||
|
"@wrnexus/cli": "0.8.6",
|
||||||
|
"eslint": "^9.0.0",
|
||||||
|
"prettier": "latest",
|
||||||
|
"typescript": "^5.5.0",
|
||||||
|
"typescript-eslint": "latest",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/ai": {
|
"packages/ai": {
|
||||||
@@ -645,6 +644,8 @@
|
|||||||
|
|
||||||
"@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
|
"@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
|
||||||
|
|
||||||
|
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.6", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.3.0", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA=="],
|
||||||
|
|
||||||
"@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="],
|
"@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="],
|
||||||
|
|
||||||
"@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
|
"@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
|
||||||
@@ -951,8 +952,12 @@
|
|||||||
|
|
||||||
"ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
|
"ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
|
||||||
|
|
||||||
|
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||||
|
|
||||||
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
|
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
|
||||||
|
|
||||||
|
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||||
|
|
||||||
"asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="],
|
"asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="],
|
||||||
|
|
||||||
"auth-showcase": ["auth-showcase@workspace:examples/auth-showcase"],
|
"auth-showcase": ["auth-showcase@workspace:examples/auth-showcase"],
|
||||||
@@ -977,10 +982,18 @@
|
|||||||
|
|
||||||
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
|
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
|
||||||
|
|
||||||
|
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||||
|
|
||||||
"captcha-showcase": ["captcha-showcase@workspace:examples/captcha-showcase"],
|
"captcha-showcase": ["captcha-showcase@workspace:examples/captcha-showcase"],
|
||||||
|
|
||||||
|
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||||
|
|
||||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||||
|
|
||||||
|
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||||
|
|
||||||
|
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
|
|
||||||
"commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
|
"commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
|
||||||
|
|
||||||
"component-showcase": ["component-showcase@workspace:examples/component-showcase"],
|
"component-showcase": ["component-showcase@workspace:examples/component-showcase"],
|
||||||
@@ -1063,14 +1076,20 @@
|
|||||||
|
|
||||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||||
|
|
||||||
|
"globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
||||||
|
|
||||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||||
|
|
||||||
"happy-dom": ["happy-dom@20.11.1", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg=="],
|
"happy-dom": ["happy-dom@20.11.1", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg=="],
|
||||||
|
|
||||||
|
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||||
|
|
||||||
"i18n-showcase": ["i18n-showcase@workspace:examples/i18n-showcase"],
|
"i18n-showcase": ["i18n-showcase@workspace:examples/i18n-showcase"],
|
||||||
|
|
||||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||||
|
|
||||||
|
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||||
|
|
||||||
"import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
|
"import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
|
||||||
|
|
||||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||||
@@ -1089,6 +1108,8 @@
|
|||||||
|
|
||||||
"joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
|
"joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
|
||||||
|
|
||||||
|
"js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
|
||||||
|
|
||||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||||
|
|
||||||
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||||
@@ -1131,6 +1152,8 @@
|
|||||||
|
|
||||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||||
|
|
||||||
|
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||||
|
|
||||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
|
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
|
||||||
@@ -1165,6 +1188,8 @@
|
|||||||
|
|
||||||
"package-manager-detector": ["package-manager-detector@1.8.0", "", {}, "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A=="],
|
"package-manager-detector": ["package-manager-detector@1.8.0", "", {}, "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A=="],
|
||||||
|
|
||||||
|
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||||
|
|
||||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||||
|
|
||||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||||
@@ -1215,8 +1240,12 @@
|
|||||||
|
|
||||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
|
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||||
|
|
||||||
"sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
|
"sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
|
||||||
|
|
||||||
|
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||||
|
|
||||||
"svgo": ["svgo@4.0.2", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng=="],
|
"svgo": ["svgo@4.0.2", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng=="],
|
||||||
|
|
||||||
"tailwindcss": ["tailwindcss@4.3.2", "", {}, "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA=="],
|
"tailwindcss": ["tailwindcss@4.3.2", "", {}, "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA=="],
|
||||||
@@ -1271,6 +1300,10 @@
|
|||||||
|
|
||||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||||
|
|
||||||
|
"@eslint/eslintrc/espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
|
||||||
|
|
||||||
|
"@eslint/eslintrc/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
|
||||||
@@ -1289,6 +1322,14 @@
|
|||||||
|
|
||||||
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||||
|
|
||||||
|
"import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||||
|
|
||||||
|
"inter-app-api-showcase/@eslint/js": ["@eslint/js@9.39.5", "", {}, "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A=="],
|
||||||
|
|
||||||
|
"inter-app-api-showcase/eslint": ["eslint@9.39.5", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.6", "@eslint/js": "9.39.5", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw=="],
|
||||||
|
|
||||||
|
"inter-app-api-showcase/typescript-eslint": ["typescript-eslint@8.66.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.66.0", "@typescript-eslint/parser": "8.66.0", "@typescript-eslint/typescript-estree": "8.66.0", "@typescript-eslint/utils": "8.66.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw=="],
|
||||||
|
|
||||||
"lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||||
@@ -1297,6 +1338,68 @@
|
|||||||
|
|
||||||
"tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
|
"tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
|
||||||
|
|
||||||
|
"@eslint/eslintrc/espree/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||||
|
|
||||||
"csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="],
|
"csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="],
|
||||||
|
|
||||||
|
"inter-app-api-showcase/eslint/@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="],
|
||||||
|
|
||||||
|
"inter-app-api-showcase/eslint/@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
|
||||||
|
|
||||||
|
"inter-app-api-showcase/eslint/@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
|
||||||
|
|
||||||
|
"inter-app-api-showcase/eslint/@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
|
||||||
|
|
||||||
|
"inter-app-api-showcase/eslint/eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
|
||||||
|
|
||||||
|
"inter-app-api-showcase/eslint/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||||
|
|
||||||
|
"inter-app-api-showcase/eslint/espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
|
||||||
|
|
||||||
|
"inter-app-api-showcase/eslint/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||||
|
|
||||||
|
"inter-app-api-showcase/typescript-eslint/@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.66.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.66.0", "@typescript-eslint/type-utils": "8.66.0", "@typescript-eslint/utils": "8.66.0", "@typescript-eslint/visitor-keys": "8.66.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.66.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A=="],
|
||||||
|
|
||||||
|
"inter-app-api-showcase/typescript-eslint/@typescript-eslint/parser": ["@typescript-eslint/parser@8.66.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.66.0", "@typescript-eslint/types": "8.66.0", "@typescript-eslint/typescript-estree": "8.66.0", "@typescript-eslint/visitor-keys": "8.66.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g=="],
|
||||||
|
|
||||||
|
"inter-app-api-showcase/typescript-eslint/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.66.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.66.0", "@typescript-eslint/tsconfig-utils": "8.66.0", "@typescript-eslint/types": "8.66.0", "@typescript-eslint/visitor-keys": "8.66.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg=="],
|
||||||
|
|
||||||
|
"inter-app-api-showcase/typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.66.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.66.0", "@typescript-eslint/types": "8.66.0", "@typescript-eslint/typescript-estree": "8.66.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA=="],
|
||||||
|
|
||||||
|
"inter-app-api-showcase/eslint/@eslint/config-array/@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
|
||||||
|
|
||||||
|
"inter-app-api-showcase/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.66.0", "", { "dependencies": { "@typescript-eslint/types": "8.66.0", "@typescript-eslint/visitor-keys": "8.66.0" } }, "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g=="],
|
||||||
|
|
||||||
|
"inter-app-api-showcase/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.66.0", "", { "dependencies": { "@typescript-eslint/types": "8.66.0", "@typescript-eslint/typescript-estree": "8.66.0", "@typescript-eslint/utils": "8.66.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g=="],
|
||||||
|
|
||||||
|
"inter-app-api-showcase/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.66.0", "", { "dependencies": { "@typescript-eslint/types": "8.66.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg=="],
|
||||||
|
|
||||||
|
"inter-app-api-showcase/typescript-eslint/@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||||
|
|
||||||
|
"inter-app-api-showcase/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.66.0", "", { "dependencies": { "@typescript-eslint/types": "8.66.0", "@typescript-eslint/visitor-keys": "8.66.0" } }, "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g=="],
|
||||||
|
|
||||||
|
"inter-app-api-showcase/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.66.0", "", {}, "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ=="],
|
||||||
|
|
||||||
|
"inter-app-api-showcase/typescript-eslint/@typescript-eslint/parser/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.66.0", "", { "dependencies": { "@typescript-eslint/types": "8.66.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg=="],
|
||||||
|
|
||||||
|
"inter-app-api-showcase/typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.66.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.66.0", "@typescript-eslint/types": "^8.66.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g=="],
|
||||||
|
|
||||||
|
"inter-app-api-showcase/typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.66.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g=="],
|
||||||
|
|
||||||
|
"inter-app-api-showcase/typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.66.0", "", {}, "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ=="],
|
||||||
|
|
||||||
|
"inter-app-api-showcase/typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.66.0", "", { "dependencies": { "@typescript-eslint/types": "8.66.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg=="],
|
||||||
|
|
||||||
|
"inter-app-api-showcase/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.66.0", "", { "dependencies": { "@typescript-eslint/types": "8.66.0", "@typescript-eslint/visitor-keys": "8.66.0" } }, "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g=="],
|
||||||
|
|
||||||
|
"inter-app-api-showcase/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.66.0", "", {}, "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ=="],
|
||||||
|
|
||||||
|
"inter-app-api-showcase/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.66.0", "", {}, "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ=="],
|
||||||
|
|
||||||
|
"inter-app-api-showcase/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/types": ["@typescript-eslint/types@8.66.0", "", {}, "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ=="],
|
||||||
|
|
||||||
|
"inter-app-api-showcase/typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.66.0", "", {}, "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ=="],
|
||||||
|
|
||||||
|
"inter-app-api-showcase/typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.66.0", "", { "dependencies": { "@typescript-eslint/types": "8.66.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -301,35 +301,150 @@ hydrated DOM, which already looks correct today.
|
|||||||
|
|
||||||
### 3.1 Twenty-two outputs still have no emitter
|
### 3.1 Twenty-two outputs still have no emitter
|
||||||
|
|
||||||
**Issue.** 22 outputs across 9 components are declared and never fired:
|
Everything needed to do this in one pass is in this section: what was already
|
||||||
FileUpload (upload, progress, success, cancel, remove), ToastNotifications (add,
|
done, what is left, and which disposition each remaining component takes.
|
||||||
dismiss, clear, action), AdvancedDatePicker (open, close, clear),
|
|
||||||
AdvancedRangeSlider (start, end), Chart (dataPointClick, legendToggle), Confetti
|
|
||||||
(start, complete), TreeView (expand, collapse), Toast (dismiss), CopyMarkup
|
|
||||||
(success).
|
|
||||||
|
|
||||||
Unlike §1.2 these are not miswired — the components genuinely do nothing. Each
|
#### 3.1.1 Already done in 0.8.6 — do not redo
|
||||||
needs a real implementation.
|
|
||||||
|
|
||||||
**Change.** For each: implement the behaviour, or delete the output. Do not
|
Fifteen components were fixed. Ten had outputs that were **declared but
|
||||||
leave a component advertising what it does not do. Deleting is a breaking change
|
miswired** — they dispatched a hand-built `CustomEvent` instead of calling
|
||||||
and needs a migration note; implementing Chart and FileUpload is real work and
|
`output.*`, so the declaration was right and only the emit was wrong. Nothing
|
||||||
should be scheduled, not squeezed in.
|
was added to these; they were rewired:
|
||||||
|
|
||||||
**How to test.** The ratchet in `packages/ui/test/ui.test.ts` is pinned at 22
|
> Card, Footer, Breadcrumb, Accordion, alert, Badge, AnnouncementBar,
|
||||||
and only ever moves down. Lower the ceiling in the same commit that fixes a
|
> AvatarGroup, ToggleCount, InputNumber
|
||||||
component.
|
|
||||||
|
|
||||||
### 3.2 Twenty-three components are still scaffolds
|
Five more were **firing events they had never declared**, so no caller could
|
||||||
|
bind to them at all. These gained an `outputs {}` block _and_ were routed
|
||||||
|
through `output.*`:
|
||||||
|
|
||||||
**Issue.** No style block, no functions — the same shape as the Table scaffold
|
| Component | Outputs added | Fires when |
|
||||||
that was removed and the LayoutSplitter scaffold that was rebuilt.
|
| ----------- | ------------------------------- | ------------------------------------------------------ |
|
||||||
|
| `Map` | `markerClick`, `select`, `zoom` | marker pressed (both names fire); zoom in/out pressed |
|
||||||
|
| `SearchBox` | `search`, `clear` | form submitted; clear button pressed |
|
||||||
|
| `Marquee` | `pause`, `resume` | hover, focus, or the pause/play button |
|
||||||
|
| `List` | `select` | item pressed — **only items with an `href`**, see §3.4 |
|
||||||
|
| `Timeline` | `select` | item pressed |
|
||||||
|
|
||||||
**Change.** Rebuild them properly under §0.1, or remove them. A scaffold in a
|
Verified firing in a browser: `SearchBox`, `Marquee`, `List`, `Timeline`, plus
|
||||||
published library is a promise the library does not keep.
|
all ten rewired components. **`Map` was not verified** — its three outputs were
|
||||||
|
converted by the same mechanical change and the build passes, but no Map was on
|
||||||
|
the probe page. Put one on a page and confirm before treating it as done.
|
||||||
|
|
||||||
**How to test.** Per component: it renders, its interactive behaviour works in a
|
#### 3.1.2 What is left — 22 outputs, 9 components
|
||||||
browser, its outputs fire, and it carries its own styles.
|
|
||||||
|
**Issue.** These are not miswired. All nine are **pure scaffolds**: roughly 25
|
||||||
|
lines each, zero state, zero functions, no style block, no event handlers. They
|
||||||
|
are markup shells that declare outputs. "Add an emitter" is not the work — there
|
||||||
|
is nothing to emit from.
|
||||||
|
|
||||||
|
**The finding that should drive the decision: most of them duplicate a component
|
||||||
|
that already works.** This is the Table situation again, where Table was removed
|
||||||
|
because DataTable superseded it.
|
||||||
|
|
||||||
|
| Scaffold | Dead outputs | Already works elsewhere |
|
||||||
|
| --------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `FileUpload` | upload, progress, success, cancel, remove | `FileInput` (3 fns, 4 outputs) + `FileUploadProgress` (5 fns, 3 outputs), plus the `@wrnexus/uploader` package |
|
||||||
|
| `ToastNotifications` | add, dismiss, clear, action | `Toaster` (961 lines, 40 fns, fully built) |
|
||||||
|
| `Toast` | dismiss | `Toaster`, same |
|
||||||
|
| `AdvancedDatePicker` | open, close, clear | `DatePicker` (8 fns, 8 outputs) |
|
||||||
|
| `AdvancedRangeSlider` | start, end | `RangeSlider` (11 fns, 8 outputs) |
|
||||||
|
| `Chart` | dataPointClick, legendToggle | nothing — needs building |
|
||||||
|
| `TreeView` | expand, collapse | nothing — needs building |
|
||||||
|
| `Confetti` | start, complete | nothing — needs building |
|
||||||
|
| `CopyMarkup` | success | nothing — and `Clipboard` is a scaffold too (0 fns) |
|
||||||
|
|
||||||
|
**Change, in two groups.**
|
||||||
|
|
||||||
|
_Group A — supersede and remove: 5 components, 15 of the 22 outputs._
|
||||||
|
`FileUpload`, `ToastNotifications`, `Toast`, `AdvancedDatePicker`,
|
||||||
|
`AdvancedRangeSlider`. Each has a working counterpart, so building them a second
|
||||||
|
time adds duplicated surface area to maintain for no new capability. Removing
|
||||||
|
them is a **breaking change** and needs a migration entry naming the replacement
|
||||||
|
per component, in the same shape as the Table removal.
|
||||||
|
|
||||||
|
This is a judgement call and it is yours: superseding is cheap and honest,
|
||||||
|
keeping them means committing to build five more components properly.
|
||||||
|
|
||||||
|
_Group B — build for real: 4 components, 7 outputs._ `Chart`, `TreeView`,
|
||||||
|
`Confetti`, `CopyMarkup` (and `Clipboard` alongside it, since it is in the same
|
||||||
|
state). These have no counterpart, so their outputs only become meaningful once
|
||||||
|
the component exists. Build each under §0.1 — markup, behaviour and styles in
|
||||||
|
its own `.wrn`.
|
||||||
|
|
||||||
|
Notes that will save time:
|
||||||
|
|
||||||
|
- **`Chart` needs a rendering decision before any output can mean anything.** An
|
||||||
|
inline SVG renderer keeps the CSP story intact and adds no dependency; a
|
||||||
|
charting library ships faster but introduces a third-party runtime dependency
|
||||||
|
the framework does not currently carry. Decide that first — `dataPointClick`
|
||||||
|
and `legendToggle` are trivial once something is actually drawn.
|
||||||
|
- **`TreeView` should reuse the existing roving-focus controller** rather than
|
||||||
|
growing a new one. Expand/collapse state must live in the component, not in a
|
||||||
|
deferred callback — see §2.2.
|
||||||
|
- **`CopyMarkup`'s props are generic input boilerplate** (`name`, `value`,
|
||||||
|
`placeholder`, `type`, `min`) rather than anything copy-related, which
|
||||||
|
suggests it was generated rather than designed. Worth deciding what it is
|
||||||
|
meant to be before building it, or folding it into `Clipboard`.
|
||||||
|
|
||||||
|
Rough effort: `CopyMarkup`/`Clipboard` and `Confetti` are small and
|
||||||
|
self-contained. `TreeView` is medium — recursive rendering plus expand state.
|
||||||
|
`Chart` is the large one.
|
||||||
|
|
||||||
|
**How to test.**
|
||||||
|
|
||||||
|
- The ratchet in `packages/ui/test/ui.test.ts` is pinned at 22 and only ever
|
||||||
|
moves down. Lower the ceiling **in the same commit** that fixes or removes a
|
||||||
|
component — never in a separate one, or the ratchet stops meaning anything.
|
||||||
|
- For a removal: `bun run check:public-api` flags the dropped export, and the
|
||||||
|
migration entry is required before `release:prepare` will pass.
|
||||||
|
- For a build: the component renders, its behaviour works in a browser, its
|
||||||
|
outputs reach a page-level `@binding`, and it carries its own styles. Bind the
|
||||||
|
output from a page and confirm it arrives — §1.1 and §1.2 are both cases where
|
||||||
|
reading the source said it worked and the browser said otherwise.
|
||||||
|
|
||||||
|
### 3.2 Twenty-eight components are still scaffolds
|
||||||
|
|
||||||
|
**Issue.** No style block, no functions and no event handlers — markup shells,
|
||||||
|
the same shape as the Table scaffold that was removed and the LayoutSplitter
|
||||||
|
scaffold that was rebuilt. A scaffold in a published library is a promise the
|
||||||
|
library does not keep.
|
||||||
|
|
||||||
|
**Evidence.** Counted as components with no `style {}`, no `function` and no
|
||||||
|
`@handler`. An earlier figure of 23 in the audit was measured with a looser rule;
|
||||||
|
28 is the number:
|
||||||
|
|
||||||
|
> AdvancedDatePicker, AdvancedRangeSlider, AuthSplitLayout, avatar, Blockquote,
|
||||||
|
> button, Chart, Clipboard, Confetti, CopyMarkup, DataMap, DragAndDrop,
|
||||||
|
> FeatureIconCard, FileUpload, HeroActions, LegendIndicator, ListGroup,
|
||||||
|
> MarketingSectionHeader, progress, Rating, skeleton, spinner, StyledIcon,
|
||||||
|
> TextLink, Toast, ToastNotifications, TreeView, WysiwygEditor
|
||||||
|
|
||||||
|
**Nine of these are the §3.1 dead-output components** — AdvancedDatePicker,
|
||||||
|
AdvancedRangeSlider, Chart, Confetti, CopyMarkup, FileUpload, Toast,
|
||||||
|
ToastNotifications, TreeView. Do §3.1 first and this list drops to 19 without
|
||||||
|
any extra work. Do not plan the two items separately.
|
||||||
|
|
||||||
|
Separately, **58 components still carry the `wire-next` scaffold class** in
|
||||||
|
their markup, including many that are otherwise finished. That class is a
|
||||||
|
generation artefact rather than a design, and it is what ties them to `ui.css`
|
||||||
|
instead of their own styles (§4.1).
|
||||||
|
|
||||||
|
**Change.** Rebuild them under §0.1, or remove them. Note that several are
|
||||||
|
primitives where a scaffold is nearly the right answer — `skeleton`, `spinner`,
|
||||||
|
`avatar`, `Blockquote` and `StyledIcon` need styles but genuinely need no
|
||||||
|
behaviour, so for those "rebuild" means moving their CSS out of `ui.css` into
|
||||||
|
the component and nothing more. Sort the list into "needs behaviour" and "needs
|
||||||
|
only its styles" before starting; the second group is much larger and much
|
||||||
|
cheaper than it looks.
|
||||||
|
|
||||||
|
**How to test.** Per component: it renders, any interactive behaviour works in a
|
||||||
|
browser, its outputs reach a page-level binding, and it carries its own styles.
|
||||||
|
Track the count down the same way as §4.1:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run build && gzip -c examples/basic-app/dist/ui.css | wc -c
|
||||||
|
```
|
||||||
|
|
||||||
### 3.3 Seven components still use Tailwind utilities
|
### 3.3 Seven components still use Tailwind utilities
|
||||||
|
|
||||||
@@ -614,7 +729,7 @@ Ranked by return, not by size. The first item changes the cost of every item
|
|||||||
below it, which is why it is first.
|
below it, which is why it is first.
|
||||||
|
|
||||||
| # | Item | Why now |
|
| # | Item | Why now |
|
||||||
| --- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
| --- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||||
| 1 | §2.1 dev-mode diagnostics | Changes the debugging economics of everything else. Every §1 bug would have been minutes instead of hours. |
|
| 1 | §2.1 dev-mode diagnostics | Changes the debugging economics of everything else. Every §1 bug would have been minutes instead of hours. |
|
||||||
| 2 | §2.3 reactive props | The biggest capability ceiling. Composition does not work without it. |
|
| 2 | §2.3 reactive props | The biggest capability ceiling. Composition does not work without it. |
|
||||||
| 3 | §2.6 server-rendered i18n | Contained work; the core SSR claim currently fails on text. |
|
| 3 | §2.6 server-rendered i18n | Contained work; the core SSR claim currently fails on text. |
|
||||||
@@ -622,7 +737,9 @@ below it, which is why it is first.
|
|||||||
| 5 | §4.6 de-duplicate generated client modules | 490 kB decoded parsed per page, 90% of it duplicated. Codegen-only, no API impact. |
|
| 5 | §4.6 de-duplicate generated client modules | 490 kB decoded parsed per page, 90% of it duplicated. Codegen-only, no API impact. |
|
||||||
| 6 | §4.1 finish the CSS migration | 66 components, mechanical, takes ~26 kB off every page. |
|
| 6 | §4.1 finish the CSS migration | 66 components, mechanical, takes ~26 kB off every page. |
|
||||||
| 7 | §4.6 split controllers out of the core runtime | 6.6 kB gzipped a typical page never executes. |
|
| 7 | §4.6 split controllers out of the core runtime | 6.6 kB gzipped a typical page never executes. |
|
||||||
| 8 | §3.1 implement or delete the 22 dead outputs | Honesty. Deleting is an afternoon; implementing is scheduled work. |
|
| 8 | §3.1 Group A: supersede the 5 duplicate scaffolds | 15 of the 22 dead outputs, and 5 fewer components to maintain. Needs a migration entry, not new code. |
|
||||||
|
| 9 | §3.2 scaffolds that need only their styles | Larger and cheaper than it looks; folds into item 6. |
|
||||||
|
| 10 | §3.1 Group B: build Chart, TreeView, Confetti, CopyMarkup | Real component work. Chart needs a rendering decision first. |
|
||||||
|
|
||||||
§2.2, §2.4 and §2.5 fold into item 1 as diagnostics first, then into item 2 as
|
§2.2, §2.4 and §2.5 fold into item 1 as diagnostics first, then into item 2 as
|
||||||
model work.
|
model work.
|
||||||
|
|||||||
@@ -2413,6 +2413,8 @@
|
|||||||
"StreamHandlers",
|
"StreamHandlers",
|
||||||
"StreamImplementOptions",
|
"StreamImplementOptions",
|
||||||
"StreamImplementation",
|
"StreamImplementation",
|
||||||
|
"StreamMetrics",
|
||||||
|
"StreamMetricsSnapshot",
|
||||||
"SubjectContext",
|
"SubjectContext",
|
||||||
"ToResultOptions",
|
"ToResultOptions",
|
||||||
"Transport",
|
"Transport",
|
||||||
|
|||||||
@@ -82,8 +82,9 @@ do arrive.
|
|||||||
|
|
||||||
A test pins this at 22 as a ceiling that only moves down.
|
A test pins this at 22 as a ceiling that only moves down.
|
||||||
|
|
||||||
**23 components are still on the `wire-next` scaffold pattern** — no style
|
**28 components are still scaffolds** — no style block, no functions and no
|
||||||
block, no functions, and for 19 of them an outputs block they never honour.
|
handlers. Nine of them also declare outputs nothing emits. (An earlier figure of
|
||||||
|
23 here used a looser rule; see §3.2 of the remediation plan.)
|
||||||
These are the same shape as the Table scaffold that was removed and the
|
These are the same shape as the Table scaffold that was removed and the
|
||||||
LayoutSplitter scaffold that was rebuilt.
|
LayoutSplitter scaffold that was rebuilt.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
AUTH_SECRET=replace-with-at-least-32-random-characters
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.wrnexus/
|
||||||
|
**/.wrnexus/
|
||||||
|
coverage/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.*.example
|
||||||
|
*.log
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
uploads/
|
||||||
|
mobile/android/
|
||||||
|
mobile/ios/
|
||||||
|
mobile/.expo/
|
||||||
|
.idea/
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
*.tsbuildinfo
|
||||||
|
.eslintcache
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.wrnexus/
|
||||||
|
**/.wrnexus/
|
||||||
|
*.log
|
||||||
|
**/CLAUDE.md
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["wrnexus.wrnexus", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" },
|
||||||
|
"prettier.requireConfig": true,
|
||||||
|
"[wrn]": { "editor.defaultFormatter": "wrnexus.wrnexus", "editor.formatOnSave": true }
|
||||||
|
}
|
||||||
@@ -1,19 +1,44 @@
|
|||||||
# Inter-app + external API showcase
|
# inter-app-api-showcase
|
||||||
|
|
||||||
`GET /api/product-summary?sku=starter` demonstrates one request handler making:
|
A WrNexus **workspace** — multiple apps, one gateway, interconnected.
|
||||||
|
|
||||||
1. an RPC request to the `catalog` app (`getProduct`);
|
```
|
||||||
2. an external HTTPS request to GitHub's public REST API; and
|
inter-app-api-showcase/
|
||||||
3. an RPC request to the `audit` app (`recordLookup`).
|
wrnexus.workspace.ts # apps ↔ domains map (used by `wrnexus gateway`)
|
||||||
|
apps/
|
||||||
The handler deliberately forwards `as: ctx` only to WRNexus peer apps. The RPC package turns that into a short-lived subject/tenant token; it is never forwarded to GitHub. Each peer app must implement the same contract from `app/lib/contracts.ts` (normally a shared workspace package) under `app/services/`, and must authorize its own procedures.
|
web/ # a WrNexus app → localhost, web.localhost
|
||||||
|
admin/ # a WrNexus app → admin.localhost
|
||||||
Before running this app, configure all three apps with the same private internal-origin map and a distinct, 32+ character RPC secret:
|
packages/
|
||||||
|
shared/ # @app/shared — shared code + cross-app pubsub bus
|
||||||
```sh
|
|
||||||
WRNEXUS_RPC_SECRET=replace-with-a-private-32-character-minimum-secret
|
|
||||||
WRNEXUS_APP_NAME=product-summary
|
|
||||||
WRNEXUS_INTERNAL_ORIGINS={"catalog":"http://127.0.0.1:4101","audit":"http://127.0.0.1:4102"}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The peer app processes must remain private; the public gateway blocks the RPC route by design. Run with `bun run --cwd examples/inter-app-api-showcase dev`.
|
## Run everything (one port, routed by domain)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
bun run dev # = wrnexus gateway → http://127.0.0.1:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the hosts to your machine (e.g. /etc/hosts):
|
||||||
|
|
||||||
|
```
|
||||||
|
127.0.0.1 web.localhost admin.localhost
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:3000` for the web app or
|
||||||
|
`http://admin.localhost:3000` for the admin app. The ports printed for individual
|
||||||
|
apps are internal gateway targets, not public workspace URLs.
|
||||||
|
|
||||||
|
## Interconnect
|
||||||
|
|
||||||
|
- **Shared code:** import `@app/shared` in any app.
|
||||||
|
- **Runtime messaging:** `import { bus } from "@app/shared"` then
|
||||||
|
`bus.publish("tenant:created", {...})` in one app and
|
||||||
|
`bus.subscribe("tenant:*", fn)` in another (needs Redis).
|
||||||
|
- **Databases:** point apps at the same `db`/`databases` in their config.
|
||||||
|
|
||||||
|
## Add another app
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wrnexus workspace add reports --domain=reports.localhost
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import type { Context } from "@wrnexus/core";
|
|
||||||
import { httpTransport, serviceClient } from "@wrnexus/rpc";
|
|
||||||
import { auditService, catalogService } from "../lib/contracts.ts";
|
|
||||||
|
|
||||||
interface GitHubRepository {
|
|
||||||
full_name?: unknown;
|
|
||||||
stargazers_count?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestedSku(ctx: Context): string {
|
|
||||||
return new URL(ctx.req.url).searchParams.get("sku")?.trim() || "starter";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET /api/product-summary?sku=starter */
|
|
||||||
export async function GET(ctx: Context): Promise<Response> {
|
|
||||||
const sku = requestedSku(ctx);
|
|
||||||
const transport = httpTransport();
|
|
||||||
|
|
||||||
// Inter-app call #1: query the catalog app. `{ as: ctx }` forwards the
|
|
||||||
// signed subject/tenant context; catalog still authorizes independently.
|
|
||||||
const catalog = serviceClient(catalogService, { app: "catalog", as: ctx, transport });
|
|
||||||
const product = await catalog.getProduct({ sku });
|
|
||||||
|
|
||||||
// External API call: GitHub's public repository endpoint. Do not send the
|
|
||||||
// user's RPC identity token or internal headers to external services.
|
|
||||||
let githubResponse: Response;
|
|
||||||
try {
|
|
||||||
githubResponse = await fetch("https://api.github.com/repos/octocat/Hello-World", {
|
|
||||||
headers: { accept: "application/vnd.github+json", "user-agent": "wrnexus-example" },
|
|
||||||
signal: ctx.req.signal,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return Response.json({ error: "External repository lookup failed." }, { status: 502 });
|
|
||||||
}
|
|
||||||
if (!githubResponse.ok) {
|
|
||||||
return Response.json({ error: "External repository lookup failed." }, { status: 502 });
|
|
||||||
}
|
|
||||||
const github = (await githubResponse.json()) as GitHubRepository;
|
|
||||||
if (typeof github.full_name !== "string" || typeof github.stargazers_count !== "number") {
|
|
||||||
return Response.json(
|
|
||||||
{ error: "External repository returned an unexpected response." },
|
|
||||||
{ status: 502 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inter-app call #2: record the completed lookup in the audit app. This is
|
|
||||||
// intentionally awaited: callers learn whether the audit record was saved.
|
|
||||||
const audit = serviceClient(auditService, { app: "audit", as: ctx, transport });
|
|
||||||
const receipt = await audit.recordLookup({
|
|
||||||
sku: product.sku,
|
|
||||||
repository: github.full_name,
|
|
||||||
stars: github.stargazers_count,
|
|
||||||
});
|
|
||||||
|
|
||||||
return Response.json({
|
|
||||||
product,
|
|
||||||
external: { repository: github.full_name, stars: github.stargazers_count },
|
|
||||||
audit: receipt,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { expect, test } from "bun:test";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
test("product summary composes one external request with two peer-app calls", () => {
|
|
||||||
const source = readFileSync(join(import.meta.dir, "api", "product-summary.ts"), "utf8");
|
|
||||||
expect(source).toContain('serviceClient(catalogService, { app: "catalog", as: ctx, transport })');
|
|
||||||
expect(source).toContain('serviceClient(auditService, { app: "audit", as: ctx, transport })');
|
|
||||||
expect(source).toContain('fetch("https://api.github.com/repos/octocat/Hello-World"');
|
|
||||||
expect(source).toContain("ctx.req.signal");
|
|
||||||
});
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { defineService, procedure } from "@wrnexus/rpc";
|
|
||||||
import { v } from "@wrnexus/validation";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In a real multi-app workspace, put these contracts in a shared package and
|
|
||||||
* import that package from this app and each peer. They live together here so
|
|
||||||
* the example is self-contained.
|
|
||||||
*/
|
|
||||||
export const catalogService = defineService({
|
|
||||||
name: "catalog",
|
|
||||||
procedures: {
|
|
||||||
getProduct: procedure
|
|
||||||
.input(v.object({ sku: v.string() }))
|
|
||||||
.output<{ sku: string; displayName: string; enabled: boolean }>()
|
|
||||||
.idempotent()
|
|
||||||
.build(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const auditService = defineService({
|
|
||||||
name: "audit",
|
|
||||||
procedures: {
|
|
||||||
recordLookup: procedure
|
|
||||||
.input(
|
|
||||||
v.object({
|
|
||||||
sku: v.string(),
|
|
||||||
repository: v.string(),
|
|
||||||
stars: v.number(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.output<{ eventId: string }>()
|
|
||||||
.build(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import { afterEach, expect, test } from "bun:test";
|
|
||||||
import { implement } from "@wrnexus/rpc";
|
|
||||||
import { handleRpcRequest } from "../../../packages/dev-server/src/rpc-dispatch.ts";
|
|
||||||
import { GET } from "./api/product-summary.ts";
|
|
||||||
import { auditService, catalogService } from "./lib/contracts.ts";
|
|
||||||
|
|
||||||
const savedFetch = globalThis.fetch;
|
|
||||||
const savedSecret = process.env.WRNEXUS_RPC_SECRET;
|
|
||||||
const savedApp = process.env.WRNEXUS_APP_NAME;
|
|
||||||
const savedOrigins = process.env.WRNEXUS_INTERNAL_ORIGINS;
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
globalThis.fetch = savedFetch;
|
|
||||||
if (savedSecret === undefined) delete process.env.WRNEXUS_RPC_SECRET;
|
|
||||||
else process.env.WRNEXUS_RPC_SECRET = savedSecret;
|
|
||||||
if (savedApp === undefined) delete process.env.WRNEXUS_APP_NAME;
|
|
||||||
else process.env.WRNEXUS_APP_NAME = savedApp;
|
|
||||||
if (savedOrigins === undefined) delete process.env.WRNEXUS_INTERNAL_ORIGINS;
|
|
||||||
else process.env.WRNEXUS_INTERNAL_ORIGINS = savedOrigins;
|
|
||||||
});
|
|
||||||
|
|
||||||
test("coordinator reaches two peer-app listeners and an external API", async () => {
|
|
||||||
process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long";
|
|
||||||
process.env.WRNEXUS_APP_NAME = "product-summary";
|
|
||||||
const catalog = implement(
|
|
||||||
catalogService,
|
|
||||||
{
|
|
||||||
getProduct: ({ sku }) => ({ sku, displayName: "Starter", enabled: true }),
|
|
||||||
},
|
|
||||||
{ selfApp: "catalog" },
|
|
||||||
);
|
|
||||||
const audit = implement(
|
|
||||||
auditService,
|
|
||||||
{
|
|
||||||
recordLookup: () => ({ eventId: "audit_1" }),
|
|
||||||
},
|
|
||||||
{ selfApp: "audit" },
|
|
||||||
);
|
|
||||||
const catalogServer = Bun.serve({
|
|
||||||
port: 0,
|
|
||||||
hostname: "127.0.0.1",
|
|
||||||
async fetch(request) {
|
|
||||||
return (
|
|
||||||
(await handleRpcRequest(request, new URL(request.url), new Map([["catalog", catalog]]))) ??
|
|
||||||
new Response("Not found", { status: 404 })
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const auditServer = Bun.serve({
|
|
||||||
port: 0,
|
|
||||||
hostname: "127.0.0.1",
|
|
||||||
async fetch(request) {
|
|
||||||
return (
|
|
||||||
(await handleRpcRequest(request, new URL(request.url), new Map([["audit", audit]]))) ??
|
|
||||||
new Response("Not found", { status: 404 })
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
process.env.WRNEXUS_INTERNAL_ORIGINS = JSON.stringify({
|
|
||||||
catalog: `http://127.0.0.1:${catalogServer.port}`,
|
|
||||||
audit: `http://127.0.0.1:${auditServer.port}`,
|
|
||||||
});
|
|
||||||
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
|
||||||
if (String(input) === "https://api.github.com/repos/octocat/Hello-World") {
|
|
||||||
return Promise.resolve(
|
|
||||||
Response.json({ full_name: "octocat/Hello-World", stargazers_count: 7 }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return savedFetch(input, init);
|
|
||||||
}) as typeof fetch;
|
|
||||||
try {
|
|
||||||
const response = await GET({
|
|
||||||
req: new Request("http://coordinator.test/api/product-summary?sku=starter"),
|
|
||||||
user: { id: "u1" },
|
|
||||||
locals: {},
|
|
||||||
} as never);
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(await response.json()).toEqual({
|
|
||||||
product: { sku: "starter", displayName: "Starter", enabled: true },
|
|
||||||
external: { repository: "octocat/Hello-World", stars: 7 },
|
|
||||||
audit: { eventId: "audit_1" },
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
catalogServer.stop(true);
|
|
||||||
auditServer.stop(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Copy to .env for local development. Never commit real secrets.
|
||||||
|
WRNEXUS_PUBLIC_ORIGIN=http://localhost:3000
|
||||||
|
DATABASE_URL=file:./dev.db
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
AUTH_SECRET=replace-with-at-least-32-random-characters
|
||||||
|
ENCRYPTION_KEY=replace-with-a-base64-encoded-32-byte-key
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
WRNEXUS_PUBLIC_ORIGIN=http://localhost:3000
|
||||||
|
DATABASE_URL=file:./test.db
|
||||||
|
AUTH_SECRET=test-only-secret-replace-outside-tests
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# WRNexusJS and production builds
|
||||||
|
dist/
|
||||||
|
.wrnexus/
|
||||||
|
**/.wrnexus/
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Environment files and local secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.*.example
|
||||||
|
|
||||||
|
# Logs and runtime files
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
*.pid
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Local databases
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
uploads/
|
||||||
|
|
||||||
|
# Generated native projects
|
||||||
|
mobile/android/
|
||||||
|
mobile/ios/
|
||||||
|
mobile/.expo/
|
||||||
|
|
||||||
|
# Editors and operating systems
|
||||||
|
.idea/
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# TypeScript and test caches
|
||||||
|
*.tsbuildinfo
|
||||||
|
.eslintcache
|
||||||
|
.nyc_output/
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.wrnexus/
|
||||||
|
**/.wrnexus/
|
||||||
|
*.log
|
||||||
|
CLAUDE.md
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["wrnexus.wrnexus", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": "explicit"
|
||||||
|
},
|
||||||
|
"prettier.requireConfig": true,
|
||||||
|
"[wrn]": {
|
||||||
|
"editor.defaultFormatter": "wrnexus.wrnexus",
|
||||||
|
"editor.formatOnSave": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
# WrNexus app - instructions for AI coding assistants
|
||||||
|
|
||||||
|
This is a **WrNexus** app. When creating or editing pages, components, API routes,
|
||||||
|
or features, follow the framework conventions below. WrNexus is private and not in
|
||||||
|
your training data, so rely on these rules - do NOT assume React/Next.js/Vue patterns.
|
||||||
|
|
||||||
|
# WrNexus
|
||||||
|
|
||||||
|
> WrNexus is an SSR-first, **Bun-native** full-stack web framework. UI is written in
|
||||||
|
> `.wrn` files (its own component language — NOT React/JSX/Vue). Routing is file-based.
|
||||||
|
> This document teaches an AI how to write correct WrNexus code. It is private and
|
||||||
|
> post-dates model training data, so rely on THIS document, not prior web-framework
|
||||||
|
> assumptions.
|
||||||
|
|
||||||
|
## Golden rules
|
||||||
|
|
||||||
|
- **Pages, components, and layouts are `.wrn` files.** Do NOT write `.tsx`/`.jsx`/React
|
||||||
|
for UI. Do NOT use `useState`, hooks, JSX, or a client bundler.
|
||||||
|
- **Routing is file-based** under `app/`. The filename is the route. No router config.
|
||||||
|
- **Interactivity** lives in `state` + `{expr}` + `@event` inside `.wrn`. Components render
|
||||||
|
on the server and hydrate automatically — you never write client-side JS islands.
|
||||||
|
- **Runtime is Bun only** (uses `Bun.serve`, `bun:sqlite`, `Bun.password`, …). Node is not supported.
|
||||||
|
- To add files, prefer the CLI: `wrnexus generate page <Name>` / `component <name>` / `api <path>` / `schema <name>`.
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
pages/ *.wrn → routes: index.wrn = "/", about.wrn = "/about", blog/[slug].wrn = "/blog/:slug"
|
||||||
|
components/ *.wrn → reusable UI, mounted in a page/component via <div data-component="name" ...props>
|
||||||
|
layouts/ *.wrn → named layouts; a page opts in with layout = "name"
|
||||||
|
api/ *.ts → HTTP handlers: export const GET/POST/PUT/PATCH/DELETE = async (ctx) => Response
|
||||||
|
middleware/ *.ts → export default async (ctx, next) => next()
|
||||||
|
realtime/ *.ts → export default defineRoom({ ... }) from "@wrnexus/core" (ws://host/realtime/<name>)
|
||||||
|
schemas/ *.ts → validation schemas (the `v` builder), used by forms + parseBody
|
||||||
|
locales/ *.json → i18n messages per language
|
||||||
|
db/ schema.ts, queries/*.sql, migrations/*.sql
|
||||||
|
styles/ global.css → Tailwind (default) or plain CSS
|
||||||
|
wrnexus.config.ts → app config (AppConfig from "@wrnexus/styles")
|
||||||
|
public/ → static assets served at /
|
||||||
|
```
|
||||||
|
|
||||||
|
## `.wrn` page
|
||||||
|
|
||||||
|
```wrn
|
||||||
|
page Home {
|
||||||
|
layout = "public" // optional: a component in app/layouts/<name>.wrn ("none" to skip)
|
||||||
|
|
||||||
|
state count = 0 // optional: seeds client-reactive state (omit for pure SSR)
|
||||||
|
|
||||||
|
seo {
|
||||||
|
title = "Home"
|
||||||
|
description = "..."
|
||||||
|
canonical = "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
view {
|
||||||
|
<h1>Hello</h1>
|
||||||
|
<p>Count is {count}, doubled is {count * 2}.</p>
|
||||||
|
<button @click="count++">Increment</button>
|
||||||
|
<div data-component="counter" start="5" label="Clicks"></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
style {
|
||||||
|
h1 { color: var(--wire-color-text); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## `.wrn` component
|
||||||
|
|
||||||
|
```wrn
|
||||||
|
component Counter {
|
||||||
|
props { // props come from mount attributes; each is coerced to the
|
||||||
|
start = 0 // TYPE of its default (so start="5" arrives as the number 5)
|
||||||
|
label = "Count"
|
||||||
|
}
|
||||||
|
state count = start // state may reference props
|
||||||
|
view {
|
||||||
|
<button @click="count++">{label}: {count}</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Mount it from any page/component: `<div data-component="counter" start="0" label="Clicks"></div>`.
|
||||||
|
Components render on the server with their props, then hydrate — no per-component JS.
|
||||||
|
|
||||||
|
## The `view { }` block (plain HTML + a few directives)
|
||||||
|
|
||||||
|
- `{expr}` — interpolate a JS expression. Reactive if it references `state`: `{count}`, `{count * 2}`, `{user.name}`.
|
||||||
|
- `@event="expr"` — bind a DOM event; the expression runs in the reactive scope: `@click="count++"`, `@input="name = event.target.value"`.
|
||||||
|
- `<div data-component="name" prop="v">` — mount a component (attrs become string props, coerced).
|
||||||
|
- `<slot></slot>` / `<slot name="x"></slot>` — component/layout slots; fill with `<div data-slot="x">…</div>`.
|
||||||
|
- **Server loop (DB/list/table):** `{#each <list> as <item>[, <i>]} …rows… {:empty} …fallback… {/each}` — iterates SSR data on the server and renders markup per item. `{item.field}` interpolates (HTML-escaped, XSS-safe). `<list>` is a JS expression, usually an `ssr` data binding (see "Data-driven tables" below). This is how you render a database table in `.wrn`.
|
||||||
|
- **Server conditional:** `{#if <expr>} … {:else if <expr>} … {:else} … {/if}` — renders the first truthy branch on the server. `<expr>` can reference `ssr` data, or the `item`/`index` of an enclosing `{#each}`. Works at page level and inside loops (e.g. `{#if r.active}<span>●</span>{:else}<span>○</span>{/if}` per row). For client-side show/hide based on reactive `state`, use `data-show="expr"` instead.
|
||||||
|
- i18n: `{t:home.title}` in text, `t:placeholder="form.name"` on attributes — resolved per request from `app/locales/`.
|
||||||
|
- Theme: any element with `data-wire-theme-toggle` toggles light/dark; `data-wire-theme-set="dark"` sets it.
|
||||||
|
- Void/self-closing tags are fine: `<br />`, `<img src="..." />`.
|
||||||
|
- Only `{` and `}` are special (interpolation). Don't use a bare `}` in view text.
|
||||||
|
|
||||||
|
## Data-driven tables / lists (server-rendered `.wrn`)
|
||||||
|
|
||||||
|
Use an `ssr` data binding to fetch rows on the server, then `{#each}` to render them.
|
||||||
|
This renders on the **server** (SSR-first) and is HTML-escaped by default.
|
||||||
|
|
||||||
|
```wrn
|
||||||
|
page Admin {
|
||||||
|
layout = "dashboard"
|
||||||
|
|
||||||
|
// Fetch on the server. The api handler at /api/contacts returns { contacts: [...] };
|
||||||
|
// this block's `return contacts` exposes that array (via `$data`) as the binding `rows`.
|
||||||
|
ssr {
|
||||||
|
api rows GET /api/contacts { return contacts }
|
||||||
|
}
|
||||||
|
|
||||||
|
view {
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{#each rows as r, i}
|
||||||
|
<tr>
|
||||||
|
<td>#{i}</td>
|
||||||
|
<td>{r.name}</td>
|
||||||
|
<td><a href="mailto:{r.email}">{r.email}</a></td>
|
||||||
|
</tr>
|
||||||
|
{:empty}
|
||||||
|
<tr><td colspan="3">No submissions yet.</td></tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The matching API returns the array under a key the `ssr` block reads:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/api/contacts.ts → GET /api/contacts
|
||||||
|
import { getDb } from "@wrnexus/db";
|
||||||
|
export const GET = async () => {
|
||||||
|
const contacts = await getDb().all("SELECT id, name, email FROM contacts ORDER BY id DESC");
|
||||||
|
return Response.json({ contacts }); // ssr block does `return contacts`
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prefer this `.wrn` + `{#each}` approach for DB-backed tables and lists.** (`.ts`/`.tsx`
|
||||||
|
pages returning an HTML string are also supported for fully-custom programmatic rendering,
|
||||||
|
but a `.wrn` page with `ssr` data + `{#each}` is the idiomatic, SSR-first way.)
|
||||||
|
|
||||||
|
## API routes (`app/api/*.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/api/users/list.ts → GET /api/users/list
|
||||||
|
import { getDb } from "@wrnexus/db";
|
||||||
|
|
||||||
|
export const GET = async (ctx) => {
|
||||||
|
return Response.json({ users: await ListUsers(getDb()) });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST = async (ctx) => {
|
||||||
|
const body = await ctx.req.json();
|
||||||
|
return Response.json({ ok: true, body }, { status: 201 });
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`ctx` (the `Context` from `@wrnexus/core`) has:
|
||||||
|
`req: Request`, `url: URL`, `params: Record<string,string>` (dynamic route params, e.g. `/users/[id]` → `ctx.params.id`),
|
||||||
|
`lang: string`, `t(key, params?)` (i18n), `cookies` (get/set), `session` (get/set). Auth: `getUser(ctx)` after `sessionAuth`/`logIn`.
|
||||||
|
|
||||||
|
When an SSO forward-auth verifier needs the URL that originally reached the gateway, use
|
||||||
|
`@wrnexus/helpers` instead of constructing it from untrusted headers:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { redirectToLogin } from "@wrnexus/helpers";
|
||||||
|
|
||||||
|
return redirectToLogin(ctx, "/login", {
|
||||||
|
allowedHosts: ["admin.localhost:3000", "reports.localhost:3000"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The package also exports `getOriginalRequestUrl`, `getOriginalRequestOrigin`,
|
||||||
|
`getOriginalRequestPath`, and `getOriginalRequestMethod`. Always pass `allowedHosts` when
|
||||||
|
using forwarded gateway URLs; the helper rejects untrusted redirect destinations.
|
||||||
|
|
||||||
|
## Middleware & realtime
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/middleware/logger.ts
|
||||||
|
export default async function logger(ctx, next) {
|
||||||
|
console.log(ctx.req.method, ctx.url.pathname);
|
||||||
|
return next(); // return a Response WITHOUT calling next() to short-circuit
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/realtime/chat.ts → ws://host/realtime/chat
|
||||||
|
import { defineRoom } from "@wrnexus/core";
|
||||||
|
export default defineRoom({
|
||||||
|
onConnect(client) {
|
||||||
|
client.send({ type: "system", text: "connected" });
|
||||||
|
},
|
||||||
|
onMessage(client, msg) {
|
||||||
|
client.room.broadcast({ type: "message", data: msg });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Client side: a page opts in with `data-room="chat"` (handled by the realtime runtime).
|
||||||
|
|
||||||
|
## Config (`wrnexus.config.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { AppConfig } from "@wrnexus/styles";
|
||||||
|
const config: AppConfig = {
|
||||||
|
seo: { title: "App", titleTemplate: "%s | App", description: "..." },
|
||||||
|
styles: {
|
||||||
|
entry: "app/styles/global.css",
|
||||||
|
process: async ({ entryPath, mode }) => /* Tailwind */ "",
|
||||||
|
},
|
||||||
|
fonts: {
|
||||||
|
sans: '"Inter", system-ui, sans-serif',
|
||||||
|
google: [{ family: "Inter", weights: [400, 600] }],
|
||||||
|
},
|
||||||
|
theme: { default: "dark", themes: { light: { "color-primary": "#2563eb" } } },
|
||||||
|
i18n: { default: "en", locales: ["en", "es"] },
|
||||||
|
db: { driver: "sqlite", url: "file:./dev.db" },
|
||||||
|
security: { cors: { enabled: true, origin: ["http://localhost:5173"] } },
|
||||||
|
// profiles: { production: { db: { driver: "postgres", url: process.env.DATABASE_URL } } },
|
||||||
|
};
|
||||||
|
export default config;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database (`@wrnexus/db`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/db/schema.ts
|
||||||
|
import { v, table } from "@wrnexus/db";
|
||||||
|
export const users = table("users", {
|
||||||
|
id: v.id(),
|
||||||
|
name: v.string(),
|
||||||
|
email: v.string().unique(),
|
||||||
|
createdAt: v.timestamp(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- Queries: write `app/db/queries/*.sql` with `-- name: ListUsers :many` blocks; `wrnexus db generate` emits typed functions.
|
||||||
|
- Access at runtime: `import { getDb } from "@wrnexus/db"; const rows = await ListUsers(getDb());`
|
||||||
|
- Migrations in `app/db/migrations/`; run `wrnexus db migrate` (dev auto-migrates sqlite).
|
||||||
|
|
||||||
|
## Validation (`@wrnexus/validation`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/schemas/login.ts
|
||||||
|
import { v } from "@wrnexus/validation";
|
||||||
|
export default v.object({
|
||||||
|
email: v.string().email(),
|
||||||
|
password: v.string().min(8),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
In an API route: `import s from "../schemas/login"; import { parseBody } from "@wrnexus/validation"; const r = await parseBody(s, ctx.req);` → `r.ok ? r.value : r.response`.
|
||||||
|
In a form: `<form data-schema="login" action="/api/login" method="post">` + `<span data-error="email"></span>` (client + server validation wired automatically).
|
||||||
|
|
||||||
|
## AI / LLM (`@wrnexus/ai`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/api/ai.ts
|
||||||
|
import { createAI } from "@wrnexus/ai";
|
||||||
|
const ai = createAI(); // reads ANTHROPIC_API_KEY; default model claude-opus-4-8
|
||||||
|
export const POST = async (ctx) => {
|
||||||
|
const { prompt } = await ctx.req.json();
|
||||||
|
return ai.streamResponse(prompt); // or: return Response.json({ text: await ai.generate(prompt) })
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
```
|
||||||
|
wrnexus dev . # dev server + HMR
|
||||||
|
wrnexus build . # production build → dist/server.js
|
||||||
|
bun dist/server.js # run the production server (or npm start)
|
||||||
|
wrnexus create <name> # scaffold a new app
|
||||||
|
wrnexus update --latest # deps + syntax/config migrations + verification
|
||||||
|
wrnexus generate page <Name> # scaffold a page (aliases: g p)
|
||||||
|
wrnexus generate component <name> | api <path> | schema <name>
|
||||||
|
wrnexus db migrate | rollback | status | new [--from-models] | generate | seed
|
||||||
|
wrnexus eject <component> # copy a Wire UI component's .wrn into app/components to customize
|
||||||
|
```
|
||||||
|
|
||||||
|
## When asked to "create a page/component/feature"
|
||||||
|
|
||||||
|
1. Create the `.wrn` file under `app/pages/` (or `app/components/`) with a `page`/`component` block — or run `wrnexus generate page <Name>`.
|
||||||
|
2. Put markup in `view { }`, interactive bits in `state` + `{expr}` + `@event`, reusable UI as components mounted via `data-component`.
|
||||||
|
3. For data, add an `app/api/*.ts` route and `getDb()`; for forms, add an `app/schemas/*.ts` and `data-schema`.
|
||||||
|
4. Style with Tailwind utility classes in the view, or theme tokens (`var(--wire-*)`), or `style { }`.
|
||||||
|
5. Never emit React/JSX, a manual router, or client-side island JS — the framework handles hydration.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// POST /api/ai { "prompt": "..." } → Claude's reply.
|
||||||
|
// Set ANTHROPIC_API_KEY in your environment (e.g. a .env file) to enable this.
|
||||||
|
import { createAI } from "@wrnexus/ai";
|
||||||
|
import type { Context } from "@wrnexus/core";
|
||||||
|
|
||||||
|
const ai = createAI(); // reads ANTHROPIC_API_KEY; defaults to claude-opus-4-8
|
||||||
|
|
||||||
|
export const POST = async (ctx: Context) => {
|
||||||
|
if (!process.env.ANTHROPIC_API_KEY) {
|
||||||
|
return Response.json({ error: "Set ANTHROPIC_API_KEY to use AI." }, { status: 501 });
|
||||||
|
}
|
||||||
|
const { prompt } = await ctx.req.json().catch(() => ({}));
|
||||||
|
if (!prompt) return Response.json({ error: "Provide a 'prompt'." }, { status: 400 });
|
||||||
|
|
||||||
|
// Stream the reply back as plain text. Use `ai.generate(prompt)` for a one-shot string.
|
||||||
|
return ai.streamResponse(prompt);
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const GET = async () => {
|
||||||
|
return Response.json({ message: "Hello API" });
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// A reusable component. Route: none — mounted inside a page with
|
||||||
|
// <div data-component="counter" ...props></div>.
|
||||||
|
//
|
||||||
|
// Components render on the SERVER (with their props applied) and are hydrated in
|
||||||
|
// the browser by the generic reactive runtime — they ship no JS of their own.
|
||||||
|
component Counter {
|
||||||
|
// Props arrive as mount attributes, each coerced to the type of its default
|
||||||
|
// (so start="5" arrives as the number 5).
|
||||||
|
props {
|
||||||
|
start = 0
|
||||||
|
label = "Count"
|
||||||
|
}
|
||||||
|
|
||||||
|
// State can reference props. `count` seeds the reactive scope.
|
||||||
|
state count = start
|
||||||
|
|
||||||
|
view {
|
||||||
|
<button @click="count++" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-indigo-500 active:scale-[0.98]">{label}: {count}</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Create application tables here.
|
||||||
|
-- Run with: bunx wrnexus db migrate
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Add deterministic development seed data here.
|
||||||
|
export async function seed(): Promise<void> {}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Global document layout. The framework renders this once around the selected
|
||||||
|
// page layout and merges SEO metadata, styles, and scripts into <head>/<body>.
|
||||||
|
// Request cookies, resolved theme, language, URL, and pathname are available
|
||||||
|
// as SSR props, so document attributes do not need a client-side correction.
|
||||||
|
layout Document {
|
||||||
|
props {
|
||||||
|
cookies = {}
|
||||||
|
theme = "light"
|
||||||
|
language = "en"
|
||||||
|
url = ""
|
||||||
|
pathname = "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
view {
|
||||||
|
<html>
|
||||||
|
<head></head>
|
||||||
|
<body>
|
||||||
|
<div id="app"><slot /></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"appName": "admin",
|
||||||
|
"welcome": "Welcome to admin"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { Middleware } from "@wrnexus/core";
|
||||||
|
|
||||||
|
const logger: Middleware = async (ctx, next) => {
|
||||||
|
console.log(ctx.req.method, ctx.url.pathname);
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
|
||||||
|
export default logger;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
page About {
|
||||||
|
seo {
|
||||||
|
title = "About"
|
||||||
|
description = "Learn how admin is built with WrNexus."
|
||||||
|
}
|
||||||
|
|
||||||
|
view {
|
||||||
|
<main class="min-h-screen bg-white px-6 py-20 text-slate-900 dark:bg-[#0b0f1e] dark:text-slate-100">
|
||||||
|
<article class="mx-auto max-w-2xl">
|
||||||
|
<a href="/" class="text-sm text-indigo-600 hover:underline dark:text-indigo-400">← Home</a>
|
||||||
|
<p class="mt-12 font-mono text-xs uppercase tracking-[0.2em] text-indigo-500">WrNexus application</p>
|
||||||
|
<h1 class="mt-4 text-4xl font-bold tracking-tight">About admin</h1>
|
||||||
|
<p class="mt-6 text-lg leading-8 text-slate-600 dark:text-slate-400">
|
||||||
|
This page is server-rendered from <code>app/pages/about.wrn</code>. Add state,
|
||||||
|
events, components, APIs, and data without switching to another UI framework.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Home page (route: /). SSR-first: the view is server-rendered, then components
|
||||||
|
// (.wrn files under app/components) hydrate in the browser. Styled with Tailwind.
|
||||||
|
page Home {
|
||||||
|
seo {
|
||||||
|
title = "Home"
|
||||||
|
description = "admin — built with WrNexus, an SSR-first Bun framework."
|
||||||
|
}
|
||||||
|
|
||||||
|
view {
|
||||||
|
<main class="relative min-h-screen overflow-hidden bg-white text-slate-900 dark:bg-[#0b0f1e] dark:text-slate-100">
|
||||||
|
<div aria-hidden="true" class="pointer-events-none absolute inset-x-0 -top-40 mx-auto h-96 max-w-2xl rounded-full bg-indigo-500/20 blur-3xl"></div>
|
||||||
|
|
||||||
|
<div class="relative mx-auto flex min-h-screen max-w-3xl flex-col px-6">
|
||||||
|
<header class="flex items-center justify-between py-6">
|
||||||
|
<span class="flex items-center gap-2.5 font-semibold tracking-tight">
|
||||||
|
<span class="grid h-7 w-7 place-items-center rounded-md bg-gradient-to-br from-indigo-500 to-violet-600 text-sm font-bold text-white">W</span>
|
||||||
|
admin
|
||||||
|
</span>
|
||||||
|
<button data-wire-theme-toggle class="rounded-md border border-slate-200 px-3 py-1.5 text-sm text-slate-600 transition hover:border-slate-300 hover:text-slate-900 dark:border-white/10 dark:text-slate-400 dark:hover:border-white/20 dark:hover:text-white">
|
||||||
|
Toggle theme
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="flex flex-1 flex-col items-center justify-center py-16 text-center">
|
||||||
|
<p class="font-mono text-xs uppercase tracking-[0.2em] text-indigo-500 dark:text-indigo-400">SSR-first · Bun-native</p>
|
||||||
|
|
||||||
|
<h1 class="mt-5 text-4xl font-bold leading-[1.1] tracking-tight sm:text-6xl">
|
||||||
|
Server-rendered.<br />
|
||||||
|
Instantly <span class="bg-gradient-to-r from-indigo-500 to-violet-500 bg-clip-text text-transparent">interactive</span>.
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="mt-5 max-w-md text-base leading-relaxed text-slate-600 dark:text-slate-400">
|
||||||
|
admin runs on WrNexus — write <code class="rounded bg-slate-100 px-1.5 py-0.5 font-mono text-[0.85em] text-slate-800 dark:bg-white/10 dark:text-slate-200">.wrn</code> components, ship no client boilerplate, and let the server do the work.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||||
|
<a href="/about" class="rounded-lg bg-slate-900 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition hover:bg-slate-700 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200">Get started</a>
|
||||||
|
<a href="/api/hello" class="rounded-lg border border-slate-200 px-5 py-2.5 text-sm font-medium text-slate-700 transition hover:border-slate-300 dark:border-white/10 dark:text-slate-300 dark:hover:border-white/20">View API</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-14 w-full max-w-md rounded-2xl border border-slate-200 bg-white p-6 text-left shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||||
|
<div class="flex items-center gap-2 font-mono text-xs text-slate-400">
|
||||||
|
<span class="h-2 w-2 rounded-full bg-emerald-400"></span>
|
||||||
|
live · hydrated on the server
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex items-center justify-between gap-4">
|
||||||
|
<div data-component="counter" start="0" label="Clicks"></div>
|
||||||
|
<span class="max-w-[10rem] text-right text-xs leading-snug text-slate-500">This button works. You wrote zero client JavaScript.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-10 font-mono text-xs text-slate-400 dark:text-slate-600">
|
||||||
|
edit <span class="text-slate-600 dark:text-slate-400">app/pages/index.wrn</span> to make it yours
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="border-t border-slate-100 py-6 text-center text-xs text-slate-400 dark:border-white/5 dark:text-slate-600">
|
||||||
|
Built with <a href="https://www.npmjs.com/package/@wrnexus/cli" class="text-slate-600 underline-offset-2 hover:underline dark:text-slate-400">WrNexus</a>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// ws://<host>/realtime/chat — a simple broadcast room.
|
||||||
|
//
|
||||||
|
// The client side is the framework's realtime runtime; a page opts in with
|
||||||
|
// `data-room="chat"`. Here we only handle room events.
|
||||||
|
//
|
||||||
|
// client.send(msg) → just this connection
|
||||||
|
// client.broadcast(msg) → everyone else in the room
|
||||||
|
// client.room.broadcast(msg) → everyone, including the sender
|
||||||
|
import { defineRoom } from "@wrnexus/core";
|
||||||
|
|
||||||
|
export default defineRoom({
|
||||||
|
onConnect(client) {
|
||||||
|
client.send({ type: "system", text: "connected" });
|
||||||
|
},
|
||||||
|
|
||||||
|
onMessage(client, msg) {
|
||||||
|
// Echo each message to the whole room so every tab stays in sync.
|
||||||
|
client.room.broadcast({ type: "message", data: msg });
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { v } from "@wrnexus/validation";
|
||||||
|
|
||||||
|
export const contactSchema = v.object({
|
||||||
|
email: v.string().email(),
|
||||||
|
message: v.string().min(10).max(2_000),
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { implement } from "../../../../../../packages/rpc/src/index.ts";
|
||||||
|
import { catalogService } from "../../../../packages/shared/src/index.ts";
|
||||||
|
|
||||||
|
/** Private service consumed by the workspace's web app. */
|
||||||
|
export default implement(
|
||||||
|
catalogService,
|
||||||
|
{
|
||||||
|
getProduct: ({ sku }) => ({ sku, name: "WRNexus Sta
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/*
|
||||||
|
* Global stylesheet. Tailwind v4 is compiled by the styles.process hook in
|
||||||
|
* wrnexus.config.ts and served at /__wrnexus/styles.css on every page.
|
||||||
|
*
|
||||||
|
* @source tells Tailwind which files to scan for class names.
|
||||||
|
*/
|
||||||
|
@import "tailwindcss";
|
||||||
|
@plugin "@iconify/tailwind4";
|
||||||
|
@source "../**/*.wrn";
|
||||||
|
@source "../**/*.tsx";
|
||||||
|
|
||||||
|
/* Make Tailwind's `dark:` variant follow the framework's data-theme attribute
|
||||||
|
* (set on <html> by the theme system), not the OS setting. Any element with
|
||||||
|
* data-wire-theme-toggle flips it. */
|
||||||
|
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--wire-font-sans, "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { dirname } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import js from "@eslint/js";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
const tsconfigRootDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
"node_modules/**",
|
||||||
|
"dist/**",
|
||||||
|
".wrnexus/**",
|
||||||
|
"**/.wrnexus/**",
|
||||||
|
"mobile/android/**",
|
||||||
|
"mobile/ios/**",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
tsconfigRootDir,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
rules: {
|
||||||
|
"no-undef": "off",
|
||||||
|
"no-console": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
argsIgnorePattern: "^_",
|
||||||
|
varsIgnorePattern: "^_",
|
||||||
|
caughtErrorsIgnorePattern: "^_",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"name": "admin",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"wrnexus": {
|
||||||
|
"version": "0.8.6"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "wrnexus dev .",
|
||||||
|
"build": "wrnexus build .",
|
||||||
|
"start": "bun dist/server.js",
|
||||||
|
"production": "bun run build && bun run start",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "wrnexus test .",
|
||||||
|
"test:watch": "wrnexus test . --watch",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
|
"format": "prettier . --write",
|
||||||
|
"format:check": "prettier . --check",
|
||||||
|
"doctor": "wrnexus doctor .",
|
||||||
|
"analyze": "wrnexus analyze .",
|
||||||
|
"inspect": "wrnexus inspect packages .",
|
||||||
|
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@wrnexus/ai": "0.8.6",
|
||||||
|
"@wrnexus/auth": "0.8.6",
|
||||||
|
"@wrnexus/captcha": "0.8.6",
|
||||||
|
"@wrnexus/core": "0.8.6",
|
||||||
|
"@wrnexus/csr": "0.8.6",
|
||||||
|
"@wrnexus/db": "0.8.6",
|
||||||
|
"@wrnexus/dev-server": "0.8.6",
|
||||||
|
"@wrnexus/encryption": "0.8.6",
|
||||||
|
"@wrnexus/helpers": "0.8.6",
|
||||||
|
"@wrnexus/i18n": "0.8.6",
|
||||||
|
"@wrnexus/image": "0.8.6",
|
||||||
|
"@wrnexus/jwt": "0.8.6",
|
||||||
|
"@wrnexus/observability": "0.8.6",
|
||||||
|
"@wrnexus/realtime": "0.8.6",
|
||||||
|
"@wrnexus/security": "0.8.6",
|
||||||
|
"@wrnexus/store": "0.8.6",
|
||||||
|
"@wrnexus/styles": "0.8.6",
|
||||||
|
"@wrnexus/tracking": "0.8.6",
|
||||||
|
"@wrnexus/ui": "0.8.6",
|
||||||
|
"@wrnexus/uploader": "0.8.6",
|
||||||
|
"@wrnexus/validation": "0.8.6",
|
||||||
|
"@wrnexus/authz": "0.8.6",
|
||||||
|
"@wrnexus/rpc": "file:../../../../packages/rpc",
|
||||||
|
"@app/shared": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@wrnexus/cli": "0.8.6",
|
||||||
|
"@eslint/js": "^9.0.0",
|
||||||
|
"@iconify-json/lucide": "^1.2.118",
|
||||||
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
|
"@tailwindcss/cli": "^4.0.0",
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"eslint": "^9.0.0",
|
||||||
|
"prettier": "latest",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"typescript": "^5.5.0",
|
||||||
|
"typescript-eslint": "latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
# WrNexus
|
||||||
|
|
||||||
|
> WrNexus is an SSR-first, **Bun-native** full-stack web framework. UI is written in
|
||||||
|
> `.wrn` files (its own component language — NOT React/JSX/Vue). Routing is file-based.
|
||||||
|
> This document teaches an AI how to write correct WrNexus code. It is private and
|
||||||
|
> post-dates model training data, so rely on THIS document, not prior web-framework
|
||||||
|
> assumptions.
|
||||||
|
|
||||||
|
## Golden rules
|
||||||
|
|
||||||
|
- **Pages, components, and layouts are `.wrn` files.** Do NOT write `.tsx`/`.jsx`/React
|
||||||
|
for UI. Do NOT use `useState`, hooks, JSX, or a client bundler.
|
||||||
|
- **Routing is file-based** under `app/`. The filename is the route. No router config.
|
||||||
|
- **Interactivity** lives in `state` + `{expr}` + `@event` inside `.wrn`. Components render
|
||||||
|
on the server and hydrate automatically — you never write client-side JS islands.
|
||||||
|
- **Runtime is Bun only** (uses `Bun.serve`, `bun:sqlite`, `Bun.password`, …). Node is not supported.
|
||||||
|
- To add files, prefer the CLI: `wrnexus generate page <Name>` / `component <name>` / `api <path>` / `schema <name>`.
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
pages/ *.wrn → routes: index.wrn = "/", about.wrn = "/about", blog/[slug].wrn = "/blog/:slug"
|
||||||
|
components/ *.wrn → reusable UI, mounted in a page/component via <div data-component="name" ...props>
|
||||||
|
layouts/ *.wrn → named layouts; a page opts in with layout = "name"
|
||||||
|
api/ *.ts → HTTP handlers: export const GET/POST/PUT/PATCH/DELETE = async (ctx) => Response
|
||||||
|
middleware/ *.ts → export default async (ctx, next) => next()
|
||||||
|
realtime/ *.ts → export default defineRoom({ ... }) from "@wrnexus/core" (ws://host/realtime/<name>)
|
||||||
|
schemas/ *.ts → validation schemas (the `v` builder), used by forms + parseBody
|
||||||
|
locales/ *.json → i18n messages per language
|
||||||
|
db/ schema.ts, queries/*.sql, migrations/*.sql
|
||||||
|
styles/ global.css → Tailwind (default) or plain CSS
|
||||||
|
wrnexus.config.ts → app config (AppConfig from "@wrnexus/styles")
|
||||||
|
public/ → static assets served at /
|
||||||
|
```
|
||||||
|
|
||||||
|
## `.wrn` page
|
||||||
|
|
||||||
|
```wrn
|
||||||
|
page Home {
|
||||||
|
layout = "public" // optional: a component in app/layouts/<name>.wrn ("none" to skip)
|
||||||
|
|
||||||
|
state count = 0 // optional: seeds client-reactive state (omit for pure SSR)
|
||||||
|
|
||||||
|
seo {
|
||||||
|
title = "Home"
|
||||||
|
description = "..."
|
||||||
|
canonical = "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
view {
|
||||||
|
<h1>Hello</h1>
|
||||||
|
<p>Count is {count}, doubled is {count * 2}.</p>
|
||||||
|
<button @click="count++">Increment</button>
|
||||||
|
<div data-component="counter" start="5" label="Clicks"></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
style {
|
||||||
|
h1 { color: var(--wire-color-text); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## `.wrn` component
|
||||||
|
|
||||||
|
```wrn
|
||||||
|
component Counter {
|
||||||
|
props { // props come from mount attributes; each is coerced to the
|
||||||
|
start = 0 // TYPE of its default (so start="5" arrives as the number 5)
|
||||||
|
label = "Count"
|
||||||
|
}
|
||||||
|
state count = start // state may reference props
|
||||||
|
view {
|
||||||
|
<button @click="count++">{label}: {count}</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Mount it from any page/component: `<div data-component="counter" start="0" label="Clicks"></div>`.
|
||||||
|
Components render on the server with their props, then hydrate — no per-component JS.
|
||||||
|
|
||||||
|
## The `view { }` block (plain HTML + a few directives)
|
||||||
|
|
||||||
|
- `{expr}` — interpolate a JS expression. Reactive if it references `state`: `{count}`, `{count * 2}`, `{user.name}`.
|
||||||
|
- `@event="expr"` — bind a DOM event; the expression runs in the reactive scope: `@click="count++"`, `@input="name = event.target.value"`.
|
||||||
|
- `<div data-component="name" prop="v">` — mount a component (attrs become string props, coerced).
|
||||||
|
- `<slot></slot>` / `<slot name="x"></slot>` — component/layout slots; fill with `<div data-slot="x">…</div>`.
|
||||||
|
- **Server loop (DB/list/table):** `{#each <list> as <item>[, <i>]} …rows… {:empty} …fallback… {/each}` — iterates SSR data on the server and renders markup per item. `{item.field}` interpolates (HTML-escaped, XSS-safe). `<list>` is a JS expression, usually an `ssr` data binding (see "Data-driven tables" below). This is how you render a database table in `.wrn`.
|
||||||
|
- **Server conditional:** `{#if <expr>} … {:else if <expr>} … {:else} … {/if}` — renders the first truthy branch on the server. `<expr>` can reference `ssr` data, or the `item`/`index` of an enclosing `{#each}`. Works at page level and inside loops (e.g. `{#if r.active}<span>●</span>{:else}<span>○</span>{/if}` per row). For client-side show/hide based on reactive `state`, use `data-show="expr"` instead.
|
||||||
|
- i18n: `{t:home.title}` in text, `t:placeholder="form.name"` on attributes — resolved per request from `app/locales/`.
|
||||||
|
- Theme: any element with `data-wire-theme-toggle` toggles light/dark; `data-wire-theme-set="dark"` sets it.
|
||||||
|
- Void/self-closing tags are fine: `<br />`, `<img src="..." />`.
|
||||||
|
- Only `{` and `}` are special (interpolation). Don't use a bare `}` in view text.
|
||||||
|
|
||||||
|
## Data-driven tables / lists (server-rendered `.wrn`)
|
||||||
|
|
||||||
|
Use an `ssr` data binding to fetch rows on the server, then `{#each}` to render them.
|
||||||
|
This renders on the **server** (SSR-first) and is HTML-escaped by default.
|
||||||
|
|
||||||
|
```wrn
|
||||||
|
page Admin {
|
||||||
|
layout = "dashboard"
|
||||||
|
|
||||||
|
// Fetch on the server. The api handler at /api/contacts returns { contacts: [...] };
|
||||||
|
// this block's `return contacts` exposes that array (via `$data`) as the binding `rows`.
|
||||||
|
ssr {
|
||||||
|
api rows GET /api/contacts { return contacts }
|
||||||
|
}
|
||||||
|
|
||||||
|
view {
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{#each rows as r, i}
|
||||||
|
<tr>
|
||||||
|
<td>#{i}</td>
|
||||||
|
<td>{r.name}</td>
|
||||||
|
<td><a href="mailto:{r.email}">{r.email}</a></td>
|
||||||
|
</tr>
|
||||||
|
{:empty}
|
||||||
|
<tr><td colspan="3">No submissions yet.</td></tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The matching API returns the array under a key the `ssr` block reads:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/api/contacts.ts → GET /api/contacts
|
||||||
|
import { getDb } from "@wrnexus/db";
|
||||||
|
export const GET = async () => {
|
||||||
|
const contacts = await getDb().all("SELECT id, name, email FROM contacts ORDER BY id DESC");
|
||||||
|
return Response.json({ contacts }); // ssr block does `return contacts`
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prefer this `.wrn` + `{#each}` approach for DB-backed tables and lists.** (`.ts`/`.tsx`
|
||||||
|
pages returning an HTML string are also supported for fully-custom programmatic rendering,
|
||||||
|
but a `.wrn` page with `ssr` data + `{#each}` is the idiomatic, SSR-first way.)
|
||||||
|
|
||||||
|
## API routes (`app/api/*.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/api/users/list.ts → GET /api/users/list
|
||||||
|
import { getDb } from "@wrnexus/db";
|
||||||
|
|
||||||
|
export const GET = async (ctx) => {
|
||||||
|
return Response.json({ users: await ListUsers(getDb()) });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST = async (ctx) => {
|
||||||
|
const body = await ctx.req.json();
|
||||||
|
return Response.json({ ok: true, body }, { status: 201 });
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`ctx` (the `Context` from `@wrnexus/core`) has:
|
||||||
|
`req: Request`, `url: URL`, `params: Record<string,string>` (dynamic route params, e.g. `/users/[id]` → `ctx.params.id`),
|
||||||
|
`lang: string`, `t(key, params?)` (i18n), `cookies` (get/set), `session` (get/set). Auth: `getUser(ctx)` after `sessionAuth`/`logIn`.
|
||||||
|
|
||||||
|
When an SSO forward-auth verifier needs the URL that originally reached the gateway, use
|
||||||
|
`@wrnexus/helpers` instead of constructing it from untrusted headers:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { redirectToLogin } from "@wrnexus/helpers";
|
||||||
|
|
||||||
|
return redirectToLogin(ctx, "/login", {
|
||||||
|
allowedHosts: ["admin.localhost:3000", "reports.localhost:3000"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The package also exports `getOriginalRequestUrl`, `getOriginalRequestOrigin`,
|
||||||
|
`getOriginalRequestPath`, and `getOriginalRequestMethod`. Always pass `allowedHosts` when
|
||||||
|
using forwarded gateway URLs; the helper rejects untrusted redirect destinations.
|
||||||
|
|
||||||
|
## Middleware & realtime
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/middleware/logger.ts
|
||||||
|
export default async function logger(ctx, next) {
|
||||||
|
console.log(ctx.req.method, ctx.url.pathname);
|
||||||
|
return next(); // return a Response WITHOUT calling next() to short-circuit
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/realtime/chat.ts → ws://host/realtime/chat
|
||||||
|
import { defineRoom } from "@wrnexus/core";
|
||||||
|
export default defineRoom({
|
||||||
|
onConnect(client) { client.send({ type: "system", text: "connected" }); },
|
||||||
|
onMessage(client, msg) { client.room.broadcast({ type: "message", data: msg }); },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
Client side: a page opts in with `data-room="chat"` (handled by the realtime runtime).
|
||||||
|
|
||||||
|
## Config (`wrnexus.config.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { AppConfig } from "@wrnexus/styles";
|
||||||
|
const config: AppConfig = {
|
||||||
|
seo: { title: "App", titleTemplate: "%s | App", description: "..." },
|
||||||
|
styles: { entry: "app/styles/global.css", process: async ({ entryPath, mode }) => /* Tailwind */ "" },
|
||||||
|
fonts: { sans: '"Inter", system-ui, sans-serif', google: [{ family: "Inter", weights: [400, 600] }] },
|
||||||
|
theme: { default: "dark", themes: { light: { "color-primary": "#2563eb" } } },
|
||||||
|
i18n: { default: "en", locales: ["en", "es"] },
|
||||||
|
db: { driver: "sqlite", url: "file:./dev.db" },
|
||||||
|
security: { cors: { enabled: true, origin: ["http://localhost:5173"] } },
|
||||||
|
// profiles: { production: { db: { driver: "postgres", url: process.env.DATABASE_URL } } },
|
||||||
|
};
|
||||||
|
export default config;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database (`@wrnexus/db`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/db/schema.ts
|
||||||
|
import { v, table } from "@wrnexus/db";
|
||||||
|
export const users = table("users", {
|
||||||
|
id: v.id(),
|
||||||
|
name: v.string(),
|
||||||
|
email: v.string().unique(),
|
||||||
|
createdAt: v.timestamp(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
- Queries: write `app/db/queries/*.sql` with `-- name: ListUsers :many` blocks; `wrnexus db generate` emits typed functions.
|
||||||
|
- Access at runtime: `import { getDb } from "@wrnexus/db"; const rows = await ListUsers(getDb());`
|
||||||
|
- Migrations in `app/db/migrations/`; run `wrnexus db migrate` (dev auto-migrates sqlite).
|
||||||
|
|
||||||
|
## Validation (`@wrnexus/validation`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/schemas/login.ts
|
||||||
|
import { v } from "@wrnexus/validation";
|
||||||
|
export default v.object({
|
||||||
|
email: v.string().email(),
|
||||||
|
password: v.string().min(8),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
In an API route: `import s from "../schemas/login"; import { parseBody } from "@wrnexus/validation"; const r = await parseBody(s, ctx.req);` → `r.ok ? r.value : r.response`.
|
||||||
|
In a form: `<form data-schema="login" action="/api/login" method="post">` + `<span data-error="email"></span>` (client + server validation wired automatically).
|
||||||
|
|
||||||
|
## AI / LLM (`@wrnexus/ai`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/api/ai.ts
|
||||||
|
import { createAI } from "@wrnexus/ai";
|
||||||
|
const ai = createAI(); // reads ANTHROPIC_API_KEY; default model claude-opus-4-8
|
||||||
|
export const POST = async (ctx) => {
|
||||||
|
const { prompt } = await ctx.req.json();
|
||||||
|
return ai.streamResponse(prompt); // or: return Response.json({ text: await ai.generate(prompt) })
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
```
|
||||||
|
wrnexus dev . # dev server + HMR
|
||||||
|
wrnexus build . # production build → dist/server.js
|
||||||
|
bun dist/server.js # run the production server (or npm start)
|
||||||
|
wrnexus create <name> # scaffold a new app
|
||||||
|
wrnexus update --latest # deps + syntax/config migrations + verification
|
||||||
|
wrnexus generate page <Name> # scaffold a page (aliases: g p)
|
||||||
|
wrnexus generate component <name> | api <path> | schema <name>
|
||||||
|
wrnexus db migrate | rollback | status | new [--from-models] | generate | seed
|
||||||
|
wrnexus eject <component> # copy a Wire UI component's .wrn into app/components to customize
|
||||||
|
```
|
||||||
|
|
||||||
|
## When asked to "create a page/component/feature"
|
||||||
|
|
||||||
|
1. Create the `.wrn` file under `app/pages/` (or `app/components/`) with a `page`/`component` block — or run `wrnexus generate page <Name>`.
|
||||||
|
2. Put markup in `view { }`, interactive bits in `state` + `{expr}` + `@event`, reusable UI as components mounted via `data-component`.
|
||||||
|
3. For data, add an `app/api/*.ts` route and `getDb()`; for forms, add an `app/schemas/*.ts` and `data-schema`.
|
||||||
|
4. Style with Tailwind utility classes in the view, or theme tokens (`var(--wire-*)`), or `style { }`.
|
||||||
|
5. Never emit React/JSX, a manual router, or client-side island JS — the framework handles hydration.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { parseOrThrow } from "@wrnexus/validation";
|
||||||
|
import { contactSchema } from "../app/schemas/contact.ts";
|
||||||
|
|
||||||
|
test("starter validation schema accepts a contact request", () => {
|
||||||
|
expect(
|
||||||
|
parseOrThrow(contactSchema, {
|
||||||
|
email: "hello@example.com",
|
||||||
|
message: "Hello from the generated application.",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
email: "hello@example.com",
|
||||||
|
message: "Hello from the generated application.",
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["bun"],
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": false,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "@wrnexus/core"
|
||||||
|
},
|
||||||
|
"include": ["app", "test", "wrnexus.config.ts"],
|
||||||
|
"exclude": ["node_modules", "dist", "**/dist", "**/.wrnexus"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import type { AppConfig } from "@wrnexus/styles";
|
||||||
|
|
||||||
|
const config: AppConfig = {
|
||||||
|
compatibilityDate: "2026-08-02",
|
||||||
|
frameworkBehaviour: 1,
|
||||||
|
// v0.8 defaults: explicit imports, strict template types, safe stores, and
|
||||||
|
// automatic progressive navigation. Package plugins are discovered from the
|
||||||
|
// installed packages above; add custom plugins to this array when needed.
|
||||||
|
plugins: [],
|
||||||
|
imports: { mode: "explicit", autoImport: true, aliases: { "@": "./app" } },
|
||||||
|
types: {
|
||||||
|
strict: true,
|
||||||
|
noImplicitAny: true,
|
||||||
|
strictNullChecks: true,
|
||||||
|
checkTemplates: true,
|
||||||
|
checkComponentProps: true,
|
||||||
|
generateDeclarations: true,
|
||||||
|
},
|
||||||
|
functions: { legacyDefaultRuntime: "current" },
|
||||||
|
stores: { strictMutations: true, persistence: true },
|
||||||
|
compatibility: {
|
||||||
|
legacyEmit: false,
|
||||||
|
legacyEventProps: false,
|
||||||
|
legacyComponentDiscovery: false,
|
||||||
|
stringLayouts: false,
|
||||||
|
},
|
||||||
|
experimental: {},
|
||||||
|
|
||||||
|
performance: {
|
||||||
|
enforcement: "warn",
|
||||||
|
analyze: true,
|
||||||
|
budgets: {
|
||||||
|
routeJsBytes: 50 * 1024,
|
||||||
|
routeCssBytes: 25 * 1024,
|
||||||
|
lcpMs: 2_500,
|
||||||
|
inpMs: 200,
|
||||||
|
cls: 0.1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
observability: {
|
||||||
|
enabled: true,
|
||||||
|
serviceName: "admin",
|
||||||
|
serverTiming: true,
|
||||||
|
sampleRate: 1,
|
||||||
|
exporter: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ? "otlp" : "none",
|
||||||
|
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||||
|
webVitals: true,
|
||||||
|
},
|
||||||
|
tenancy: { mode: "domain", required: false, rootDomains: ["localhost"] },
|
||||||
|
build: { cache: true, sourceMaps: true, report: true, adapter: "bun" },
|
||||||
|
navigation: { mode: "auto" },
|
||||||
|
devToolbar: { enabled: true, position: "bottom-center", openEditor: true },
|
||||||
|
|
||||||
|
mobile: {
|
||||||
|
enabled: true,
|
||||||
|
appId: "com.example.admin",
|
||||||
|
appName: "admin",
|
||||||
|
userAgent: "WrNexusMobile",
|
||||||
|
backgroundColor: "#0f172a",
|
||||||
|
// layout: "mobile", // app/layouts/mobile.wrn
|
||||||
|
// icon: "resources/icon.png",
|
||||||
|
},
|
||||||
|
|
||||||
|
// PWA support is enabled automatically. Override any install metadata here.
|
||||||
|
pwa: {
|
||||||
|
name: "admin",
|
||||||
|
shortName: "admin",
|
||||||
|
display: "standalone",
|
||||||
|
themeColor: "#6366f1",
|
||||||
|
backgroundColor: "#0f172a",
|
||||||
|
},
|
||||||
|
|
||||||
|
seo: {
|
||||||
|
title: "admin",
|
||||||
|
titleTemplate: "%s | admin",
|
||||||
|
// Set WRNEXUS_PUBLIC_ORIGIN in production when TLS terminates at a proxy.
|
||||||
|
canonicalBase: process.env.WRNEXUS_PUBLIC_ORIGIN,
|
||||||
|
description: "An SSR-first WrNexus app.",
|
||||||
|
robots: "index,follow",
|
||||||
|
themeColor: "#6366f1",
|
||||||
|
},
|
||||||
|
|
||||||
|
styles: {
|
||||||
|
entry: "app/styles/global.css",
|
||||||
|
|
||||||
|
// Tailwind v4 build. Runs once at dev-serve time (cached; re-run on restart)
|
||||||
|
// and at `wrnexus build`. `@tailwindcss/cli` writes to stdout, so we capture
|
||||||
|
// and return the final CSS. Delete this hook to drop Tailwind — global.css is
|
||||||
|
// still bundled and served as-is.
|
||||||
|
process: async ({ entryPath, appRoot, mode }) => {
|
||||||
|
const args = ["@tailwindcss/cli", "-i", entryPath!];
|
||||||
|
if (mode === "production") args.push("--minify");
|
||||||
|
return await Bun.$.cwd(appRoot)`bunx ${args}`.text();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Fonts — optimized preconnect, subsetted weights, font-display, and CSP.
|
||||||
|
fonts: {
|
||||||
|
sans: '"Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif',
|
||||||
|
google: [{ family: "Plus Jakarta Sans", weights: [400, 500, 600, 700] }],
|
||||||
|
},
|
||||||
|
//
|
||||||
|
// // Or self-host (fastest, no third party) — drop files in public/fonts/:
|
||||||
|
// // local: [{ family: "Inter", src: "/fonts/inter.woff2", weight: "100 900", preload: true }],
|
||||||
|
|
||||||
|
theme: { palette: "violet", default: "light" },
|
||||||
|
i18n: { default: "en", locales: ["en"] },
|
||||||
|
db: { driver: "sqlite", url: process.env.DATABASE_URL ?? "file:./dev.db" },
|
||||||
|
databases: {},
|
||||||
|
storage: {
|
||||||
|
default: "public",
|
||||||
|
stores: {
|
||||||
|
public: {
|
||||||
|
driver: "local",
|
||||||
|
access: "public",
|
||||||
|
dir: "uploads/public",
|
||||||
|
maxBytes: 10_000_000,
|
||||||
|
accept: ["image/*", "application/pdf"],
|
||||||
|
},
|
||||||
|
private: {
|
||||||
|
driver: "local",
|
||||||
|
access: "private",
|
||||||
|
dir: "uploads/private",
|
||||||
|
maxBytes: 10_000_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
realtime: { scale: Boolean(process.env.REDIS_URL), redisUrl: process.env.REDIS_URL },
|
||||||
|
port: Number(process.env.PORT ?? 3000),
|
||||||
|
security: {
|
||||||
|
cors: { enabled: false },
|
||||||
|
},
|
||||||
|
profiles: {
|
||||||
|
development: {},
|
||||||
|
test: {
|
||||||
|
db: { driver: "sqlite", url: "file:./test.db" },
|
||||||
|
observability: { exporter: "none", sampleRate: 0 },
|
||||||
|
},
|
||||||
|
staging: {
|
||||||
|
seo: { robots: "noindex,nofollow" },
|
||||||
|
performance: { enforcement: "error" },
|
||||||
|
build: { sourceMaps: true, report: true },
|
||||||
|
},
|
||||||
|
production: {
|
||||||
|
seo: { canonicalBase: process.env.WRNEXUS_PUBLIC_ORIGIN },
|
||||||
|
performance: { enforcement: "error" },
|
||||||
|
build: { sourceMaps: false, report: true },
|
||||||
|
devToolbar: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Copy to .env for local development. Never commit real secrets.
|
||||||
|
WRNEXUS_PUBLIC_ORIGIN=http://localhost:3000
|
||||||
|
DATABASE_URL=file:./dev.db
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
AUTH_SECRET=replace-with-at-least-32-random-characters
|
||||||
|
ENCRYPTION_KEY=replace-with-a-base64-encoded-32-byte-key
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
WRNEXUS_PUBLIC_ORIGIN=http://localhost:3000
|
||||||
|
DATABASE_URL=file:./test.db
|
||||||
|
AUTH_SECRET=test-only-secret-replace-outside-tests
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# WRNexusJS and production builds
|
||||||
|
dist/
|
||||||
|
.wrnexus/
|
||||||
|
**/.wrnexus/
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Environment files and local secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.*.example
|
||||||
|
|
||||||
|
# Logs and runtime files
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
*.pid
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Local databases
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
uploads/
|
||||||
|
|
||||||
|
# Generated native projects
|
||||||
|
mobile/android/
|
||||||
|
mobile/ios/
|
||||||
|
mobile/.expo/
|
||||||
|
|
||||||
|
# Editors and operating systems
|
||||||
|
.idea/
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# TypeScript and test caches
|
||||||
|
*.tsbuildinfo
|
||||||
|
.eslintcache
|
||||||
|
.nyc_output/
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.wrnexus/
|
||||||
|
**/.wrnexus/
|
||||||
|
*.log
|
||||||
|
CLAUDE.md
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["wrnexus.wrnexus", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": "explicit"
|
||||||
|
},
|
||||||
|
"prettier.requireConfig": true,
|
||||||
|
"[wrn]": {
|
||||||
|
"editor.defaultFormatter": "wrnexus.wrnexus",
|
||||||
|
"editor.formatOnSave": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
# WrNexus app - instructions for AI coding assistants
|
||||||
|
|
||||||
|
This is a **WrNexus** app. When creating or editing pages, components, API routes,
|
||||||
|
or features, follow the framework conventions below. WrNexus is private and not in
|
||||||
|
your training data, so rely on these rules - do NOT assume React/Next.js/Vue patterns.
|
||||||
|
|
||||||
|
# WrNexus
|
||||||
|
|
||||||
|
> WrNexus is an SSR-first, **Bun-native** full-stack web framework. UI is written in
|
||||||
|
> `.wrn` files (its own component language — NOT React/JSX/Vue). Routing is file-based.
|
||||||
|
> This document teaches an AI how to write correct WrNexus code. It is private and
|
||||||
|
> post-dates model training data, so rely on THIS document, not prior web-framework
|
||||||
|
> assumptions.
|
||||||
|
|
||||||
|
## Golden rules
|
||||||
|
|
||||||
|
- **Pages, components, and layouts are `.wrn` files.** Do NOT write `.tsx`/`.jsx`/React
|
||||||
|
for UI. Do NOT use `useState`, hooks, JSX, or a client bundler.
|
||||||
|
- **Routing is file-based** under `app/`. The filename is the route. No router config.
|
||||||
|
- **Interactivity** lives in `state` + `{expr}` + `@event` inside `.wrn`. Components render
|
||||||
|
on the server and hydrate automatically — you never write client-side JS islands.
|
||||||
|
- **Runtime is Bun only** (uses `Bun.serve`, `bun:sqlite`, `Bun.password`, …). Node is not supported.
|
||||||
|
- To add files, prefer the CLI: `wrnexus generate page <Name>` / `component <name>` / `api <path>` / `schema <name>`.
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
pages/ *.wrn → routes: index.wrn = "/", about.wrn = "/about", blog/[slug].wrn = "/blog/:slug"
|
||||||
|
components/ *.wrn → reusable UI, mounted in a page/component via <div data-component="name" ...props>
|
||||||
|
layouts/ *.wrn → named layouts; a page opts in with layout = "name"
|
||||||
|
api/ *.ts → HTTP handlers: export const GET/POST/PUT/PATCH/DELETE = async (ctx) => Response
|
||||||
|
middleware/ *.ts → export default async (ctx, next) => next()
|
||||||
|
realtime/ *.ts → export default defineRoom({ ... }) from "@wrnexus/core" (ws://host/realtime/<name>)
|
||||||
|
schemas/ *.ts → validation schemas (the `v` builder), used by forms + parseBody
|
||||||
|
locales/ *.json → i18n messages per language
|
||||||
|
db/ schema.ts, queries/*.sql, migrations/*.sql
|
||||||
|
styles/ global.css → Tailwind (default) or plain CSS
|
||||||
|
wrnexus.config.ts → app config (AppConfig from "@wrnexus/styles")
|
||||||
|
public/ → static assets served at /
|
||||||
|
```
|
||||||
|
|
||||||
|
## `.wrn` page
|
||||||
|
|
||||||
|
```wrn
|
||||||
|
page Home {
|
||||||
|
layout = "public" // optional: a component in app/layouts/<name>.wrn ("none" to skip)
|
||||||
|
|
||||||
|
state count = 0 // optional: seeds client-reactive state (omit for pure SSR)
|
||||||
|
|
||||||
|
seo {
|
||||||
|
title = "Home"
|
||||||
|
description = "..."
|
||||||
|
canonical = "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
view {
|
||||||
|
<h1>Hello</h1>
|
||||||
|
<p>Count is {count}, doubled is {count * 2}.</p>
|
||||||
|
<button @click="count++">Increment</button>
|
||||||
|
<div data-component="counter" start="5" label="Clicks"></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
style {
|
||||||
|
h1 { color: var(--wire-color-text); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## `.wrn` component
|
||||||
|
|
||||||
|
```wrn
|
||||||
|
component Counter {
|
||||||
|
props { // props come from mount attributes; each is coerced to the
|
||||||
|
start = 0 // TYPE of its default (so start="5" arrives as the number 5)
|
||||||
|
label = "Count"
|
||||||
|
}
|
||||||
|
state count = start // state may reference props
|
||||||
|
view {
|
||||||
|
<button @click="count++">{label}: {count}</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Mount it from any page/component: `<div data-component="counter" start="0" label="Clicks"></div>`.
|
||||||
|
Components render on the server with their props, then hydrate — no per-component JS.
|
||||||
|
|
||||||
|
## The `view { }` block (plain HTML + a few directives)
|
||||||
|
|
||||||
|
- `{expr}` — interpolate a JS expression. Reactive if it references `state`: `{count}`, `{count * 2}`, `{user.name}`.
|
||||||
|
- `@event="expr"` — bind a DOM event; the expression runs in the reactive scope: `@click="count++"`, `@input="name = event.target.value"`.
|
||||||
|
- `<div data-component="name" prop="v">` — mount a component (attrs become string props, coerced).
|
||||||
|
- `<slot></slot>` / `<slot name="x"></slot>` — component/layout slots; fill with `<div data-slot="x">…</div>`.
|
||||||
|
- **Server loop (DB/list/table):** `{#each <list> as <item>[, <i>]} …rows… {:empty} …fallback… {/each}` — iterates SSR data on the server and renders markup per item. `{item.field}` interpolates (HTML-escaped, XSS-safe). `<list>` is a JS expression, usually an `ssr` data binding (see "Data-driven tables" below). This is how you render a database table in `.wrn`.
|
||||||
|
- **Server conditional:** `{#if <expr>} … {:else if <expr>} … {:else} … {/if}` — renders the first truthy branch on the server. `<expr>` can reference `ssr` data, or the `item`/`index` of an enclosing `{#each}`. Works at page level and inside loops (e.g. `{#if r.active}<span>●</span>{:else}<span>○</span>{/if}` per row). For client-side show/hide based on reactive `state`, use `data-show="expr"` instead.
|
||||||
|
- i18n: `{t:home.title}` in text, `t:placeholder="form.name"` on attributes — resolved per request from `app/locales/`.
|
||||||
|
- Theme: any element with `data-wire-theme-toggle` toggles light/dark; `data-wire-theme-set="dark"` sets it.
|
||||||
|
- Void/self-closing tags are fine: `<br />`, `<img src="..." />`.
|
||||||
|
- Only `{` and `}` are special (interpolation). Don't use a bare `}` in view text.
|
||||||
|
|
||||||
|
## Data-driven tables / lists (server-rendered `.wrn`)
|
||||||
|
|
||||||
|
Use an `ssr` data binding to fetch rows on the server, then `{#each}` to render them.
|
||||||
|
This renders on the **server** (SSR-first) and is HTML-escaped by default.
|
||||||
|
|
||||||
|
```wrn
|
||||||
|
page Admin {
|
||||||
|
layout = "dashboard"
|
||||||
|
|
||||||
|
// Fetch on the server. The api handler at /api/contacts returns { contacts: [...] };
|
||||||
|
// this block's `return contacts` exposes that array (via `$data`) as the binding `rows`.
|
||||||
|
ssr {
|
||||||
|
api rows GET /api/contacts { return contacts }
|
||||||
|
}
|
||||||
|
|
||||||
|
view {
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{#each rows as r, i}
|
||||||
|
<tr>
|
||||||
|
<td>#{i}</td>
|
||||||
|
<td>{r.name}</td>
|
||||||
|
<td><a href="mailto:{r.email}">{r.email}</a></td>
|
||||||
|
</tr>
|
||||||
|
{:empty}
|
||||||
|
<tr><td colspan="3">No submissions yet.</td></tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The matching API returns the array under a key the `ssr` block reads:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/api/contacts.ts → GET /api/contacts
|
||||||
|
import { getDb } from "@wrnexus/db";
|
||||||
|
export const GET = async () => {
|
||||||
|
const contacts = await getDb().all("SELECT id, name, email FROM contacts ORDER BY id DESC");
|
||||||
|
return Response.json({ contacts }); // ssr block does `return contacts`
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prefer this `.wrn` + `{#each}` approach for DB-backed tables and lists.** (`.ts`/`.tsx`
|
||||||
|
pages returning an HTML string are also supported for fully-custom programmatic rendering,
|
||||||
|
but a `.wrn` page with `ssr` data + `{#each}` is the idiomatic, SSR-first way.)
|
||||||
|
|
||||||
|
## API routes (`app/api/*.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/api/users/list.ts → GET /api/users/list
|
||||||
|
import { getDb } from "@wrnexus/db";
|
||||||
|
|
||||||
|
export const GET = async (ctx) => {
|
||||||
|
return Response.json({ users: await ListUsers(getDb()) });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST = async (ctx) => {
|
||||||
|
const body = await ctx.req.json();
|
||||||
|
return Response.json({ ok: true, body }, { status: 201 });
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`ctx` (the `Context` from `@wrnexus/core`) has:
|
||||||
|
`req: Request`, `url: URL`, `params: Record<string,string>` (dynamic route params, e.g. `/users/[id]` → `ctx.params.id`),
|
||||||
|
`lang: string`, `t(key, params?)` (i18n), `cookies` (get/set), `session` (get/set). Auth: `getUser(ctx)` after `sessionAuth`/`logIn`.
|
||||||
|
|
||||||
|
When an SSO forward-auth verifier needs the URL that originally reached the gateway, use
|
||||||
|
`@wrnexus/helpers` instead of constructing it from untrusted headers:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { redirectToLogin } from "@wrnexus/helpers";
|
||||||
|
|
||||||
|
return redirectToLogin(ctx, "/login", {
|
||||||
|
allowedHosts: ["admin.localhost:3000", "reports.localhost:3000"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The package also exports `getOriginalRequestUrl`, `getOriginalRequestOrigin`,
|
||||||
|
`getOriginalRequestPath`, and `getOriginalRequestMethod`. Always pass `allowedHosts` when
|
||||||
|
using forwarded gateway URLs; the helper rejects untrusted redirect destinations.
|
||||||
|
|
||||||
|
## Middleware & realtime
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/middleware/logger.ts
|
||||||
|
export default async function logger(ctx, next) {
|
||||||
|
console.log(ctx.req.method, ctx.url.pathname);
|
||||||
|
return next(); // return a Response WITHOUT calling next() to short-circuit
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/realtime/chat.ts → ws://host/realtime/chat
|
||||||
|
import { defineRoom } from "@wrnexus/core";
|
||||||
|
export default defineRoom({
|
||||||
|
onConnect(client) {
|
||||||
|
client.send({ type: "system", text: "connected" });
|
||||||
|
},
|
||||||
|
onMessage(client, msg) {
|
||||||
|
client.room.broadcast({ type: "message", data: msg });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Client side: a page opts in with `data-room="chat"` (handled by the realtime runtime).
|
||||||
|
|
||||||
|
## Config (`wrnexus.config.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { AppConfig } from "@wrnexus/styles";
|
||||||
|
const config: AppConfig = {
|
||||||
|
seo: { title: "App", titleTemplate: "%s | App", description: "..." },
|
||||||
|
styles: {
|
||||||
|
entry: "app/styles/global.css",
|
||||||
|
process: async ({ entryPath, mode }) => /* Tailwind */ "",
|
||||||
|
},
|
||||||
|
fonts: {
|
||||||
|
sans: '"Inter", system-ui, sans-serif',
|
||||||
|
google: [{ family: "Inter", weights: [400, 600] }],
|
||||||
|
},
|
||||||
|
theme: { default: "dark", themes: { light: { "color-primary": "#2563eb" } } },
|
||||||
|
i18n: { default: "en", locales: ["en", "es"] },
|
||||||
|
db: { driver: "sqlite", url: "file:./dev.db" },
|
||||||
|
security: { cors: { enabled: true, origin: ["http://localhost:5173"] } },
|
||||||
|
// profiles: { production: { db: { driver: "postgres", url: process.env.DATABASE_URL } } },
|
||||||
|
};
|
||||||
|
export default config;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database (`@wrnexus/db`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/db/schema.ts
|
||||||
|
import { v, table } from "@wrnexus/db";
|
||||||
|
export const users = table("users", {
|
||||||
|
id: v.id(),
|
||||||
|
name: v.string(),
|
||||||
|
email: v.string().unique(),
|
||||||
|
createdAt: v.timestamp(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- Queries: write `app/db/queries/*.sql` with `-- name: ListUsers :many` blocks; `wrnexus db generate` emits typed functions.
|
||||||
|
- Access at runtime: `import { getDb } from "@wrnexus/db"; const rows = await ListUsers(getDb());`
|
||||||
|
- Migrations in `app/db/migrations/`; run `wrnexus db migrate` (dev auto-migrates sqlite).
|
||||||
|
|
||||||
|
## Validation (`@wrnexus/validation`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/schemas/login.ts
|
||||||
|
import { v } from "@wrnexus/validation";
|
||||||
|
export default v.object({
|
||||||
|
email: v.string().email(),
|
||||||
|
password: v.string().min(8),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
In an API route: `import s from "../schemas/login"; import { parseBody } from "@wrnexus/validation"; const r = await parseBody(s, ctx.req);` → `r.ok ? r.value : r.response`.
|
||||||
|
In a form: `<form data-schema="login" action="/api/login" method="post">` + `<span data-error="email"></span>` (client + server validation wired automatically).
|
||||||
|
|
||||||
|
## AI / LLM (`@wrnexus/ai`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/api/ai.ts
|
||||||
|
import { createAI } from "@wrnexus/ai";
|
||||||
|
const ai = createAI(); // reads ANTHROPIC_API_KEY; default model claude-opus-4-8
|
||||||
|
export const POST = async (ctx) => {
|
||||||
|
const { prompt } = await ctx.req.json();
|
||||||
|
return ai.streamResponse(prompt); // or: return Response.json({ text: await ai.generate(prompt) })
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
```
|
||||||
|
wrnexus dev . # dev server + HMR
|
||||||
|
wrnexus build . # production build → dist/server.js
|
||||||
|
bun dist/server.js # run the production server (or npm start)
|
||||||
|
wrnexus create <name> # scaffold a new app
|
||||||
|
wrnexus update --latest # deps + syntax/config migrations + verification
|
||||||
|
wrnexus generate page <Name> # scaffold a page (aliases: g p)
|
||||||
|
wrnexus generate component <name> | api <path> | schema <name>
|
||||||
|
wrnexus db migrate | rollback | status | new [--from-models] | generate | seed
|
||||||
|
wrnexus eject <component> # copy a Wire UI component's .wrn into app/components to customize
|
||||||
|
```
|
||||||
|
|
||||||
|
## When asked to "create a page/component/feature"
|
||||||
|
|
||||||
|
1. Create the `.wrn` file under `app/pages/` (or `app/components/`) with a `page`/`component` block — or run `wrnexus generate page <Name>`.
|
||||||
|
2. Put markup in `view { }`, interactive bits in `state` + `{expr}` + `@event`, reusable UI as components mounted via `data-component`.
|
||||||
|
3. For data, add an `app/api/*.ts` route and `getDb()`; for forms, add an `app/schemas/*.ts` and `data-schema`.
|
||||||
|
4. Style with Tailwind utility classes in the view, or theme tokens (`var(--wire-*)`), or `style { }`.
|
||||||
|
5. Never emit React/JSX, a manual router, or client-side island JS — the framework handles hydration.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// POST /api/ai { "prompt": "..." } → Claude's reply.
|
||||||
|
// Set ANTHROPIC_API_KEY in your environment (e.g. a .env file) to enable this.
|
||||||
|
import { createAI } from "@wrnexus/ai";
|
||||||
|
import type { Context } from "@wrnexus/core";
|
||||||
|
|
||||||
|
const ai = createAI(); // reads ANTHROPIC_API_KEY; defaults to claude-opus-4-8
|
||||||
|
|
||||||
|
export const POST = async (ctx: Context) => {
|
||||||
|
if (!process.env.ANTHROPIC_API_KEY) {
|
||||||
|
return Response.json({ error: "Set ANTHROPIC_API_KEY to use AI." }, { status: 501 });
|
||||||
|
}
|
||||||
|
const { prompt } = await ctx.req.json().catch(() => ({}));
|
||||||
|
if (!prompt) return Response.json({ error: "Provide a 'prompt'." }, { status: 400 });
|
||||||
|
|
||||||
|
// Stream the reply back as plain text. Use `ai.generate(prompt)` for a one-shot string.
|
||||||
|
return ai.streamResponse(prompt);
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const GET = async () => {
|
||||||
|
return Response.json({ message: "Hello API" });
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Context } from "../../../../../../packages/core/src/index.ts";
|
||||||
|
import {
|
||||||
|
httpTransport,
|
||||||
|
retryingTransport,
|
||||||
|
serviceClient,
|
||||||
|
} from "../../../../../../packages/rpc/src/index.ts";
|
||||||
|
import { catalogService } from "../../../../packages/shared/src/index.ts";
|
||||||
|
|
||||||
|
/** GET /api/product?sku=starter — obtains product data from the admin app. */
|
||||||
|
export async function GET(ctx: Context): Promise<Response> {
|
||||||
|
const sku = new URL(ctx.req.url).searchParams.get("sku")?.trim() || "starter";
|
||||||
|
const catalog = serviceClient(catalogService, {
|
||||||
|
app: "admin",
|
||||||
|
as: ctx,
|
||||||
|
transport: retryingTransport(
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// A reusable component. Route: none — mounted inside a page with
|
||||||
|
// <div data-component="counter" ...props></div>.
|
||||||
|
//
|
||||||
|
// Components render on the SERVER (with their props applied) and are hydrated in
|
||||||
|
// the browser by the generic reactive runtime — they ship no JS of their own.
|
||||||
|
component Counter {
|
||||||
|
// Props arrive as mount attributes, each coerced to the type of its default
|
||||||
|
// (so start="5" arrives as the number 5).
|
||||||
|
props {
|
||||||
|
start = 0
|
||||||
|
label = "Count"
|
||||||
|
}
|
||||||
|
|
||||||
|
// State can reference props. `count` seeds the reactive scope.
|
||||||
|
state count = start
|
||||||
|
|
||||||
|
view {
|
||||||
|
<button @click="count++" class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-indigo-500 active:scale-[0.98]">{label}: {count}</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Create application tables here.
|
||||||
|
-- Run with: bunx wrnexus db migrate
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Add deterministic development seed data here.
|
||||||
|
export async function seed(): Promise<void> {}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Global document layout. The framework renders this once around the selected
|
||||||
|
// page layout and merges SEO metadata, styles, and scripts into <head>/<body>.
|
||||||
|
// Request cookies, resolved theme, language, URL, and pathname are available
|
||||||
|
// as SSR props, so document attributes do not need a client-side correction.
|
||||||
|
layout Document {
|
||||||
|
props {
|
||||||
|
cookies = {}
|
||||||
|
theme = "light"
|
||||||
|
language = "en"
|
||||||
|
url = ""
|
||||||
|
pathname = "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
view {
|
||||||
|
<html>
|
||||||
|
<head></head>
|
||||||
|
<body>
|
||||||
|
<div id="app"><slot /></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"appName": "web",
|
||||||
|
"welcome": "Welcome to web"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { Middleware } from "@wrnexus/core";
|
||||||
|
|
||||||
|
const logger: Middleware = async (ctx, next) => {
|
||||||
|
console.log(ctx.req.method, ctx.url.pathname);
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
|
||||||
|
export default logger;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
page About {
|
||||||
|
seo {
|
||||||
|
title = "About"
|
||||||
|
description = "Learn how web is built with WrNexus."
|
||||||
|
}
|
||||||
|
|
||||||
|
view {
|
||||||
|
<main class="min-h-screen bg-white px-6 py-20 text-slate-900 dark:bg-[#0b0f1e] dark:text-slate-100">
|
||||||
|
<article class="mx-auto max-w-2xl">
|
||||||
|
<a href="/" class="text-sm text-indigo-600 hover:underline dark:text-indigo-400">← Home</a>
|
||||||
|
<p class="mt-12 font-mono text-xs uppercase tracking-[0.2em] text-indigo-500">WrNexus application</p>
|
||||||
|
<h1 class="mt-4 text-4xl font-bold tracking-tight">About web</h1>
|
||||||
|
<p class="mt-6 text-lg leading-8 text-slate-600 dark:text-slate-400">
|
||||||
|
This page is server-rendered from <code>app/pages/about.wrn</code>. Add state,
|
||||||
|
events, components, APIs, and data without switching to another UI framework.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Home page (route: /). SSR-first: the view is server-rendered, then components
|
||||||
|
// (.wrn files under app/components) hydrate in the browser. Styled with Tailwind.
|
||||||
|
page Home {
|
||||||
|
seo {
|
||||||
|
title = "Home"
|
||||||
|
description = "web — built with WrNexus, an SSR-first Bun framework."
|
||||||
|
}
|
||||||
|
|
||||||
|
view {
|
||||||
|
<main class="relative min-h-screen overflow-hidden bg-white text-slate-900 dark:bg-[#0b0f1e] dark:text-slate-100">
|
||||||
|
<div aria-hidden="true" class="pointer-events-none absolute inset-x-0 -top-40 mx-auto h-96 max-w-2xl rounded-full bg-indigo-500/20 blur-3xl"></div>
|
||||||
|
|
||||||
|
<div class="relative mx-auto flex min-h-screen max-w-3xl flex-col px-6">
|
||||||
|
<header class="flex items-center justify-between py-6">
|
||||||
|
<span class="flex items-center gap-2.5 font-semibold tracking-tight">
|
||||||
|
<span class="grid h-7 w-7 place-items-center rounded-md bg-gradient-to-br from-indigo-500 to-violet-600 text-sm font-bold text-white">W</span>
|
||||||
|
web
|
||||||
|
</span>
|
||||||
|
<button data-wire-theme-toggle class="rounded-md border border-slate-200 px-3 py-1.5 text-sm text-slate-600 transition hover:border-slate-300 hover:text-slate-900 dark:border-white/10 dark:text-slate-400 dark:hover:border-white/20 dark:hover:text-white">
|
||||||
|
Toggle theme
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="flex flex-1 flex-col items-center justify-center py-16 text-center">
|
||||||
|
<p class="font-mono text-xs uppercase tracking-[0.2em] text-indigo-500 dark:text-indigo-400">SSR-first · Bun-native</p>
|
||||||
|
|
||||||
|
<h1 class="mt-5 text-4xl font-bold leading-[1.1] tracking-tight sm:text-6xl">
|
||||||
|
Server-rendered.<br />
|
||||||
|
Instantly <span class="bg-gradient-to-r from-indigo-500 to-violet-500 bg-clip-text text-transparent">interactive</span>.
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="mt-5 max-w-md text-base leading-relaxed text-slate-600 dark:text-slate-400">
|
||||||
|
web runs on WrNexus — write <code class="rounded bg-slate-100 px-1.5 py-0.5 font-mono text-[0.85em] text-slate-800 dark:bg-white/10 dark:text-slate-200">.wrn</code> components, ship no client boilerplate, and let the server do the work.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||||
|
<a href="/about" class="rounded-lg bg-slate-900 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition hover:bg-slate-700 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200">Get started</a>
|
||||||
|
<a href="/api/hello" class="rounded-lg border border-slate-200 px-5 py-2.5 text-sm font-medium text-slate-700 transition hover:border-slate-300 dark:border-white/10 dark:text-slate-300 dark:hover:border-white/20">View API</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-14 w-full max-w-md rounded-2xl border border-slate-200 bg-white p-6 text-left shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||||
|
<div class="flex items-center gap-2 font-mono text-xs text-slate-400">
|
||||||
|
<span class="h-2 w-2 rounded-full bg-emerald-400"></span>
|
||||||
|
live · hydrated on the server
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex items-center justify-between gap-4">
|
||||||
|
<div data-component="counter" start="0" label="Clicks"></div>
|
||||||
|
<span class="max-w-[10rem] text-right text-xs leading-snug text-slate-500">This button works. You wrote zero client JavaScript.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-10 font-mono text-xs text-slate-400 dark:text-slate-600">
|
||||||
|
edit <span class="text-slate-600 dark:text-slate-400">app/pages/index.wrn</span> to make it yours
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="border-t border-slate-100 py-6 text-center text-xs text-slate-400 dark:border-white/5 dark:text-slate-600">
|
||||||
|
Built with <a href="https://www.npmjs.com/package/@wrnexus/cli" class="text-slate-600 underline-offset-2 hover:underline dark:text-slate-400">WrNexus</a>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// ws://<host>/realtime/chat — a simple broadcast room.
|
||||||
|
//
|
||||||
|
// The client side is the framework's realtime runtime; a page opts in with
|
||||||
|
// `data-room="chat"`. Here we only handle room events.
|
||||||
|
//
|
||||||
|
// client.send(msg) → just this connection
|
||||||
|
// client.broadcast(msg) → everyone else in the room
|
||||||
|
// client.room.broadcast(msg) → everyone, including the sender
|
||||||
|
import { defineRoom } from "@wrnexus/core";
|
||||||
|
|
||||||
|
export default defineRoom({
|
||||||
|
onConnect(client) {
|
||||||
|
client.send({ type: "system", text: "connected" });
|
||||||
|
},
|
||||||
|
|
||||||
|
onMessage(client, msg) {
|
||||||
|
// Echo each message to the whole room so every tab stays in sync.
|
||||||
|
client.room.broadcast({ type: "message", data: msg });
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { v } from "@wrnexus/validation";
|
||||||
|
|
||||||
|
export const contactSchema = v.object({
|
||||||
|
email: v.string().email(),
|
||||||
|
message: v.string().min(10).max(2_000),
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/*
|
||||||
|
* Global stylesheet. Tailwind v4 is compiled by the styles.process hook in
|
||||||
|
* wrnexus.config.ts and served at /__wrnexus/styles.css on every page.
|
||||||
|
*
|
||||||
|
* @source tells Tailwind which files to scan for class names.
|
||||||
|
*/
|
||||||
|
@import "tailwindcss";
|
||||||
|
@plugin "@iconify/tailwind4";
|
||||||
|
@source "../**/*.wrn";
|
||||||
|
@source "../**/*.tsx";
|
||||||
|
|
||||||
|
/* Make Tailwind's `dark:` variant follow the framework's data-theme attribute
|
||||||
|
* (set on <html> by the theme system), not the OS setting. Any element with
|
||||||
|
* data-wire-theme-toggle flips it. */
|
||||||
|
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--wire-font-sans, "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { dirname } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import js from "@eslint/js";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
const tsconfigRootDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
"node_modules/**",
|
||||||
|
"dist/**",
|
||||||
|
".wrnexus/**",
|
||||||
|
"**/.wrnexus/**",
|
||||||
|
"mobile/android/**",
|
||||||
|
"mobile/ios/**",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
tsconfigRootDir,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
rules: {
|
||||||
|
"no-undef": "off",
|
||||||
|
"no-console": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
argsIgnorePattern: "^_",
|
||||||
|
varsIgnorePattern: "^_",
|
||||||
|
caughtErrorsIgnorePattern: "^_",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"wrnexus": {
|
||||||
|
"version": "0.8.6"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "wrnexus dev .",
|
||||||
|
"build": "wrnexus build .",
|
||||||
|
"start": "bun dist/server.js",
|
||||||
|
"production": "bun run build && bun run start",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "wrnexus test .",
|
||||||
|
"test:watch": "wrnexus test . --watch",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
|
"format": "prettier . --write",
|
||||||
|
"format:check": "prettier . --check",
|
||||||
|
"doctor": "wrnexus doctor .",
|
||||||
|
"analyze": "wrnexus analyze .",
|
||||||
|
"inspect": "wrnexus inspect packages .",
|
||||||
|
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@wrnexus/ai": "0.8.6",
|
||||||
|
"@wrnexus/auth": "0.8.6",
|
||||||
|
"@wrnexus/captcha": "0.8.6",
|
||||||
|
"@wrnexus/core": "file:../../../../packages/core",
|
||||||
|
"@wrnexus/csr": "0.8.6",
|
||||||
|
"@wrnexus/db": "0.8.6",
|
||||||
|
"@wrnexus/dev-server": "0.8.6",
|
||||||
|
"@wrnexus/encryption": "0.8.6",
|
||||||
|
"@wrnexus/helpers": "0.8.6",
|
||||||
|
"@wrnexus/i18n": "0.8.6",
|
||||||
|
"@wrnexus/image": "0.8.6",
|
||||||
|
"@wrnexus/jwt": "0.8.6",
|
||||||
|
"@wrnexus/observability": "0.8.6",
|
||||||
|
"@wrnexus/realtime": "0.8.6",
|
||||||
|
"@wrnexus/security": "0.8.6",
|
||||||
|
"@wrnexus/store": "0.8.6",
|
||||||
|
"@wrnexus/styles": "0.8.6",
|
||||||
|
"@wrnexus/tracking": "0.8.6",
|
||||||
|
"@wrnexus/ui": "0.8.6",
|
||||||
|
"@wrnexus/uploader": "0.8.6",
|
||||||
|
"@wrnexus/validation": "file:../../../../packages/validation",
|
||||||
|
"@wrnexus/authz": "0.8.6",
|
||||||
|
"@wrnexus/rpc": "file:../../../../packages/rpc",
|
||||||
|
"@app/shared": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@wrnexus/cli": "0.8.6",
|
||||||
|
"@eslint/js": "^9.0.0",
|
||||||
|
"@iconify-json/lucide": "^1.2.118",
|
||||||
|
"@iconify/tailwind4": "^1.2.3",
|
||||||
|
"@tailwindcss/cli": "^4.0.0",
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"eslint": "^9.0.0",
|
||||||
|
"prettier": "latest",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"typescript": "^5.5.0",
|
||||||
|
"typescript-eslint": "latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
# WrNexus
|
||||||
|
|
||||||
|
> WrNexus is an SSR-first, **Bun-native** full-stack web framework. UI is written in
|
||||||
|
> `.wrn` files (its own component language — NOT React/JSX/Vue). Routing is file-based.
|
||||||
|
> This document teaches an AI how to write correct WrNexus code. It is private and
|
||||||
|
> post-dates model training data, so rely on THIS document, not prior web-framework
|
||||||
|
> assumptions.
|
||||||
|
|
||||||
|
## Golden rules
|
||||||
|
|
||||||
|
- **Pages, components, and layouts are `.wrn` files.** Do NOT write `.tsx`/`.jsx`/React
|
||||||
|
for UI. Do NOT use `useState`, hooks, JSX, or a client bundler.
|
||||||
|
- **Routing is file-based** under `app/`. The filename is the route. No router config.
|
||||||
|
- **Interactivity** lives in `state` + `{expr}` + `@event` inside `.wrn`. Components render
|
||||||
|
on the server and hydrate automatically — you never write client-side JS islands.
|
||||||
|
- **Runtime is Bun only** (uses `Bun.serve`, `bun:sqlite`, `Bun.password`, …). Node is not supported.
|
||||||
|
- To add files, prefer the CLI: `wrnexus generate page <Name>` / `component <name>` / `api <path>` / `schema <name>`.
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
pages/ *.wrn → routes: index.wrn = "/", about.wrn = "/about", blog/[slug].wrn = "/blog/:slug"
|
||||||
|
components/ *.wrn → reusable UI, mounted in a page/component via <div data-component="name" ...props>
|
||||||
|
layouts/ *.wrn → named layouts; a page opts in with layout = "name"
|
||||||
|
api/ *.ts → HTTP handlers: export const GET/POST/PUT/PATCH/DELETE = async (ctx) => Response
|
||||||
|
middleware/ *.ts → export default async (ctx, next) => next()
|
||||||
|
realtime/ *.ts → export default defineRoom({ ... }) from "@wrnexus/core" (ws://host/realtime/<name>)
|
||||||
|
schemas/ *.ts → validation schemas (the `v` builder), used by forms + parseBody
|
||||||
|
locales/ *.json → i18n messages per language
|
||||||
|
db/ schema.ts, queries/*.sql, migrations/*.sql
|
||||||
|
styles/ global.css → Tailwind (default) or plain CSS
|
||||||
|
wrnexus.config.ts → app config (AppConfig from "@wrnexus/styles")
|
||||||
|
public/ → static assets served at /
|
||||||
|
```
|
||||||
|
|
||||||
|
## `.wrn` page
|
||||||
|
|
||||||
|
```wrn
|
||||||
|
page Home {
|
||||||
|
layout = "public" // optional: a component in app/layouts/<name>.wrn ("none" to skip)
|
||||||
|
|
||||||
|
state count = 0 // optional: seeds client-reactive state (omit for pure SSR)
|
||||||
|
|
||||||
|
seo {
|
||||||
|
title = "Home"
|
||||||
|
description = "..."
|
||||||
|
canonical = "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
view {
|
||||||
|
<h1>Hello</h1>
|
||||||
|
<p>Count is {count}, doubled is {count * 2}.</p>
|
||||||
|
<button @click="count++">Increment</button>
|
||||||
|
<div data-component="counter" start="5" label="Clicks"></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
style {
|
||||||
|
h1 { color: var(--wire-color-text); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## `.wrn` component
|
||||||
|
|
||||||
|
```wrn
|
||||||
|
component Counter {
|
||||||
|
props { // props come from mount attributes; each is coerced to the
|
||||||
|
start = 0 // TYPE of its default (so start="5" arrives as the number 5)
|
||||||
|
label = "Count"
|
||||||
|
}
|
||||||
|
state count = start // state may reference props
|
||||||
|
view {
|
||||||
|
<button @click="count++">{label}: {count}</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Mount it from any page/component: `<div data-component="counter" start="0" label="Clicks"></div>`.
|
||||||
|
Components render on the server with their props, then hydrate — no per-component JS.
|
||||||
|
|
||||||
|
## The `view { }` block (plain HTML + a few directives)
|
||||||
|
|
||||||
|
- `{expr}` — interpolate a JS expression. Reactive if it references `state`: `{count}`, `{count * 2}`, `{user.name}`.
|
||||||
|
- `@event="expr"` — bind a DOM event; the expression runs in the reactive scope: `@click="count++"`, `@input="name = event.target.value"`.
|
||||||
|
- `<div data-component="name" prop="v">` — mount a component (attrs become string props, coerced).
|
||||||
|
- `<slot></slot>` / `<slot name="x"></slot>` — component/layout slots; fill with `<div data-slot="x">…</div>`.
|
||||||
|
- **Server loop (DB/list/table):** `{#each <list> as <item>[, <i>]} …rows… {:empty} …fallback… {/each}` — iterates SSR data on the server and renders markup per item. `{item.field}` interpolates (HTML-escaped, XSS-safe). `<list>` is a JS expression, usually an `ssr` data binding (see "Data-driven tables" below). This is how you render a database table in `.wrn`.
|
||||||
|
- **Server conditional:** `{#if <expr>} … {:else if <expr>} … {:else} … {/if}` — renders the first truthy branch on the server. `<expr>` can reference `ssr` data, or the `item`/`index` of an enclosing `{#each}`. Works at page level and inside loops (e.g. `{#if r.active}<span>●</span>{:else}<span>○</span>{/if}` per row). For client-side show/hide based on reactive `state`, use `data-show="expr"` instead.
|
||||||
|
- i18n: `{t:home.title}` in text, `t:placeholder="form.name"` on attributes — resolved per request from `app/locales/`.
|
||||||
|
- Theme: any element with `data-wire-theme-toggle` toggles light/dark; `data-wire-theme-set="dark"` sets it.
|
||||||
|
- Void/self-closing tags are fine: `<br />`, `<img src="..." />`.
|
||||||
|
- Only `{` and `}` are special (interpolation). Don't use a bare `}` in view text.
|
||||||
|
|
||||||
|
## Data-driven tables / lists (server-rendered `.wrn`)
|
||||||
|
|
||||||
|
Use an `ssr` data binding to fetch rows on the server, then `{#each}` to render them.
|
||||||
|
This renders on the **server** (SSR-first) and is HTML-escaped by default.
|
||||||
|
|
||||||
|
```wrn
|
||||||
|
page Admin {
|
||||||
|
layout = "dashboard"
|
||||||
|
|
||||||
|
// Fetch on the server. The api handler at /api/contacts returns { contacts: [...] };
|
||||||
|
// this block's `return contacts` exposes that array (via `$data`) as the binding `rows`.
|
||||||
|
ssr {
|
||||||
|
api rows GET /api/contacts { return contacts }
|
||||||
|
}
|
||||||
|
|
||||||
|
view {
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{#each rows as r, i}
|
||||||
|
<tr>
|
||||||
|
<td>#{i}</td>
|
||||||
|
<td>{r.name}</td>
|
||||||
|
<td><a href="mailto:{r.email}">{r.email}</a></td>
|
||||||
|
</tr>
|
||||||
|
{:empty}
|
||||||
|
<tr><td colspan="3">No submissions yet.</td></tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The matching API returns the array under a key the `ssr` block reads:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/api/contacts.ts → GET /api/contacts
|
||||||
|
import { getDb } from "@wrnexus/db";
|
||||||
|
export const GET = async () => {
|
||||||
|
const contacts = await getDb().all("SELECT id, name, email FROM contacts ORDER BY id DESC");
|
||||||
|
return Response.json({ contacts }); // ssr block does `return contacts`
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prefer this `.wrn` + `{#each}` approach for DB-backed tables and lists.** (`.ts`/`.tsx`
|
||||||
|
pages returning an HTML string are also supported for fully-custom programmatic rendering,
|
||||||
|
but a `.wrn` page with `ssr` data + `{#each}` is the idiomatic, SSR-first way.)
|
||||||
|
|
||||||
|
## API routes (`app/api/*.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/api/users/list.ts → GET /api/users/list
|
||||||
|
import { getDb } from "@wrnexus/db";
|
||||||
|
|
||||||
|
export const GET = async (ctx) => {
|
||||||
|
return Response.json({ users: await ListUsers(getDb()) });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST = async (ctx) => {
|
||||||
|
const body = await ctx.req.json();
|
||||||
|
return Response.json({ ok: true, body }, { status: 201 });
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`ctx` (the `Context` from `@wrnexus/core`) has:
|
||||||
|
`req: Request`, `url: URL`, `params: Record<string,string>` (dynamic route params, e.g. `/users/[id]` → `ctx.params.id`),
|
||||||
|
`lang: string`, `t(key, params?)` (i18n), `cookies` (get/set), `session` (get/set). Auth: `getUser(ctx)` after `sessionAuth`/`logIn`.
|
||||||
|
|
||||||
|
When an SSO forward-auth verifier needs the URL that originally reached the gateway, use
|
||||||
|
`@wrnexus/helpers` instead of constructing it from untrusted headers:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { redirectToLogin } from "@wrnexus/helpers";
|
||||||
|
|
||||||
|
return redirectToLogin(ctx, "/login", {
|
||||||
|
allowedHosts: ["admin.localhost:3000", "reports.localhost:3000"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The package also exports `getOriginalRequestUrl`, `getOriginalRequestOrigin`,
|
||||||
|
`getOriginalRequestPath`, and `getOriginalRequestMethod`. Always pass `allowedHosts` when
|
||||||
|
using forwarded gateway URLs; the helper rejects untrusted redirect destinations.
|
||||||
|
|
||||||
|
## Middleware & realtime
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/middleware/logger.ts
|
||||||
|
export default async function logger(ctx, next) {
|
||||||
|
console.log(ctx.req.method, ctx.url.pathname);
|
||||||
|
return next(); // return a Response WITHOUT calling next() to short-circuit
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/realtime/chat.ts → ws://host/realtime/chat
|
||||||
|
import { defineRoom } from "@wrnexus/core";
|
||||||
|
export default defineRoom({
|
||||||
|
onConnect(client) { client.send({ type: "system", text: "connected" }); },
|
||||||
|
onMessage(client, msg) { client.room.broadcast({ type: "message", data: msg }); },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
Client side: a page opts in with `data-room="chat"` (handled by the realtime runtime).
|
||||||
|
|
||||||
|
## Config (`wrnexus.config.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { AppConfig } from "@wrnexus/styles";
|
||||||
|
const config: AppConfig = {
|
||||||
|
seo: { title: "App", titleTemplate: "%s | App", description: "..." },
|
||||||
|
styles: { entry: "app/styles/global.css", process: async ({ entryPath, mode }) => /* Tailwind */ "" },
|
||||||
|
fonts: { sans: '"Inter", system-ui, sans-serif', google: [{ family: "Inter", weights: [400, 600] }] },
|
||||||
|
theme: { default: "dark", themes: { light: { "color-primary": "#2563eb" } } },
|
||||||
|
i18n: { default: "en", locales: ["en", "es"] },
|
||||||
|
db: { driver: "sqlite", url: "file:./dev.db" },
|
||||||
|
security: { cors: { enabled: true, origin: ["http://localhost:5173"] } },
|
||||||
|
// profiles: { production: { db: { driver: "postgres", url: process.env.DATABASE_URL } } },
|
||||||
|
};
|
||||||
|
export default config;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database (`@wrnexus/db`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/db/schema.ts
|
||||||
|
import { v, table } from "@wrnexus/db";
|
||||||
|
export const users = table("users", {
|
||||||
|
id: v.id(),
|
||||||
|
name: v.string(),
|
||||||
|
email: v.string().unique(),
|
||||||
|
createdAt: v.timestamp(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
- Queries: write `app/db/queries/*.sql` with `-- name: ListUsers :many` blocks; `wrnexus db generate` emits typed functions.
|
||||||
|
- Access at runtime: `import { getDb } from "@wrnexus/db"; const rows = await ListUsers(getDb());`
|
||||||
|
- Migrations in `app/db/migrations/`; run `wrnexus db migrate` (dev auto-migrates sqlite).
|
||||||
|
|
||||||
|
## Validation (`@wrnexus/validation`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/schemas/login.ts
|
||||||
|
import { v } from "@wrnexus/validation";
|
||||||
|
export default v.object({
|
||||||
|
email: v.string().email(),
|
||||||
|
password: v.string().min(8),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
In an API route: `import s from "../schemas/login"; import { parseBody } from "@wrnexus/validation"; const r = await parseBody(s, ctx.req);` → `r.ok ? r.value : r.response`.
|
||||||
|
In a form: `<form data-schema="login" action="/api/login" method="post">` + `<span data-error="email"></span>` (client + server validation wired automatically).
|
||||||
|
|
||||||
|
## AI / LLM (`@wrnexus/ai`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/api/ai.ts
|
||||||
|
import { createAI } from "@wrnexus/ai";
|
||||||
|
const ai = createAI(); // reads ANTHROPIC_API_KEY; default model claude-opus-4-8
|
||||||
|
export const POST = async (ctx) => {
|
||||||
|
const { prompt } = await ctx.req.json();
|
||||||
|
return ai.streamResponse(prompt); // or: return Response.json({ text: await ai.generate(prompt) })
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
```
|
||||||
|
wrnexus dev . # dev server + HMR
|
||||||
|
wrnexus build . # production build → dist/server.js
|
||||||
|
bun dist/server.js # run the production server (or npm start)
|
||||||
|
wrnexus create <name> # scaffold a new app
|
||||||
|
wrnexus update --latest # deps + syntax/config migrations + verification
|
||||||
|
wrnexus generate page <Name> # scaffold a page (aliases: g p)
|
||||||
|
wrnexus generate component <name> | api <path> | schema <name>
|
||||||
|
wrnexus db migrate | rollback | status | new [--from-models] | generate | seed
|
||||||
|
wrnexus eject <component> # copy a Wire UI component's .wrn into app/components to customize
|
||||||
|
```
|
||||||
|
|
||||||
|
## When asked to "create a page/component/feature"
|
||||||
|
|
||||||
|
1. Create the `.wrn` file under `app/pages/` (or `app/components/`) with a `page`/`component` block — or run `wrnexus generate page <Name>`.
|
||||||
|
2. Put markup in `view { }`, interactive bits in `state` + `{expr}` + `@event`, reusable UI as components mounted via `data-component`.
|
||||||
|
3. For data, add an `app/api/*.ts` route and `getDb()`; for forms, add an `app/schemas/*.ts` and `data-schema`.
|
||||||
|
4. Style with Tailwind utility classes in the view, or theme tokens (`var(--wire-*)`), or `style { }`.
|
||||||
|
5. Never emit React/JSX, a manual router, or client-side island JS — the framework handles hydration.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { afterEach, expect, test } from "bun:test";
|
||||||
|
import { implement } from "../../../../../packages/rpc/src/index.ts";
|
||||||
|
import { handleRpcRequest } from "../../../../../packages/dev-server/src/rpc-dispatch.ts";
|
||||||
|
import { catalogService } from "../../../packages/shared/src/index.ts";
|
||||||
|
import { GET } from "../app/api/product.ts";
|
||||||
|
|
||||||
|
const secret = process.env.WRNEXUS_RPC_SECRET;
|
||||||
|
const app = process.env.WRNEXUS_APP_NAME;
|
||||||
|
const origins = process.env.WRNEXUS_INTERNAL_ORIGINS;
|
||||||
|
afterEach(() => {
|
||||||
|
if (secret === undefined) delete process.env.WRNEXUS_RPC_SECRET;
|
||||||
|
else process.env.WRNEXUS_RPC_SECRET = secret;
|
||||||
|
if (app === undefined) delete process.env.WRNEXUS_APP_NAME;
|
||||||
|
else process.env.WRNEXUS_APP_NAME = app;
|
||||||
|
if (origins === undefined) delete process.env.WRNEXUS_INTERNAL_ORIGINS;
|
||||||
|
else process.env.WRNEXUS_INTERNAL_ORIGINS = origins;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("web calls the generated admin app over private RPC", async () => {
|
||||||
|
process.env.WRNEXUS_RPC_SECRET = "test-rpc-secret-at-least-32-chars-long";
|
||||||
|
process.env.WRNEXUS_APP_NAME = "web";
|
||||||
|
const service = implement(
|
||||||
|
catalogService,
|
||||||
|
{ getProduct: ({ sku }) => ({ sku, name: "WRNexus Starter", priceCents: 4900 }) },
|
||||||
|
{ selfApp: "admin" },
|
||||||
|
);
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
async fetch(request) {
|
||||||
|
return (
|
||||||
|
(await handleRpcRequest(request, new URL(request.url), new Map([["catalog", service]]))) ??
|
||||||
|
new Response("Not found", { status: 404 })
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
process.env.WRNEXUS_INTERNAL_ORIGINS = JSON.stringify({
|
||||||
|
admin: `http://127.0.0.1:${server.port}`,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const response = await GET({
|
||||||
|
req: new Request("http://web.test/api/product?sku=starter"),
|
||||||
|
user: { id: "u1" },
|
||||||
|
locals: {},
|
||||||
|
} as never);
|
||||||
|
expect(await response.json()).toEqual({
|
||||||
|
product: { sku: "starter", name: "WRNexus Starter", priceCents: 4900 },
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { parseOrThrow } from "@wrnexus/validation";
|
||||||
|
import { contactSchema } from "../app/schemas/contact.ts";
|
||||||
|
|
||||||
|
test("starter validation schema accepts a contact request", () => {
|
||||||
|
expect(
|
||||||
|
parseOrThrow(contactSchema, {
|
||||||
|
email: "hello@example.com",
|
||||||
|
message: "Hello from the generated application.",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
email: "hello@example.com",
|
||||||
|
message: "Hello from the generated application.",
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["bun"],
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": false,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "@wrnexus/core"
|
||||||
|
},
|
||||||
|
"include": ["app", "test", "wrnexus.config.ts"],
|
||||||
|
"exclude": ["node_modules", "dist", "**/dist", "**/.wrnexus"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import type { AppConfig } from "@wrnexus/styles";
|
||||||
|
|
||||||
|
const config: AppConfig = {
|
||||||
|
compatibilityDate: "2026-08-02",
|
||||||
|
frameworkBehaviour: 1,
|
||||||
|
// v0.8 defaults: explicit imports, strict template types, safe stores, and
|
||||||
|
// automatic progressive navigation. Package plugins are discovered from the
|
||||||
|
// installed packages above; add custom plugins to this array when needed.
|
||||||
|
plugins: [],
|
||||||
|
imports: { mode: "explicit", autoImport: true, aliases: { "@": "./app" } },
|
||||||
|
types: {
|
||||||
|
strict: true,
|
||||||
|
noImplicitAny: true,
|
||||||
|
strictNullChecks: true,
|
||||||
|
checkTemplates: true,
|
||||||
|
checkComponentProps: true,
|
||||||
|
generateDeclarations: true,
|
||||||
|
},
|
||||||
|
functions: { legacyDefaultRuntime: "current" },
|
||||||
|
stores: { strictMutations: true, persistence: true },
|
||||||
|
compatibility: {
|
||||||
|
legacyEmit: false,
|
||||||
|
legacyEventProps: false,
|
||||||
|
legacyComponentDiscovery: false,
|
||||||
|
stringLayouts: false,
|
||||||
|
},
|
||||||
|
experimental: {},
|
||||||
|
|
||||||
|
performance: {
|
||||||
|
enforcement: "warn",
|
||||||
|
analyze: true,
|
||||||
|
budgets: {
|
||||||
|
routeJsBytes: 50 * 1024,
|
||||||
|
routeCssBytes: 25 * 1024,
|
||||||
|
lcpMs: 2_500,
|
||||||
|
inpMs: 200,
|
||||||
|
cls: 0.1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
observability: {
|
||||||
|
enabled: true,
|
||||||
|
serviceName: "web",
|
||||||
|
serverTiming: true,
|
||||||
|
sampleRate: 1,
|
||||||
|
exporter: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ? "otlp" : "none",
|
||||||
|
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||||
|
webVitals: true,
|
||||||
|
},
|
||||||
|
tenancy: { mode: "domain", required: false, rootDomains: ["localhost"] },
|
||||||
|
build: { cache: true, sourceMaps: true, report: true, adapter: "bun" },
|
||||||
|
navigation: { mode: "auto" },
|
||||||
|
devToolbar: { enabled: true, position: "bottom-center", openEditor: true },
|
||||||
|
|
||||||
|
mobile: {
|
||||||
|
enabled: true,
|
||||||
|
appId: "com.example.web",
|
||||||
|
appName: "web",
|
||||||
|
userAgent: "WrNexusMobile",
|
||||||
|
backgroundColor: "#0f172a",
|
||||||
|
// layout: "mobile", // app/layouts/mobile.wrn
|
||||||
|
// icon: "resources/icon.png",
|
||||||
|
},
|
||||||
|
|
||||||
|
// PWA support is enabled automatically. Override any install metadata here.
|
||||||
|
pwa: {
|
||||||
|
name: "web",
|
||||||
|
shortName: "web",
|
||||||
|
display: "standalone",
|
||||||
|
themeColor: "#6366f1",
|
||||||
|
backgroundColor: "#0f172a",
|
||||||
|
},
|
||||||
|
|
||||||
|
seo: {
|
||||||
|
title: "web",
|
||||||
|
titleTemplate: "%s | web",
|
||||||
|
// Set WRNEXUS_PUBLIC_ORIGIN in production when TLS terminates at a proxy.
|
||||||
|
canonicalBase: process.env.WRNEXUS_PUBLIC_ORIGIN,
|
||||||
|
description: "An SSR-first WrNexus app.",
|
||||||
|
robots: "index,follow",
|
||||||
|
themeColor: "#6366f1",
|
||||||
|
},
|
||||||
|
|
||||||
|
styles: {
|
||||||
|
entry: "app/styles/global.css",
|
||||||
|
|
||||||
|
// Tailwind v4 build. Runs once at dev-serve time (cached; re-run on restart)
|
||||||
|
// and at `wrnexus build`. `@tailwindcss/cli` writes to stdout, so we capture
|
||||||
|
// and return the final CSS. Delete this hook to drop Tailwind — global.css is
|
||||||
|
// still bundled and served as-is.
|
||||||
|
process: async ({ entryPath, appRoot, mode }) => {
|
||||||
|
const args = ["@tailwindcss/cli", "-i", entryPath!];
|
||||||
|
if (mode === "production") args.push("--minify");
|
||||||
|
return await Bun.$.cwd(appRoot)`bunx ${args}`.text();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Fonts — optimized preconnect, subsetted weights, font-display, and CSP.
|
||||||
|
fonts: {
|
||||||
|
sans: '"Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif',
|
||||||
|
google: [{ family: "Plus Jakarta Sans", weights: [400, 500, 600, 700] }],
|
||||||
|
},
|
||||||
|
//
|
||||||
|
// // Or self-host (fastest, no third party) — drop files in public/fonts/:
|
||||||
|
// // local: [{ family: "Inter", src: "/fonts/inter.woff2", weight: "100 900", preload: true }],
|
||||||
|
|
||||||
|
theme: { palette: "violet", default: "light" },
|
||||||
|
i18n: { default: "en", locales: ["en"] },
|
||||||
|
db: { driver: "sqlite", url: process.env.DATABASE_URL ?? "file:./dev.db" },
|
||||||
|
databases: {},
|
||||||
|
storage: {
|
||||||
|
default: "public",
|
||||||
|
stores: {
|
||||||
|
public: {
|
||||||
|
driver: "local",
|
||||||
|
access: "public",
|
||||||
|
dir: "uploads/public",
|
||||||
|
maxBytes: 10_000_000,
|
||||||
|
accept: ["image/*", "application/pdf"],
|
||||||
|
},
|
||||||
|
private: {
|
||||||
|
driver: "local",
|
||||||
|
access: "private",
|
||||||
|
dir: "uploads/private",
|
||||||
|
maxBytes: 10_000_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
realtime: { scale: Boolean(process.env.REDIS_URL), redisUrl: process.env.REDIS_URL },
|
||||||
|
port: Number(process.env.PORT ?? 3000),
|
||||||
|
security: {
|
||||||
|
cors: { enabled: false },
|
||||||
|
},
|
||||||
|
profiles: {
|
||||||
|
development: {},
|
||||||
|
test: {
|
||||||
|
db: { driver: "sqlite", url: "file:./test.db" },
|
||||||
|
observability: { exporter: "none", sampleRate: 0 },
|
||||||
|
},
|
||||||
|
staging: {
|
||||||
|
seo: { robots: "noindex,nofollow" },
|
||||||
|
performance: { enforcement: "error" },
|
||||||
|
build: { sourceMaps: true, report: true },
|
||||||
|
},
|
||||||
|
production: {
|
||||||
|
seo: { canonicalBase: process.env.WRNEXUS_PUBLIC_ORIGIN },
|
||||||
|
performance: { enforcement: "error" },
|
||||||
|
build: { sourceMaps: false, report: true },
|
||||||
|
devToolbar: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { dirname } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import js from "@eslint/js";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
const tsconfigRootDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ["node_modules/**", "dist/**", "**/dist/**", ".wrnexus/**", "**/.wrnexus/**"] },
|
||||||
|
{ languageOptions: { parserOptions: { tsconfigRootDir } } },
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
rules: {
|
||||||
|
"no-undef": "off",
|
||||||
|
"no-console": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,22 +1,32 @@
|
|||||||
{
|
{
|
||||||
"name": "inter-app-api-showcase",
|
"name": "inter-app-api-showcase",
|
||||||
"version": "0.8.6",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"workspaces": [
|
||||||
|
"apps/*",
|
||||||
|
"packages/*"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run ../../packages/cli/src/index.ts dev .",
|
"dev": "wrnexus gateway",
|
||||||
"build": "bun run ../../packages/cli/src/index.ts build .",
|
"gateway": "wrnexus gateway",
|
||||||
"test": "bun test",
|
"staging": "wrnexus staging",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
"production": "wrnexus production",
|
||||||
"check": "bun run typecheck && bun run test && bun run build"
|
"typecheck": "tsc --noEmit && bun run --filter './apps/*' typecheck",
|
||||||
},
|
"test": "bun run --filter './apps/*' test",
|
||||||
"dependencies": {
|
"lint": "eslint .",
|
||||||
"@wrnexus/core": "workspace:*",
|
"lint:fix": "eslint . --fix",
|
||||||
"@wrnexus/rpc": "workspace:*",
|
"format": "prettier . --write",
|
||||||
"@wrnexus/validation": "workspace:*"
|
"format:check": "prettier . --check",
|
||||||
|
"doctor": "bun run --filter './apps/*' doctor",
|
||||||
|
"check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@wrnexus/cli": "0.8.6",
|
||||||
"typescript": "^5.9.2"
|
"@eslint/js": "^9.0.0",
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"eslint": "^9.0.0",
|
||||||
|
"prettier": "latest",
|
||||||
|
"typescript": "^5.5.0",
|
||||||
|
"typescript-eslint": "latest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "@app/shared",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "bun test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@wrnexus/pubsub": "file:../../../../packages/pubsub",
|
||||||
|
"@wrnexus/rpc": "file:../../../../packages/rpc",
|
||||||
|
"@wrnexus/validation": "file:../../../../packages/validation"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Shared code for every app in this workspace. Import it anywhere: `@app/shared`.
|
||||||
|
* The cross-app event bus uses Redis so messages reach every app process/domain.
|
||||||
|
*/
|
||||||
|
import { createPubSub } from "../../../../../packages/pubsub/src/index.ts";
|
||||||
|
import { redisDriver } from "../../../../../packages/pubsub/src/redis.ts";
|
||||||
|
import { defineService, procedure } from "../../../../../packages/rpc/src/index.ts";
|
||||||
|
import { v } from "../../../../../packages/validation/src/index.ts";
|
||||||
|
|
||||||
|
// One bus per process, backed by Redis (set REDIS_URL, defaults to localhost:6379).
|
||||||
|
export const bus = createPubSub(redisDriver(process.env.REDIS_URL));
|
||||||
|
|
||||||
|
// Shared domain types can live here and be imported by every app.
|
||||||
|
export interface Tenant {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Typed contract imported by both generated workspace apps. */
|
||||||
|
export const catalogService = defineService({
|
||||||
|
name: "catalog",
|
||||||
|
procedures: {
|
||||||
|
getProduct: procedure
|
||||||
|
.input(v.object({ sku: v.string() }))
|
||||||
|
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.json",
|
"compilerOptions": {
|
||||||
"compilerOptions": { "lib": ["ESNext", "DOM", "DOM.Iterable"] },
|
"target": "ESNext",
|
||||||
"include": ["app"]
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ESNext", "DOM"],
|
||||||
|
"types": ["bun"],
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"allowImportingTsExtensions": true
|
||||||
|
},
|
||||||
|
"include": ["wrnexus.workspace.ts", "packages/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist", "apps"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { WorkspaceConfig } from "@wrnexus/cli/workspace";
|
||||||
|
|
||||||
|
// Map each app to the domains it serves. `wrnexus gateway` runs them all behind
|
||||||
|
// one port and routes by Host header (add these hosts to your /etc/hosts).
|
||||||
|
const config: WorkspaceConfig = {
|
||||||
|
defaultEnvironment: "development",
|
||||||
|
environments: {
|
||||||
|
development: {
|
||||||
|
protocol: "http",
|
||||||
|
rootDomain: "localhost",
|
||||||
|
port: 3000,
|
||||||
|
runtime: "development",
|
||||||
|
hmr: true,
|
||||||
|
build: false,
|
||||||
|
migrate: false,
|
||||||
|
},
|
||||||
|
staging: {
|
||||||
|
protocol: "https",
|
||||||
|
rootDomain: "staging.example.com",
|
||||||
|
port: 443,
|
||||||
|
runtime: "production",
|
||||||
|
hmr: false,
|
||||||
|
build: true,
|
||||||
|
migrate: true,
|
||||||
|
},
|
||||||
|
production: {
|
||||||
|
protocol: "https",
|
||||||
|
rootDomain: "example.com",
|
||||||
|
port: 443,
|
||||||
|
runtime: "production",
|
||||||
|
hmr: false,
|
||||||
|
build: true,
|
||||||
|
migrate: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Gateway-wide security (all optional):
|
||||||
|
security: {
|
||||||
|
trustedHostsOnly: true, // reject requests for unknown domains
|
||||||
|
rateLimit: { max: 300, windowMs: 60_000 }, // per client IP
|
||||||
|
headers: true, // baseline security headers at the edge
|
||||||
|
accessLog: true, // log host → app, method, path, status
|
||||||
|
},
|
||||||
|
apps: [
|
||||||
|
{ name: "web", dir: "apps/web", domains: ["localhost", "web.localhost"] },
|
||||||
|
{
|
||||||
|
name: "admin",
|
||||||
|
dir: "apps/admin",
|
||||||
|
domains: ["admin.localhost"],
|
||||||
|
// Lock the admin app down at the edge (pick one):
|
||||||
|
auth: { basic: { user: "admin", pass: "change-me" } },
|
||||||
|
// auth: { allowIps: ["127.0.0.1", "::1"] },
|
||||||
|
// auth: { forward: { url: "http://localhost:4001/api/verify" } }, // SSO
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -68,18 +68,47 @@ export async function handleRpcRequest(
|
|||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const identity = req.headers.get(RPC_IDENTITY_HEADER) ?? undefined;
|
const identity = req.headers.get(RPC_IDENTITY_HEADER) ?? undefined;
|
||||||
const iterator = service.stream(procedure, payload, identity)[Symbol.asyncIterator]();
|
const iterator = service.stream(procedure, payload, identity)[Symbol.asyncIterator]();
|
||||||
|
const { maxFrameBytes, heartbeatMs } = service.streamOptions;
|
||||||
|
service.metrics.begin();
|
||||||
const body = new ReadableStream<Uint8Array>({
|
const body = new ReadableStream<Uint8Array>({
|
||||||
async pull(controller) {
|
async start(controller) {
|
||||||
try {
|
try {
|
||||||
const next = await iterator.next();
|
let next = iterator.next();
|
||||||
if (next.done) {
|
while (true) {
|
||||||
|
let heartbeatTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const heartbeat = new Promise<{ kind: "heartbeat" }>((resolve) => {
|
||||||
|
heartbeatTimer = setTimeout(() => resolve({ kind: "heartbeat" }), heartbeatMs);
|
||||||
|
});
|
||||||
|
const outcome = await Promise.race([
|
||||||
|
next.then((value) => ({ kind: "data" as const, value })),
|
||||||
|
heartbeat,
|
||||||
|
]);
|
||||||
|
if (heartbeatTimer !== undefined) clearTimeout(heartbeatTimer);
|
||||||
|
if (outcome.kind === "heartbeat") {
|
||||||
|
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (outcome.value.done) {
|
||||||
|
service.metrics.complete();
|
||||||
controller.close();
|
controller.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const frame = JSON.stringify({ ok: true, value: outcome.value.value });
|
||||||
|
if (encoder.encode(frame).byteLength > maxFrameBytes) {
|
||||||
|
service.metrics.fail();
|
||||||
controller.enqueue(
|
controller.enqueue(
|
||||||
encoder.encode(`data: ${JSON.stringify({ ok: true, value: next.value })}\n\n`),
|
encoder.encode(
|
||||||
|
'data: {"ok":false,"code":"RPC_MALFORMED","message":"Stream frame exceeds limit"}\n\n',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
controller.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
controller.enqueue(encoder.encode(`data: ${frame}\n\n`));
|
||||||
|
next = iterator.next();
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
service.metrics.fail();
|
||||||
controller.enqueue(
|
controller.enqueue(
|
||||||
encoder.encode('data: {"ok":false,"code":"RPC_HANDLER","message":"Stream failed"}\n\n'),
|
encoder.encode('data: {"ok":false,"code":"RPC_HANDLER","message":"Stream failed"}\n\n'),
|
||||||
);
|
);
|
||||||
@@ -87,6 +116,7 @@ export async function handleRpcRequest(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async cancel() {
|
async cancel() {
|
||||||
|
service.metrics.complete();
|
||||||
await iterator.return?.();
|
await iterator.return?.();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,4 +63,6 @@ export type {
|
|||||||
StreamHandlers,
|
StreamHandlers,
|
||||||
StreamImplementOptions,
|
StreamImplementOptions,
|
||||||
StreamImplementation,
|
StreamImplementation,
|
||||||
|
StreamMetricsSnapshot,
|
||||||
} from "./stream.ts";
|
} from "./stream.ts";
|
||||||
|
export { StreamMetrics } from "./stream.ts";
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export function rpcStreamPath(service: string, procedure: string): string {
|
|||||||
export interface StreamImplementation<Procedures extends AnyProcedures = AnyProcedures> {
|
export interface StreamImplementation<Procedures extends AnyProcedures = AnyProcedures> {
|
||||||
contract: ServiceContract<Procedures>;
|
contract: ServiceContract<Procedures>;
|
||||||
stream(procedure: string, payload: unknown, identity?: string): AsyncIterable<unknown>;
|
stream(procedure: string, payload: unknown, identity?: string): AsyncIterable<unknown>;
|
||||||
|
streamOptions: Required<Pick<StreamImplementOptions, "maxFrameBytes" | "heartbeatMs">>;
|
||||||
|
metrics: StreamMetrics;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StreamHandlers<Procedures extends AnyProcedures> = {
|
export type StreamHandlers<Procedures extends AnyProcedures> = {
|
||||||
@@ -30,6 +32,44 @@ export type StreamHandlers<Procedures extends AnyProcedures> = {
|
|||||||
export interface StreamImplementOptions {
|
export interface StreamImplementOptions {
|
||||||
selfApp: string;
|
selfApp: string;
|
||||||
checkPermission?: (permission: string, subject?: SubjectContext) => Promise<boolean> | boolean;
|
checkPermission?: (permission: string, subject?: SubjectContext) => Promise<boolean> | boolean;
|
||||||
|
/** Maximum serialized SSE data frame. Default: 64 KiB. */
|
||||||
|
maxFrameBytes?: number;
|
||||||
|
/** Emit an SSE comment while idle. Default: 15 seconds. */
|
||||||
|
heartbeatMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreamMetricsSnapshot {
|
||||||
|
started: number;
|
||||||
|
completed: number;
|
||||||
|
failed: number;
|
||||||
|
active: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StreamMetrics {
|
||||||
|
private started = 0;
|
||||||
|
private completed = 0;
|
||||||
|
private failed = 0;
|
||||||
|
private active = 0;
|
||||||
|
begin() {
|
||||||
|
this.started++;
|
||||||
|
this.active++;
|
||||||
|
}
|
||||||
|
complete() {
|
||||||
|
this.completed++;
|
||||||
|
this.active = Math.max(0, this.active - 1);
|
||||||
|
}
|
||||||
|
fail() {
|
||||||
|
this.failed++;
|
||||||
|
this.active = Math.max(0, this.active - 1);
|
||||||
|
}
|
||||||
|
snapshot(): StreamMetricsSnapshot {
|
||||||
|
return {
|
||||||
|
started: this.started,
|
||||||
|
completed: this.completed,
|
||||||
|
failed: this.failed,
|
||||||
|
active: this.active,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Define an authenticated, validated stream endpoint. */
|
/** Define an authenticated, validated stream endpoint. */
|
||||||
@@ -38,8 +78,16 @@ export function implementStream<Procedures extends AnyProcedures>(
|
|||||||
handlers: StreamHandlers<Procedures>,
|
handlers: StreamHandlers<Procedures>,
|
||||||
options: StreamImplementOptions,
|
options: StreamImplementOptions,
|
||||||
): StreamImplementation<Procedures> {
|
): StreamImplementation<Procedures> {
|
||||||
|
const maxFrameBytes = options.maxFrameBytes ?? 64 * 1024;
|
||||||
|
const heartbeatMs = options.heartbeatMs ?? 15_000;
|
||||||
|
if (!Number.isInteger(maxFrameBytes) || maxFrameBytes < 1)
|
||||||
|
throw new RangeError("rpc stream maxFrameBytes must be a positive integer");
|
||||||
|
if (!Number.isInteger(heartbeatMs) || heartbeatMs < 1)
|
||||||
|
throw new RangeError("rpc stream heartbeatMs must be a positive integer");
|
||||||
return {
|
return {
|
||||||
contract,
|
contract,
|
||||||
|
streamOptions: { maxFrameBytes, heartbeatMs },
|
||||||
|
metrics: new StreamMetrics(),
|
||||||
stream(procedure, payload, identity) {
|
stream(procedure, payload, identity) {
|
||||||
const handler = Object.hasOwn(handlers, procedure)
|
const handler = Object.hasOwn(handlers, procedure)
|
||||||
? handlers[procedure as keyof Procedures]
|
? handlers[procedure as keyof Procedures]
|
||||||
@@ -90,6 +138,8 @@ export interface StreamClientOptions {
|
|||||||
as?: Context;
|
as?: Context;
|
||||||
fetch?: typeof fetch;
|
fetch?: typeof fetch;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
/** Reject oversized server frames before parsing. Default: 64 KiB. */
|
||||||
|
maxFrameBytes?: number;
|
||||||
}
|
}
|
||||||
export type StreamClient<Procedures extends AnyProcedures> = {
|
export type StreamClient<Procedures extends AnyProcedures> = {
|
||||||
[K in keyof Procedures]: (
|
[K in keyof Procedures]: (
|
||||||
@@ -100,6 +150,7 @@ export type StreamClient<Procedures extends AnyProcedures> = {
|
|||||||
function decodeFrames(
|
function decodeFrames(
|
||||||
body: ReadableStream<Uint8Array>,
|
body: ReadableStream<Uint8Array>,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
maxFrameBytes = 64 * 1024,
|
||||||
): AsyncIterable<unknown> {
|
): AsyncIterable<unknown> {
|
||||||
return (async function* () {
|
return (async function* () {
|
||||||
const reader = body.getReader();
|
const reader = body.getReader();
|
||||||
@@ -119,7 +170,15 @@ function decodeFrames(
|
|||||||
.find((line) => line.startsWith("data: "))
|
.find((line) => line.startsWith("data: "))
|
||||||
?.slice(6);
|
?.slice(6);
|
||||||
if (!data) continue;
|
if (!data) continue;
|
||||||
const value: unknown = JSON.parse(data);
|
if (new TextEncoder().encode(data).byteLength > maxFrameBytes) {
|
||||||
|
throw new ServiceError(RPC_ERROR_CODES.malformed, "Stream frame exceeds limit");
|
||||||
|
}
|
||||||
|
let value: unknown;
|
||||||
|
try {
|
||||||
|
value = JSON.parse(data);
|
||||||
|
} catch {
|
||||||
|
throw new ServiceError(RPC_ERROR_CODES.malformed, "Malformed stream response");
|
||||||
|
}
|
||||||
if (!value || typeof value !== "object" || !("ok" in value))
|
if (!value || typeof value !== "object" || !("ok" in value))
|
||||||
throw new ServiceError(RPC_ERROR_CODES.malformed, "Malformed stream response");
|
throw new ServiceError(RPC_ERROR_CODES.malformed, "Malformed stream response");
|
||||||
const result = value as { ok: boolean; value?: unknown; code?: string; message?: string };
|
const result = value as { ok: boolean; value?: unknown; code?: string; message?: string };
|
||||||
@@ -143,6 +202,9 @@ export function streamClient<Procedures extends AnyProcedures>(
|
|||||||
): StreamClient<Procedures> {
|
): StreamClient<Procedures> {
|
||||||
const app = options.app ?? contract.name;
|
const app = options.app ?? contract.name;
|
||||||
const doFetch = options.fetch ?? fetch;
|
const doFetch = options.fetch ?? fetch;
|
||||||
|
const maxFrameBytes = options.maxFrameBytes ?? 64 * 1024;
|
||||||
|
if (!Number.isInteger(maxFrameBytes) || maxFrameBytes < 1)
|
||||||
|
throw new RangeError("rpc stream maxFrameBytes must be a positive integer");
|
||||||
return new Proxy({} as StreamClient<Procedures>, {
|
return new Proxy({} as StreamClient<Procedures>, {
|
||||||
get(_target, property) {
|
get(_target, property) {
|
||||||
if (typeof property !== "string" || !Object.hasOwn(contract.procedures, property))
|
if (typeof property !== "string" || !Object.hasOwn(contract.procedures, property))
|
||||||
@@ -163,7 +225,7 @@ export function streamClient<Procedures extends AnyProcedures>(
|
|||||||
);
|
);
|
||||||
if (!response.ok || !response.body)
|
if (!response.ok || !response.body)
|
||||||
throw new ServiceError(RPC_ERROR_CODES.transport, "Service unavailable");
|
throw new ServiceError(RPC_ERROR_CODES.transport, "Service unavailable");
|
||||||
yield* decodeFrames(response.body, options.signal);
|
yield* decodeFrames(response.body, options.signal, maxFrameBytes);
|
||||||
})();
|
})();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ test("private streaming RPC authenticates identity and yields SSE frames as an a
|
|||||||
const values: number[] = [];
|
const values: number[] = [];
|
||||||
for await (const value of client.count(undefined)) values.push(value);
|
for await (const value of client.count(undefined)) values.push(value);
|
||||||
expect(values).toEqual([1, 2]);
|
expect(values).toEqual([1, 2]);
|
||||||
|
expect(service.metrics.snapshot()).toEqual({ started: 1, completed: 1, failed: 0, active: 0 });
|
||||||
} finally {
|
} finally {
|
||||||
if (oldSecret === undefined) delete process.env.WRNEXUS_RPC_SECRET;
|
if (oldSecret === undefined) delete process.env.WRNEXUS_RPC_SECRET;
|
||||||
else process.env.WRNEXUS_RPC_SECRET = oldSecret;
|
else process.env.WRNEXUS_RPC_SECRET = oldSecret;
|
||||||
|
|||||||
Reference in New Issue
Block a user