diff --git a/bun.lock b/bun.lock index 75d38b4b..1d44ccec 100644 --- a/bun.lock +++ b/bun.lock @@ -107,15 +107,14 @@ }, "examples/inter-app-api-showcase": { "name": "inter-app-api-showcase", - "version": "0.8.6", - "dependencies": { - "@wrnexus/core": "workspace:*", - "@wrnexus/rpc": "workspace:*", - "@wrnexus/validation": "workspace:*", - }, "devDependencies": { - "@types/bun": "^1.3.14", - "typescript": "^5.9.2", + "@eslint/js": "^9.0.0", + "@types/bun": "latest", + "@wrnexus/cli": "0.8.6", + "eslint": "^9.0.0", + "prettier": "latest", + "typescript": "^5.5.0", + "typescript-eslint": "latest", }, }, "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/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/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=="], + "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=="], + "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=="], "auth-showcase": ["auth-showcase@workspace:examples/auth-showcase"], @@ -977,10 +982,18 @@ "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"], + "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=="], + "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=="], "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=="], + "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=="], "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"], "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=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], @@ -1089,6 +1108,8 @@ "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-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=="], + "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=="], "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=="], + "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-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=="], + "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=="], + "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=="], "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/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/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=="], + "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=="], "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=="], + "@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=="], + + "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=="], } } diff --git a/docs/framework-remediation-plan.md b/docs/framework-remediation-plan.md index 592c4bd3..18b9fd69 100644 --- a/docs/framework-remediation-plan.md +++ b/docs/framework-remediation-plan.md @@ -301,35 +301,150 @@ hydrated DOM, which already looks correct today. ### 3.1 Twenty-two outputs still have no emitter -**Issue.** 22 outputs across 9 components are declared and never fired: -FileUpload (upload, progress, success, cancel, remove), ToastNotifications (add, -dismiss, clear, action), AdvancedDatePicker (open, close, clear), -AdvancedRangeSlider (start, end), Chart (dataPointClick, legendToggle), Confetti -(start, complete), TreeView (expand, collapse), Toast (dismiss), CopyMarkup -(success). +Everything needed to do this in one pass is in this section: what was already +done, what is left, and which disposition each remaining component takes. -Unlike §1.2 these are not miswired — the components genuinely do nothing. Each -needs a real implementation. +#### 3.1.1 Already done in 0.8.6 — do not redo -**Change.** For each: implement the behaviour, or delete the output. Do not -leave a component advertising what it does not do. Deleting is a breaking change -and needs a migration note; implementing Chart and FileUpload is real work and -should be scheduled, not squeezed in. +Fifteen components were fixed. Ten had outputs that were **declared but +miswired** — they dispatched a hand-built `CustomEvent` instead of calling +`output.*`, so the declaration was right and only the emit was wrong. Nothing +was added to these; they were rewired: -**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 a -component. +> Card, Footer, Breadcrumb, Accordion, alert, Badge, AnnouncementBar, +> AvatarGroup, ToggleCount, InputNumber -### 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 -that was removed and the LayoutSplitter scaffold that was rebuilt. +| Component | Outputs added | Fires when | +| ----------- | ------------------------------- | ------------------------------------------------------ | +| `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 -published library is a promise the library does not keep. +Verified firing in a browser: `SearchBox`, `Marquee`, `List`, `Timeline`, plus +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 -browser, its outputs fire, and it carries its own styles. +#### 3.1.2 What is left — 22 outputs, 9 components + +**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 @@ -613,16 +728,18 @@ assertion on top. Ranked by return, not by size. The first item changes the cost of every item below it, which is why it is first. -| # | 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. | -| 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. | -| 4 | §4.2 + §4.3 dev loop | Cheap, and it compounds across every task below. | -| 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. | -| 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. | +| # | 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. | +| 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. | +| 4 | §4.2 + §4.3 dev loop | Cheap, and it compounds across every task below. | +| 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. | +| 7 | §4.6 split controllers out of the core runtime | 6.6 kB gzipped a typical page never executes. | +| 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 model work. diff --git a/docs/public-api-0.8.json b/docs/public-api-0.8.json index f44b5680..20ab60f9 100644 --- a/docs/public-api-0.8.json +++ b/docs/public-api-0.8.json @@ -2413,6 +2413,8 @@ "StreamHandlers", "StreamImplementOptions", "StreamImplementation", + "StreamMetrics", + "StreamMetricsSnapshot", "SubjectContext", "ToResultOptions", "Transport", diff --git a/docs/ui-library-audit-2026-08-08.md b/docs/ui-library-audit-2026-08-08.md index 4cc3b2b3..40be25bf 100644 --- a/docs/ui-library-audit-2026-08-08.md +++ b/docs/ui-library-audit-2026-08-08.md @@ -82,8 +82,9 @@ do arrive. 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 -block, no functions, and for 19 of them an outputs block they never honour. +**28 components are still scaffolds** — no style block, no functions and no +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 LayoutSplitter scaffold that was rebuilt. diff --git a/examples/inter-app-api-showcase/.editorconfig b/examples/inter-app-api-showcase/.editorconfig new file mode 100644 index 00000000..86a63dc0 --- /dev/null +++ b/examples/inter-app-api-showcase/.editorconfig @@ -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 diff --git a/examples/inter-app-api-showcase/.env.example b/examples/inter-app-api-showcase/.env.example new file mode 100644 index 00000000..a5c8eed9 --- /dev/null +++ b/examples/inter-app-api-showcase/.env.example @@ -0,0 +1,2 @@ +REDIS_URL=redis://localhost:6379 +AUTH_SECRET=replace-with-at-least-32-random-characters diff --git a/examples/inter-app-api-showcase/.gitignore b/examples/inter-app-api-showcase/.gitignore new file mode 100644 index 00000000..82122e42 --- /dev/null +++ b/examples/inter-app-api-showcase/.gitignore @@ -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 diff --git a/examples/inter-app-api-showcase/.prettierignore b/examples/inter-app-api-showcase/.prettierignore new file mode 100644 index 00000000..b62d22e1 --- /dev/null +++ b/examples/inter-app-api-showcase/.prettierignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.wrnexus/ +**/.wrnexus/ +*.log +**/CLAUDE.md diff --git a/examples/inter-app-api-showcase/.prettierrc.json b/examples/inter-app-api-showcase/.prettierrc.json new file mode 100644 index 00000000..32474fc7 --- /dev/null +++ b/examples/inter-app-api-showcase/.prettierrc.json @@ -0,0 +1,9 @@ +{ + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "endOfLine": "lf" +} diff --git a/examples/inter-app-api-showcase/.vscode/extensions.json b/examples/inter-app-api-showcase/.vscode/extensions.json new file mode 100644 index 00000000..59ff820b --- /dev/null +++ b/examples/inter-app-api-showcase/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["wrnexus.wrnexus", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"] +} diff --git a/examples/inter-app-api-showcase/.vscode/settings.json b/examples/inter-app-api-showcase/.vscode/settings.json new file mode 100644 index 00000000..90ef9bee --- /dev/null +++ b/examples/inter-app-api-showcase/.vscode/settings.json @@ -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 } +} diff --git a/examples/inter-app-api-showcase/README.md b/examples/inter-app-api-showcase/README.md index d9ceaf5a..e75be186 100644 --- a/examples/inter-app-api-showcase/README.md +++ b/examples/inter-app-api-showcase/README.md @@ -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 -3. an RPC request to the `audit` app (`recordLookup`). - -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. - -Before running this app, configure all three apps with the same private internal-origin map and a distinct, 32+ character RPC secret: - -```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"} +``` +inter-app-api-showcase/ + wrnexus.workspace.ts # apps ↔ domains map (used by `wrnexus gateway`) + apps/ + web/ # a WrNexus app → localhost, web.localhost + admin/ # a WrNexus app → admin.localhost + packages/ + shared/ # @app/shared — shared code + cross-app pubsub bus ``` -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 +``` diff --git a/examples/inter-app-api-showcase/app/api/product-summary.ts b/examples/inter-app-api-showcase/app/api/product-summary.ts deleted file mode 100644 index 6567754a..00000000 --- a/examples/inter-app-api-showcase/app/api/product-summary.ts +++ /dev/null @@ -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 { - 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, - }); -} diff --git a/examples/inter-app-api-showcase/app/example.test.ts b/examples/inter-app-api-showcase/app/example.test.ts deleted file mode 100644 index 971e7524..00000000 --- a/examples/inter-app-api-showcase/app/example.test.ts +++ /dev/null @@ -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"); -}); diff --git a/examples/inter-app-api-showcase/app/lib/contracts.ts b/examples/inter-app-api-showcase/app/lib/contracts.ts deleted file mode 100644 index 9c608188..00000000 --- a/examples/inter-app-api-showcase/app/lib/contracts.ts +++ /dev/null @@ -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(), - }, -}); diff --git a/examples/inter-app-api-showcase/app/live-integration.test.ts b/examples/inter-app-api-showcase/app/live-integration.test.ts deleted file mode 100644 index 8bb8dee4..00000000 --- a/examples/inter-app-api-showcase/app/live-integration.test.ts +++ /dev/null @@ -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); - } -}); diff --git a/examples/inter-app-api-showcase/apps/admin/.editorconfig b/examples/inter-app-api-showcase/apps/admin/.editorconfig new file mode 100644 index 00000000..86a63dc0 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/.editorconfig @@ -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 diff --git a/examples/inter-app-api-showcase/apps/admin/.env.example b/examples/inter-app-api-showcase/apps/admin/.env.example new file mode 100644 index 00000000..c214ea2b --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/.env.example @@ -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= diff --git a/examples/inter-app-api-showcase/apps/admin/.env.test.example b/examples/inter-app-api-showcase/apps/admin/.env.test.example new file mode 100644 index 00000000..5a1efcc3 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/.env.test.example @@ -0,0 +1,3 @@ +WRNEXUS_PUBLIC_ORIGIN=http://localhost:3000 +DATABASE_URL=file:./test.db +AUTH_SECRET=test-only-secret-replace-outside-tests diff --git a/examples/inter-app-api-showcase/apps/admin/.gitignore b/examples/inter-app-api-showcase/apps/admin/.gitignore new file mode 100644 index 00000000..ce512f8f --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/.gitignore @@ -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/ diff --git a/examples/inter-app-api-showcase/apps/admin/.prettierignore b/examples/inter-app-api-showcase/apps/admin/.prettierignore new file mode 100644 index 00000000..3fd6c5c7 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/.prettierignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.wrnexus/ +**/.wrnexus/ +*.log +CLAUDE.md diff --git a/examples/inter-app-api-showcase/apps/admin/.prettierrc.json b/examples/inter-app-api-showcase/apps/admin/.prettierrc.json new file mode 100644 index 00000000..32474fc7 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/.prettierrc.json @@ -0,0 +1,9 @@ +{ + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "endOfLine": "lf" +} diff --git a/examples/inter-app-api-showcase/apps/admin/.vscode/extensions.json b/examples/inter-app-api-showcase/apps/admin/.vscode/extensions.json new file mode 100644 index 00000000..59ff820b --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["wrnexus.wrnexus", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"] +} diff --git a/examples/inter-app-api-showcase/apps/admin/.vscode/settings.json b/examples/inter-app-api-showcase/apps/admin/.vscode/settings.json new file mode 100644 index 00000000..6cad09f0 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/.vscode/settings.json @@ -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 + } +} diff --git a/examples/inter-app-api-showcase/apps/admin/CLAUDE.md b/examples/inter-app-api-showcase/apps/admin/CLAUDE.md new file mode 100644 index 00000000..7555f023 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/CLAUDE.md @@ -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 ` / `component ` / `api ` / `schema `. + +## 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
+ 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/) + 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/.wrn ("none" to skip) + + state count = 0 // optional: seeds client-reactive state (omit for pure SSR) + + seo { + title = "Home" + description = "..." + canonical = "/" + } + + view { +

