From 69020b2555ad00102b452a2a70174e5ad1ae9b7f Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Sun, 9 Aug 2026 10:34:22 +0530 Subject: [PATCH] docs: make the component sections executable in one pass Expands 3.1 and 3.2 so the work can be done without re-deriving anything. 3.1 now records what 0.8.6 already fixed, separated into the ten components that were miswired and the five that gained outputs they had been firing undeclared, with the caveat that Map's three were converted but never confirmed in a browser. For the 22 that remain it adds the finding that changes the decision: all nine are pure scaffolds with no state, functions or handlers, and five of them duplicate a component that already works -- FileUpload against FileInput and FileUploadProgress, Toast and ToastNotifications against Toaster, AdvancedDatePicker against DatePicker, AdvancedRangeSlider against RangeSlider. Superseding those is a migration entry rather than new code, and leaves Chart, TreeView, Confetti and CopyMarkup as the only ones needing to be built. 3.2 corrects the scaffold count from 23 to 28; the earlier figure used a looser rule. Nine of the 28 are the 3.1 components, so the two items must be planned together, and several of the rest are primitives that need only their styles moved out of ui.css rather than any behaviour. Also corrects the dead-output component count from 11 to 9 in both documents. Co-Authored-By: Claude Opus 5 --- bun.lock | 119 ++++++- docs/framework-remediation-plan.md | 181 +++++++++-- docs/public-api-0.8.json | 2 + docs/ui-library-audit-2026-08-08.md | 5 +- examples/inter-app-api-showcase/.editorconfig | 9 + examples/inter-app-api-showcase/.env.example | 2 + examples/inter-app-api-showcase/.gitignore | 25 ++ .../inter-app-api-showcase/.prettierignore | 6 + .../inter-app-api-showcase/.prettierrc.json | 9 + .../.vscode/extensions.json | 3 + .../.vscode/settings.json | 7 + examples/inter-app-api-showcase/README.md | 55 +++- .../app/api/product-summary.ts | 60 ---- .../app/example.test.ts | 11 - .../app/lib/contracts.ts | 34 -- .../app/live-integration.test.ts | 87 ------ .../apps/admin/.editorconfig | 9 + .../apps/admin/.env.example | 8 + .../apps/admin/.env.test.example | 3 + .../apps/admin/.gitignore | 48 +++ .../apps/admin/.prettierignore | 6 + .../apps/admin/.prettierrc.json | 9 + .../apps/admin/.vscode/extensions.json | 3 + .../apps/admin/.vscode/settings.json | 12 + .../apps/admin/CLAUDE.md | 295 ++++++++++++++++++ .../apps/admin/app/api/ai.ts | 17 + .../apps/admin/app/api/hello.ts | 3 + .../apps/admin/app/components/counter.wrn | 20 ++ .../admin/app/db/migrations/0001_init.sql | 2 + .../apps/admin/app/db/seed.ts | 2 + .../apps/admin/app/layouts/document.wrn | 22 ++ .../apps/admin/app/locales/en.json | 6 + .../apps/admin/app/middleware/logger.ts | 8 + .../apps/admin/app/pages/about.wrn | 20 ++ .../apps/admin/app/pages/index.wrn | 63 ++++ .../apps/admin/app/realtime/chat.ts | 20 ++ .../apps/admin/app/schemas/contact.ts | 6 + .../apps/admin/app/services/catalog.ts | 8 + .../apps/admin/app/styles/global.css | 19 ++ .../apps/admin/eslint.config.js | 44 +++ .../apps/admin/package.json | 65 ++++ .../apps/admin/public/llms.txt | 276 ++++++++++++++++ .../apps/admin/public/robots.txt | 2 + .../apps/admin/test/smoke.test.ts | 15 + .../apps/admin/tsconfig.json | 20 ++ .../apps/admin/wrnexus.config.ts | 153 +++++++++ .../apps/web/.editorconfig | 9 + .../apps/web/.env.example | 8 + .../apps/web/.env.test.example | 3 + .../apps/web/.gitignore | 48 +++ .../apps/web/.prettierignore | 6 + .../apps/web/.prettierrc.json | 9 + .../apps/web/.vscode/extensions.json | 3 + .../apps/web/.vscode/settings.json | 12 + .../inter-app-api-showcase/apps/web/CLAUDE.md | 295 ++++++++++++++++++ .../apps/web/app/api/ai.ts | 17 + .../apps/web/app/api/hello.ts | 3 + .../apps/web/app/api/product.ts | 15 + .../apps/web/app/components/counter.wrn | 20 ++ .../apps/web/app/db/migrations/0001_init.sql | 2 + .../apps/web/app/db/seed.ts | 2 + .../apps/web/app/layouts/document.wrn | 22 ++ .../apps/web/app/locales/en.json | 6 + .../apps/web/app/middleware/logger.ts | 8 + .../apps/web/app/pages/about.wrn | 20 ++ .../apps/web/app/pages/index.wrn | 63 ++++ .../apps/web/app/realtime/chat.ts | 20 ++ .../apps/web/app/schemas/contact.ts | 6 + .../apps/web/app/styles/global.css | 19 ++ .../apps/web/eslint.config.js | 44 +++ .../apps/web/package.json | 65 ++++ .../apps/web/public/llms.txt | 276 ++++++++++++++++ .../apps/web/public/robots.txt | 2 + .../apps/web/test/inter-app.test.ts | 50 +++ .../apps/web/test/smoke.test.ts | 15 + .../apps/web/tsconfig.json | 20 ++ .../apps/web/wrnexus.config.ts | 153 +++++++++ .../inter-app-api-showcase/eslint.config.js | 25 ++ examples/inter-app-api-showcase/package.json | 36 ++- .../packages/shared/package.json | 19 ++ .../packages/shared/src/index.ts | 25 ++ examples/inter-app-api-showcase/tsconfig.json | 16 +- .../wrnexus.workspace.ts | 57 ++++ packages/dev-server/src/rpc-dispatch.ts | 46 ++- packages/rpc/src/index.ts | 2 + packages/rpc/src/stream.ts | 66 +++- packages/rpc/test/stream.test.ts | 1 + 87 files changed, 3058 insertions(+), 275 deletions(-) create mode 100644 examples/inter-app-api-showcase/.editorconfig create mode 100644 examples/inter-app-api-showcase/.env.example create mode 100644 examples/inter-app-api-showcase/.gitignore create mode 100644 examples/inter-app-api-showcase/.prettierignore create mode 100644 examples/inter-app-api-showcase/.prettierrc.json create mode 100644 examples/inter-app-api-showcase/.vscode/extensions.json create mode 100644 examples/inter-app-api-showcase/.vscode/settings.json delete mode 100644 examples/inter-app-api-showcase/app/api/product-summary.ts delete mode 100644 examples/inter-app-api-showcase/app/example.test.ts delete mode 100644 examples/inter-app-api-showcase/app/lib/contracts.ts delete mode 100644 examples/inter-app-api-showcase/app/live-integration.test.ts create mode 100644 examples/inter-app-api-showcase/apps/admin/.editorconfig create mode 100644 examples/inter-app-api-showcase/apps/admin/.env.example create mode 100644 examples/inter-app-api-showcase/apps/admin/.env.test.example create mode 100644 examples/inter-app-api-showcase/apps/admin/.gitignore create mode 100644 examples/inter-app-api-showcase/apps/admin/.prettierignore create mode 100644 examples/inter-app-api-showcase/apps/admin/.prettierrc.json create mode 100644 examples/inter-app-api-showcase/apps/admin/.vscode/extensions.json create mode 100644 examples/inter-app-api-showcase/apps/admin/.vscode/settings.json create mode 100644 examples/inter-app-api-showcase/apps/admin/CLAUDE.md create mode 100644 examples/inter-app-api-showcase/apps/admin/app/api/ai.ts create mode 100644 examples/inter-app-api-showcase/apps/admin/app/api/hello.ts create mode 100644 examples/inter-app-api-showcase/apps/admin/app/components/counter.wrn create mode 100644 examples/inter-app-api-showcase/apps/admin/app/db/migrations/0001_init.sql create mode 100644 examples/inter-app-api-showcase/apps/admin/app/db/seed.ts create mode 100644 examples/inter-app-api-showcase/apps/admin/app/layouts/document.wrn create mode 100644 examples/inter-app-api-showcase/apps/admin/app/locales/en.json create mode 100644 examples/inter-app-api-showcase/apps/admin/app/middleware/logger.ts create mode 100644 examples/inter-app-api-showcase/apps/admin/app/pages/about.wrn create mode 100644 examples/inter-app-api-showcase/apps/admin/app/pages/index.wrn create mode 100644 examples/inter-app-api-showcase/apps/admin/app/realtime/chat.ts create mode 100644 examples/inter-app-api-showcase/apps/admin/app/schemas/contact.ts create mode 100644 examples/inter-app-api-showcase/apps/admin/app/services/catalog.ts create mode 100644 examples/inter-app-api-showcase/apps/admin/app/styles/global.css create mode 100644 examples/inter-app-api-showcase/apps/admin/eslint.config.js create mode 100644 examples/inter-app-api-showcase/apps/admin/package.json create mode 100644 examples/inter-app-api-showcase/apps/admin/public/llms.txt create mode 100644 examples/inter-app-api-showcase/apps/admin/public/robots.txt create mode 100644 examples/inter-app-api-showcase/apps/admin/test/smoke.test.ts create mode 100644 examples/inter-app-api-showcase/apps/admin/tsconfig.json create mode 100644 examples/inter-app-api-showcase/apps/admin/wrnexus.config.ts create mode 100644 examples/inter-app-api-showcase/apps/web/.editorconfig create mode 100644 examples/inter-app-api-showcase/apps/web/.env.example create mode 100644 examples/inter-app-api-showcase/apps/web/.env.test.example create mode 100644 examples/inter-app-api-showcase/apps/web/.gitignore create mode 100644 examples/inter-app-api-showcase/apps/web/.prettierignore create mode 100644 examples/inter-app-api-showcase/apps/web/.prettierrc.json create mode 100644 examples/inter-app-api-showcase/apps/web/.vscode/extensions.json create mode 100644 examples/inter-app-api-showcase/apps/web/.vscode/settings.json create mode 100644 examples/inter-app-api-showcase/apps/web/CLAUDE.md create mode 100644 examples/inter-app-api-showcase/apps/web/app/api/ai.ts create mode 100644 examples/inter-app-api-showcase/apps/web/app/api/hello.ts create mode 100644 examples/inter-app-api-showcase/apps/web/app/api/product.ts create mode 100644 examples/inter-app-api-showcase/apps/web/app/components/counter.wrn create mode 100644 examples/inter-app-api-showcase/apps/web/app/db/migrations/0001_init.sql create mode 100644 examples/inter-app-api-showcase/apps/web/app/db/seed.ts create mode 100644 examples/inter-app-api-showcase/apps/web/app/layouts/document.wrn create mode 100644 examples/inter-app-api-showcase/apps/web/app/locales/en.json create mode 100644 examples/inter-app-api-showcase/apps/web/app/middleware/logger.ts create mode 100644 examples/inter-app-api-showcase/apps/web/app/pages/about.wrn create mode 100644 examples/inter-app-api-showcase/apps/web/app/pages/index.wrn create mode 100644 examples/inter-app-api-showcase/apps/web/app/realtime/chat.ts create mode 100644 examples/inter-app-api-showcase/apps/web/app/schemas/contact.ts create mode 100644 examples/inter-app-api-showcase/apps/web/app/styles/global.css create mode 100644 examples/inter-app-api-showcase/apps/web/eslint.config.js create mode 100644 examples/inter-app-api-showcase/apps/web/package.json create mode 100644 examples/inter-app-api-showcase/apps/web/public/llms.txt create mode 100644 examples/inter-app-api-showcase/apps/web/public/robots.txt create mode 100644 examples/inter-app-api-showcase/apps/web/test/inter-app.test.ts create mode 100644 examples/inter-app-api-showcase/apps/web/test/smoke.test.ts create mode 100644 examples/inter-app-api-showcase/apps/web/tsconfig.json create mode 100644 examples/inter-app-api-showcase/apps/web/wrnexus.config.ts create mode 100644 examples/inter-app-api-showcase/eslint.config.js create mode 100644 examples/inter-app-api-showcase/packages/shared/package.json create mode 100644 examples/inter-app-api-showcase/packages/shared/src/index.ts create mode 100644 examples/inter-app-api-showcase/wrnexus.workspace.ts 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;