Hello

+

Count is {count}, doubled is {count * 2}.

+ +
+ } + + 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 { + + } +} +``` + +Mount it from any page/component: `
`. +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"`. +- `
` — mount a component (attrs become string props, coerced). +- `` / `` — component/layout slots; fill with `
`. +- **Server loop (DB/list/table):** `{#each as [, ]} …rows… {:empty} …fallback… {/each}` — iterates SSR data on the server and renders markup per item. `{item.field}` interpolates (HTML-escaped, XSS-safe). `` 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 } … {:else if } … {:else} … {/if}` — renders the first truthy branch on the server. `` can reference `ssr` data, or the `item`/`index` of an enclosing `{#each}`. Works at page level and inside loops (e.g. `{#if r.active}{:else}{/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: `
`, ``. +- 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 { + + + {#each rows as r, i} + + + + + + {:empty} + + {/each} + +
#{i}{r.name}{r.email}
No submissions yet.
+ } +} +``` + +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` (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: `
` + `` (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 # scaffold a new app +wrnexus update --latest # deps + syntax/config migrations + verification +wrnexus generate page # scaffold a page (aliases: g p) +wrnexus generate component | api | schema +wrnexus db migrate | rollback | status | new [--from-models] | generate | seed +wrnexus eject # 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 `. +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. diff --git a/examples/inter-app-api-showcase/apps/admin/app/api/ai.ts b/examples/inter-app-api-showcase/apps/admin/app/api/ai.ts new file mode 100644 index 00000000..3abef18c --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/app/api/ai.ts @@ -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); +}; diff --git a/examples/inter-app-api-showcase/apps/admin/app/api/hello.ts b/examples/inter-app-api-showcase/apps/admin/app/api/hello.ts new file mode 100644 index 00000000..d359e795 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/app/api/hello.ts @@ -0,0 +1,3 @@ +export const GET = async () => { + return Response.json({ message: "Hello API" }); +}; diff --git a/examples/inter-app-api-showcase/apps/admin/app/components/counter.wrn b/examples/inter-app-api-showcase/apps/admin/app/components/counter.wrn new file mode 100644 index 00000000..58c305b8 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/app/components/counter.wrn @@ -0,0 +1,20 @@ +// A reusable component. Route: none — mounted inside a page with +//
. +// +// 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 { + + } +} diff --git a/examples/inter-app-api-showcase/apps/admin/app/db/migrations/0001_init.sql b/examples/inter-app-api-showcase/apps/admin/app/db/migrations/0001_init.sql new file mode 100644 index 00000000..39a33e74 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/app/db/migrations/0001_init.sql @@ -0,0 +1,2 @@ +-- Create application tables here. +-- Run with: bunx wrnexus db migrate diff --git a/examples/inter-app-api-showcase/apps/admin/app/db/seed.ts b/examples/inter-app-api-showcase/apps/admin/app/db/seed.ts new file mode 100644 index 00000000..642d429f --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/app/db/seed.ts @@ -0,0 +1,2 @@ +// Add deterministic development seed data here. +export async function seed(): Promise {} diff --git a/examples/inter-app-api-showcase/apps/admin/app/layouts/document.wrn b/examples/inter-app-api-showcase/apps/admin/app/layouts/document.wrn new file mode 100644 index 00000000..1aaaf5dd --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/app/layouts/document.wrn @@ -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 /. +// 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 { + + + +
+ + + } +} diff --git a/examples/inter-app-api-showcase/apps/admin/app/locales/en.json b/examples/inter-app-api-showcase/apps/admin/app/locales/en.json new file mode 100644 index 00000000..d1e54ea3 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/app/locales/en.json @@ -0,0 +1,6 @@ +{ + "common": { + "appName": "admin", + "welcome": "Welcome to admin" + } +} diff --git a/examples/inter-app-api-showcase/apps/admin/app/middleware/logger.ts b/examples/inter-app-api-showcase/apps/admin/app/middleware/logger.ts new file mode 100644 index 00000000..a582fddf --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/app/middleware/logger.ts @@ -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; diff --git a/examples/inter-app-api-showcase/apps/admin/app/pages/about.wrn b/examples/inter-app-api-showcase/apps/admin/app/pages/about.wrn new file mode 100644 index 00000000..267eeb37 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/app/pages/about.wrn @@ -0,0 +1,20 @@ +page About { + seo { + title = "About" + description = "Learn how admin is built with WrNexus." + } + + view { +
+
+ ← Home +

WrNexus application

+

About admin

+

+ This page is server-rendered from app/pages/about.wrn. Add state, + events, components, APIs, and data without switching to another UI framework. +

+
+
+ } +} diff --git a/examples/inter-app-api-showcase/apps/admin/app/pages/index.wrn b/examples/inter-app-api-showcase/apps/admin/app/pages/index.wrn new file mode 100644 index 00000000..ff66ec05 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/app/pages/index.wrn @@ -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 { +
+ + +
+
+ + W + admin + + +
+ +
+

SSR-first · Bun-native

+ +

+ Server-rendered.
+ Instantly interactive. +

+ +

+ admin runs on WrNexus — write .wrn components, ship no client boilerplate, and let the server do the work. +

+ + + +
+
+ + live · hydrated on the server +
+
+
+ This button works. You wrote zero client JavaScript. +
+
+ +

+ edit app/pages/index.wrn to make it yours +

+
+ + +
+
+ } +} diff --git a/examples/inter-app-api-showcase/apps/admin/app/realtime/chat.ts b/examples/inter-app-api-showcase/apps/admin/app/realtime/chat.ts new file mode 100644 index 00000000..d89a0680 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/app/realtime/chat.ts @@ -0,0 +1,20 @@ +// ws:///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 }); + }, +}); diff --git a/examples/inter-app-api-showcase/apps/admin/app/schemas/contact.ts b/examples/inter-app-api-showcase/apps/admin/app/schemas/contact.ts new file mode 100644 index 00000000..bcd3c02e --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/app/schemas/contact.ts @@ -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), +}); diff --git a/examples/inter-app-api-showcase/apps/admin/app/services/catalog.ts b/examples/inter-app-api-showcase/apps/admin/app/services/catalog.ts new file mode 100644 index 00000000..673cb263 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/app/services/catalog.ts @@ -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 \ No newline at end of file diff --git a/examples/inter-app-api-showcase/apps/admin/app/styles/global.css b/examples/inter-app-api-showcase/apps/admin/app/styles/global.css new file mode 100644 index 00000000..afb7b3cf --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/app/styles/global.css @@ -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 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); +} diff --git a/examples/inter-app-api-showcase/apps/admin/eslint.config.js b/examples/inter-app-api-showcase/apps/admin/eslint.config.js new file mode 100644 index 00000000..5c86a656 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/eslint.config.js @@ -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: "^_", + }, + ], + }, + }, +); diff --git a/examples/inter-app-api-showcase/apps/admin/package.json b/examples/inter-app-api-showcase/apps/admin/package.json new file mode 100644 index 00000000..ed5de0f7 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/package.json @@ -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" + } +} diff --git a/examples/inter-app-api-showcase/apps/admin/public/llms.txt b/examples/inter-app-api-showcase/apps/admin/public/llms.txt new file mode 100644 index 00000000..34305ccc --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/public/llms.txt @@ -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 ` / `component ` / `api ` / `schema `. + +## 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
+ 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/) + 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/.wrn ("none" to skip) + + state count = 0 // optional: seeds client-reactive state (omit for pure SSR) + + seo { + title = "Home" + description = "..." + canonical = "/" + } + + view { +

Hello

+

Count is {count}, doubled is {count * 2}.

+ +
+ } + + 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 { + + } +} +``` + +Mount it from any page/component: `
`. +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"`. +- `
` — mount a component (attrs become string props, coerced). +- `` / `` — component/layout slots; fill with `
`. +- **Server loop (DB/list/table):** `{#each as [, ]} …rows… {:empty} …fallback… {/each}` — iterates SSR data on the server and renders markup per item. `{item.field}` interpolates (HTML-escaped, XSS-safe). `` 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 } … {:else if } … {:else} … {/if}` — renders the first truthy branch on the server. `` can reference `ssr` data, or the `item`/`index` of an enclosing `{#each}`. Works at page level and inside loops (e.g. `{#if r.active}{:else}{/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: `
`, ``. +- 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 { + + + {#each rows as r, i} + + + + + + {:empty} + + {/each} + +
#{i}{r.name}{r.email}
No submissions yet.
+ } +} +``` + +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` (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: `` + `` (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 # scaffold a new app +wrnexus update --latest # deps + syntax/config migrations + verification +wrnexus generate page # scaffold a page (aliases: g p) +wrnexus generate component | api | schema +wrnexus db migrate | rollback | status | new [--from-models] | generate | seed +wrnexus eject # 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 `. +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. diff --git a/examples/inter-app-api-showcase/apps/admin/public/robots.txt b/examples/inter-app-api-showcase/apps/admin/public/robots.txt new file mode 100644 index 00000000..c2a49f4f --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / diff --git a/examples/inter-app-api-showcase/apps/admin/test/smoke.test.ts b/examples/inter-app-api-showcase/apps/admin/test/smoke.test.ts new file mode 100644 index 00000000..4de7c068 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/test/smoke.test.ts @@ -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.", + }); +}); diff --git a/examples/inter-app-api-showcase/apps/admin/tsconfig.json b/examples/inter-app-api-showcase/apps/admin/tsconfig.json new file mode 100644 index 00000000..4ab5d990 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/tsconfig.json @@ -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"] +} diff --git a/examples/inter-app-api-showcase/apps/admin/wrnexus.config.ts b/examples/inter-app-api-showcase/apps/admin/wrnexus.config.ts new file mode 100644 index 00000000..3508cb54 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/admin/wrnexus.config.ts @@ -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; diff --git a/examples/inter-app-api-showcase/apps/web/.editorconfig b/examples/inter-app-api-showcase/apps/web/.editorconfig new file mode 100644 index 00000000..86a63dc0 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/.editorconfig @@ -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 diff --git a/examples/inter-app-api-showcase/apps/web/.env.example b/examples/inter-app-api-showcase/apps/web/.env.example new file mode 100644 index 00000000..c214ea2b --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/.env.example @@ -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= diff --git a/examples/inter-app-api-showcase/apps/web/.env.test.example b/examples/inter-app-api-showcase/apps/web/.env.test.example new file mode 100644 index 00000000..5a1efcc3 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/.env.test.example @@ -0,0 +1,3 @@ +WRNEXUS_PUBLIC_ORIGIN=http://localhost:3000 +DATABASE_URL=file:./test.db +AUTH_SECRET=test-only-secret-replace-outside-tests diff --git a/examples/inter-app-api-showcase/apps/web/.gitignore b/examples/inter-app-api-showcase/apps/web/.gitignore new file mode 100644 index 00000000..ce512f8f --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/.gitignore @@ -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/ diff --git a/examples/inter-app-api-showcase/apps/web/.prettierignore b/examples/inter-app-api-showcase/apps/web/.prettierignore new file mode 100644 index 00000000..3fd6c5c7 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/.prettierignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.wrnexus/ +**/.wrnexus/ +*.log +CLAUDE.md diff --git a/examples/inter-app-api-showcase/apps/web/.prettierrc.json b/examples/inter-app-api-showcase/apps/web/.prettierrc.json new file mode 100644 index 00000000..32474fc7 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/.prettierrc.json @@ -0,0 +1,9 @@ +{ + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "endOfLine": "lf" +} diff --git a/examples/inter-app-api-showcase/apps/web/.vscode/extensions.json b/examples/inter-app-api-showcase/apps/web/.vscode/extensions.json new file mode 100644 index 00000000..59ff820b --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["wrnexus.wrnexus", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"] +} diff --git a/examples/inter-app-api-showcase/apps/web/.vscode/settings.json b/examples/inter-app-api-showcase/apps/web/.vscode/settings.json new file mode 100644 index 00000000..6cad09f0 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/.vscode/settings.json @@ -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 + } +} diff --git a/examples/inter-app-api-showcase/apps/web/CLAUDE.md b/examples/inter-app-api-showcase/apps/web/CLAUDE.md new file mode 100644 index 00000000..7555f023 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/CLAUDE.md @@ -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 ` / `component ` / `api ` / `schema `. + +## 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
+ 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/) + 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/.wrn ("none" to skip) + + state count = 0 // optional: seeds client-reactive state (omit for pure SSR) + + seo { + title = "Home" + description = "..." + canonical = "/" + } + + view { +

Hello

+

Count is {count}, doubled is {count * 2}.

+ +
+ } + + 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 { + + } +} +``` + +Mount it from any page/component: `
`. +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"`. +- `
` — mount a component (attrs become string props, coerced). +- `` / `` — component/layout slots; fill with `
`. +- **Server loop (DB/list/table):** `{#each as [, ]} …rows… {:empty} …fallback… {/each}` — iterates SSR data on the server and renders markup per item. `{item.field}` interpolates (HTML-escaped, XSS-safe). `` 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 } … {:else if } … {:else} … {/if}` — renders the first truthy branch on the server. `` can reference `ssr` data, or the `item`/`index` of an enclosing `{#each}`. Works at page level and inside loops (e.g. `{#if r.active}{:else}{/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: `
`, ``. +- 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 { + + + {#each rows as r, i} + + + + + + {:empty} + + {/each} + +
#{i}{r.name}{r.email}
No submissions yet.
+ } +} +``` + +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` (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: `` + `` (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 # scaffold a new app +wrnexus update --latest # deps + syntax/config migrations + verification +wrnexus generate page # scaffold a page (aliases: g p) +wrnexus generate component | api | schema +wrnexus db migrate | rollback | status | new [--from-models] | generate | seed +wrnexus eject # 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 `. +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. diff --git a/examples/inter-app-api-showcase/apps/web/app/api/ai.ts b/examples/inter-app-api-showcase/apps/web/app/api/ai.ts new file mode 100644 index 00000000..3abef18c --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/app/api/ai.ts @@ -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); +}; diff --git a/examples/inter-app-api-showcase/apps/web/app/api/hello.ts b/examples/inter-app-api-showcase/apps/web/app/api/hello.ts new file mode 100644 index 00000000..d359e795 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/app/api/hello.ts @@ -0,0 +1,3 @@ +export const GET = async () => { + return Response.json({ message: "Hello API" }); +}; diff --git a/examples/inter-app-api-showcase/apps/web/app/api/product.ts b/examples/inter-app-api-showcase/apps/web/app/api/product.ts new file mode 100644 index 00000000..92b5345a --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/app/api/product.ts @@ -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 { + const sku = new URL(ctx.req.url).searchParams.get("sku")?.trim() || "starter"; + const catalog = serviceClient(catalogService, { + app: "admin", + as: ctx, + transport: retryingTransport( \ No newline at end of file diff --git a/examples/inter-app-api-showcase/apps/web/app/components/counter.wrn b/examples/inter-app-api-showcase/apps/web/app/components/counter.wrn new file mode 100644 index 00000000..58c305b8 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/app/components/counter.wrn @@ -0,0 +1,20 @@ +// A reusable component. Route: none — mounted inside a page with +//
. +// +// 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 { + + } +} diff --git a/examples/inter-app-api-showcase/apps/web/app/db/migrations/0001_init.sql b/examples/inter-app-api-showcase/apps/web/app/db/migrations/0001_init.sql new file mode 100644 index 00000000..39a33e74 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/app/db/migrations/0001_init.sql @@ -0,0 +1,2 @@ +-- Create application tables here. +-- Run with: bunx wrnexus db migrate diff --git a/examples/inter-app-api-showcase/apps/web/app/db/seed.ts b/examples/inter-app-api-showcase/apps/web/app/db/seed.ts new file mode 100644 index 00000000..642d429f --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/app/db/seed.ts @@ -0,0 +1,2 @@ +// Add deterministic development seed data here. +export async function seed(): Promise {} diff --git a/examples/inter-app-api-showcase/apps/web/app/layouts/document.wrn b/examples/inter-app-api-showcase/apps/web/app/layouts/document.wrn new file mode 100644 index 00000000..1aaaf5dd --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/app/layouts/document.wrn @@ -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 /. +// 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 { + + + +
+ + + } +} diff --git a/examples/inter-app-api-showcase/apps/web/app/locales/en.json b/examples/inter-app-api-showcase/apps/web/app/locales/en.json new file mode 100644 index 00000000..60ce8b04 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/app/locales/en.json @@ -0,0 +1,6 @@ +{ + "common": { + "appName": "web", + "welcome": "Welcome to web" + } +} diff --git a/examples/inter-app-api-showcase/apps/web/app/middleware/logger.ts b/examples/inter-app-api-showcase/apps/web/app/middleware/logger.ts new file mode 100644 index 00000000..a582fddf --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/app/middleware/logger.ts @@ -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; diff --git a/examples/inter-app-api-showcase/apps/web/app/pages/about.wrn b/examples/inter-app-api-showcase/apps/web/app/pages/about.wrn new file mode 100644 index 00000000..ea5496ad --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/app/pages/about.wrn @@ -0,0 +1,20 @@ +page About { + seo { + title = "About" + description = "Learn how web is built with WrNexus." + } + + view { +
+
+ ← Home +

WrNexus application

+

About web

+

+ This page is server-rendered from app/pages/about.wrn. Add state, + events, components, APIs, and data without switching to another UI framework. +

+
+
+ } +} diff --git a/examples/inter-app-api-showcase/apps/web/app/pages/index.wrn b/examples/inter-app-api-showcase/apps/web/app/pages/index.wrn new file mode 100644 index 00000000..25c1fc43 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/app/pages/index.wrn @@ -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 { +
+ + +
+
+ + W + web + + +
+ +
+

SSR-first · Bun-native

+ +

+ Server-rendered.
+ Instantly interactive. +

+ +

+ web runs on WrNexus — write .wrn components, ship no client boilerplate, and let the server do the work. +

+ + + +
+
+ + live · hydrated on the server +
+
+
+ This button works. You wrote zero client JavaScript. +
+
+ +

+ edit app/pages/index.wrn to make it yours +

+
+ + +
+
+ } +} diff --git a/examples/inter-app-api-showcase/apps/web/app/realtime/chat.ts b/examples/inter-app-api-showcase/apps/web/app/realtime/chat.ts new file mode 100644 index 00000000..d89a0680 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/app/realtime/chat.ts @@ -0,0 +1,20 @@ +// ws:///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 }); + }, +}); diff --git a/examples/inter-app-api-showcase/apps/web/app/schemas/contact.ts b/examples/inter-app-api-showcase/apps/web/app/schemas/contact.ts new file mode 100644 index 00000000..bcd3c02e --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/app/schemas/contact.ts @@ -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), +}); diff --git a/examples/inter-app-api-showcase/apps/web/app/styles/global.css b/examples/inter-app-api-showcase/apps/web/app/styles/global.css new file mode 100644 index 00000000..afb7b3cf --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/app/styles/global.css @@ -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 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); +} diff --git a/examples/inter-app-api-showcase/apps/web/eslint.config.js b/examples/inter-app-api-showcase/apps/web/eslint.config.js new file mode 100644 index 00000000..5c86a656 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/eslint.config.js @@ -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: "^_", + }, + ], + }, + }, +); diff --git a/examples/inter-app-api-showcase/apps/web/package.json b/examples/inter-app-api-showcase/apps/web/package.json new file mode 100644 index 00000000..498385b5 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/package.json @@ -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" + } +} diff --git a/examples/inter-app-api-showcase/apps/web/public/llms.txt b/examples/inter-app-api-showcase/apps/web/public/llms.txt new file mode 100644 index 00000000..34305ccc --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/public/llms.txt @@ -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 ` / `component ` / `api ` / `schema `. + +## 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
+ 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/) + 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/.wrn ("none" to skip) + + state count = 0 // optional: seeds client-reactive state (omit for pure SSR) + + seo { + title = "Home" + description = "..." + canonical = "/" + } + + view { +

Hello

+

Count is {count}, doubled is {count * 2}.

+ +
+ } + + 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 { + + } +} +``` + +Mount it from any page/component: `
`. +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"`. +- `
` — mount a component (attrs become string props, coerced). +- `` / `` — component/layout slots; fill with `
`. +- **Server loop (DB/list/table):** `{#each as [, ]} …rows… {:empty} …fallback… {/each}` — iterates SSR data on the server and renders markup per item. `{item.field}` interpolates (HTML-escaped, XSS-safe). `` 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 } … {:else if } … {:else} … {/if}` — renders the first truthy branch on the server. `` can reference `ssr` data, or the `item`/`index` of an enclosing `{#each}`. Works at page level and inside loops (e.g. `{#if r.active}{:else}{/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: `
`, ``. +- 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 { + + + {#each rows as r, i} + + + + + + {:empty} + + {/each} + +
#{i}{r.name}{r.email}
No submissions yet.
+ } +} +``` + +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` (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: `` + `` (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 # scaffold a new app +wrnexus update --latest # deps + syntax/config migrations + verification +wrnexus generate page # scaffold a page (aliases: g p) +wrnexus generate component | api | schema +wrnexus db migrate | rollback | status | new [--from-models] | generate | seed +wrnexus eject # 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 `. +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. diff --git a/examples/inter-app-api-showcase/apps/web/public/robots.txt b/examples/inter-app-api-showcase/apps/web/public/robots.txt new file mode 100644 index 00000000..c2a49f4f --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / diff --git a/examples/inter-app-api-showcase/apps/web/test/inter-app.test.ts b/examples/inter-app-api-showcase/apps/web/test/inter-app.test.ts new file mode 100644 index 00000000..a7045c22 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/test/inter-app.test.ts @@ -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 { + \ No newline at end of file diff --git a/examples/inter-app-api-showcase/apps/web/test/smoke.test.ts b/examples/inter-app-api-showcase/apps/web/test/smoke.test.ts new file mode 100644 index 00000000..4de7c068 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/test/smoke.test.ts @@ -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.", + }); +}); diff --git a/examples/inter-app-api-showcase/apps/web/tsconfig.json b/examples/inter-app-api-showcase/apps/web/tsconfig.json new file mode 100644 index 00000000..4ab5d990 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/tsconfig.json @@ -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"] +} diff --git a/examples/inter-app-api-showcase/apps/web/wrnexus.config.ts b/examples/inter-app-api-showcase/apps/web/wrnexus.config.ts new file mode 100644 index 00000000..3172c1d1 --- /dev/null +++ b/examples/inter-app-api-showcase/apps/web/wrnexus.config.ts @@ -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; diff --git a/examples/inter-app-api-showcase/eslint.config.js b/examples/inter-app-api-showcase/eslint.config.js new file mode 100644 index 00000000..177993d1 --- /dev/null +++ b/examples/inter-app-api-showcase/eslint.config.js @@ -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: "^_" }, + ], + }, + }, +); diff --git a/examples/inter-app-api-showcase/package.json b/examples/inter-app-api-showcase/package.json index 4b29d644..1733ed14 100644 --- a/examples/inter-app-api-showcase/package.json +++ b/examples/inter-app-api-showcase/package.json @@ -1,22 +1,32 @@ { "name": "inter-app-api-showcase", - "version": "0.8.6", "private": true, "type": "module", + "workspaces": [ + "apps/*", + "packages/*" + ], "scripts": { - "dev": "bun run ../../packages/cli/src/index.ts dev .", - "build": "bun run ../../packages/cli/src/index.ts build .", - "test": "bun test", - "typecheck": "tsc --noEmit -p tsconfig.json", - "check": "bun run typecheck && bun run test && bun run build" - }, - "dependencies": { - "@wrnexus/core": "workspace:*", - "@wrnexus/rpc": "workspace:*", - "@wrnexus/validation": "workspace:*" + "dev": "wrnexus gateway", + "gateway": "wrnexus gateway", + "staging": "wrnexus staging", + "production": "wrnexus production", + "typecheck": "tsc --noEmit && bun run --filter './apps/*' typecheck", + "test": "bun run --filter './apps/*' test", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier . --write", + "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": { - "@types/bun": "^1.3.14", - "typescript": "^5.9.2" + "@wrnexus/cli": "0.8.6", + "@eslint/js": "^9.0.0", + "@types/bun": "latest", + "eslint": "^9.0.0", + "prettier": "latest", + "typescript": "^5.5.0", + "typescript-eslint": "latest" } } diff --git a/examples/inter-app-api-showcase/packages/shared/package.json b/examples/inter-app-api-showcase/packages/shared/package.json new file mode 100644 index 00000000..1ef4b3d3 --- /dev/null +++ b/examples/inter-app-api-showcase/packages/shared/package.json @@ -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" + } +} diff --git a/examples/inter-app-api-showcase/packages/shared/src/index.ts b/examples/inter-app-api-showcase/packages/shared/src/index.ts new file mode 100644 index 00000000..4f197aac --- /dev/null +++ b/examples/inter-app-api-showcase/packages/shared/src/index.ts @@ -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() })) + \ No newline at end of file diff --git a/examples/inter-app-api-showcase/tsconfig.json b/examples/inter-app-api-showcase/tsconfig.json index b077260d..d4437925 100644 --- a/examples/inter-app-api-showcase/tsconfig.json +++ b/examples/inter-app-api-showcase/tsconfig.json @@ -1,5 +1,15 @@ { - "extends": "../../tsconfig.json", - "compilerOptions": { "lib": ["ESNext", "DOM", "DOM.Iterable"] }, - "include": ["app"] + "compilerOptions": { + "target": "ESNext", + "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"] } diff --git a/examples/inter-app-api-showcase/wrnexus.workspace.ts b/examples/inter-app-api-showcase/wrnexus.workspace.ts new file mode 100644 index 00000000..3a1dd77e --- /dev/null +++ b/examples/inter-app-api-showcase/wrnexus.workspace.ts @@ -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; diff --git a/packages/dev-server/src/rpc-dispatch.ts b/packages/dev-server/src/rpc-dispatch.ts index 6ab6fe15..98727103 100644 --- a/packages/dev-server/src/rpc-dispatch.ts +++ b/packages/dev-server/src/rpc-dispatch.ts @@ -68,18 +68,47 @@ export async function handleRpcRequest( const encoder = new TextEncoder(); const identity = req.headers.get(RPC_IDENTITY_HEADER) ?? undefined; const iterator = service.stream(procedure, payload, identity)[Symbol.asyncIterator](); + const { maxFrameBytes, heartbeatMs } = service.streamOptions; + service.metrics.begin(); const body = new ReadableStream({ - async pull(controller) { + async start(controller) { try { - const next = await iterator.next(); - if (next.done) { - controller.close(); - return; + let next = iterator.next(); + while (true) { + let heartbeatTimer: ReturnType | 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(); + return; + } + const frame = JSON.stringify({ ok: true, value: outcome.value.value }); + if (encoder.encode(frame).byteLength > maxFrameBytes) { + service.metrics.fail(); + controller.enqueue( + 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(); } - controller.enqueue( - encoder.encode(`data: ${JSON.stringify({ ok: true, value: next.value })}\n\n`), - ); } catch { + service.metrics.fail(); controller.enqueue( encoder.encode('data: {"ok":false,"code":"RPC_HANDLER","message":"Stream failed"}\n\n'), ); @@ -87,6 +116,7 @@ export async function handleRpcRequest( } }, async cancel() { + service.metrics.complete(); await iterator.return?.(); }, }); diff --git a/packages/rpc/src/index.ts b/packages/rpc/src/index.ts index 6c628fa4..5e6d19f7 100644 --- a/packages/rpc/src/index.ts +++ b/packages/rpc/src/index.ts @@ -63,4 +63,6 @@ export type { StreamHandlers, StreamImplementOptions, StreamImplementation, + StreamMetricsSnapshot, } from "./stream.ts"; +export { StreamMetrics } from "./stream.ts"; diff --git a/packages/rpc/src/stream.ts b/packages/rpc/src/stream.ts index 4427b633..d1ec1106 100644 --- a/packages/rpc/src/stream.ts +++ b/packages/rpc/src/stream.ts @@ -18,6 +18,8 @@ export function rpcStreamPath(service: string, procedure: string): string { export interface StreamImplementation { contract: ServiceContract; stream(procedure: string, payload: unknown, identity?: string): AsyncIterable; + streamOptions: Required>; + metrics: StreamMetrics; } export type StreamHandlers = { @@ -30,6 +32,44 @@ export type StreamHandlers = { export interface StreamImplementOptions { selfApp: string; checkPermission?: (permission: string, subject?: SubjectContext) => Promise | 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. */ @@ -38,8 +78,16 @@ export function implementStream( handlers: StreamHandlers, options: StreamImplementOptions, ): StreamImplementation { + 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 { contract, + streamOptions: { maxFrameBytes, heartbeatMs }, + metrics: new StreamMetrics(), stream(procedure, payload, identity) { const handler = Object.hasOwn(handlers, procedure) ? handlers[procedure as keyof Procedures] @@ -90,6 +138,8 @@ export interface StreamClientOptions { as?: Context; fetch?: typeof fetch; signal?: AbortSignal; + /** Reject oversized server frames before parsing. Default: 64 KiB. */ + maxFrameBytes?: number; } export type StreamClient = { [K in keyof Procedures]: ( @@ -100,6 +150,7 @@ export type StreamClient = { function decodeFrames( body: ReadableStream, signal?: AbortSignal, + maxFrameBytes = 64 * 1024, ): AsyncIterable { return (async function* () { const reader = body.getReader(); @@ -119,7 +170,15 @@ function decodeFrames( .find((line) => line.startsWith("data: ")) ?.slice(6); 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)) throw new ServiceError(RPC_ERROR_CODES.malformed, "Malformed stream response"); const result = value as { ok: boolean; value?: unknown; code?: string; message?: string }; @@ -143,6 +202,9 @@ export function streamClient( ): StreamClient { const app = options.app ?? contract.name; 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, { get(_target, property) { if (typeof property !== "string" || !Object.hasOwn(contract.procedures, property)) @@ -163,7 +225,7 @@ export function streamClient( ); if (!response.ok || !response.body) throw new ServiceError(RPC_ERROR_CODES.transport, "Service unavailable"); - yield* decodeFrames(response.body, options.signal); + yield* decodeFrames(response.body, options.signal, maxFrameBytes); })(); }, }); diff --git a/packages/rpc/test/stream.test.ts b/packages/rpc/test/stream.test.ts index 14c4243b..7f57c072 100644 --- a/packages/rpc/test/stream.test.ts +++ b/packages/rpc/test/stream.test.ts @@ -42,6 +42,7 @@ test("private streaming RPC authenticates identity and yields SSE frames as an a const values: number[] = []; for await (const value of client.count(undefined)) values.push(value); expect(values).toEqual([1, 2]); + expect(service.metrics.snapshot()).toEqual({ started: 1, completed: 1, failed: 0, active: 0 }); } finally { if (oldSecret === undefined) delete process.env.WRNEXUS_RPC_SECRET; else process.env.WRNEXUS_RPC_SECRET = oldSecret;