From 4cebacadfe6f7cd51268c79174f5970288cd8c8b Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Mon, 3 Aug 2026 19:47:30 +0530 Subject: [PATCH] release: WRNexusJS 0.8.3 --- .publish/ai/package.json | 2 +- .publish/authz/package.json | 2 +- .publish/cli/package.json | 34 +- .publish/compiler/package.json | 10 +- .publish/core/package.json | 2 +- .publish/csr/package.json | 4 +- .publish/db/package.json | 2 +- .publish/dev-server/package.json | 40 +- .publish/dev-toolbar/package.json | 2 +- .publish/encryption/package.json | 4 +- .publish/helpers/package.json | 4 +- .publish/i18n/package.json | 8 +- .publish/jwt/package.json | 4 +- .publish/mobile/package.json | 4 +- .publish/native/package.json | 2 +- .publish/oauth/package.json | 4 +- .publish/plugin/package.json | 4 +- .publish/pubsub/package.json | 2 +- .publish/queue/package.json | 4 +- .publish/reactive/package.json | 2 +- .publish/router/package.json | 6 +- .publish/ssr/package.json | 8 +- .publish/styles/package.json | 8 +- .publish/syntax/package.json | 2 +- .publish/test/package.json | 2 +- .publish/tracking/package.json | 2 +- .publish/ui/package.json | 4 +- .publish/uploader/package.json | 8 +- .publish/validation/package.json | 8 +- CHANGELOG.md | 20 + bun.lock | 92 ++--- docs/UPGRADE-0.8.3.md | 59 +++ docs/VALIDATION-0.8.3.md | 73 ++++ docs/ui-visual-contract-0.8.json | 18 +- editors/vscode/CHANGELOG.md | 6 + editors/vscode/package-lock.json | 4 +- editors/vscode/package.json | 4 +- editors/vscode/src/compiler.cjs | 364 +++++++++++++++--- editors/vscode/src/extension.bundle.cjs | 328 +++++++++++++--- editors/vscode/src/language-server.cjs | 4 +- package.json | 9 +- packages/ai/package.json | 2 +- packages/auth/components/AccountStatus.wrn | 2 + .../auth/components/AuthProviderButtons.wrn | 2 + .../auth/components/AuthSecurityNotice.wrn | 2 + packages/auth/components/AuthShell.wrn | 2 + .../auth/components/AuthenticatorSetup.wrn | 2 + packages/auth/components/DeviceSessions.wrn | 2 + packages/auth/components/ForgotPassword.wrn | 2 + .../auth/components/ImpersonationBanner.wrn | 2 + packages/auth/components/InvitationAccept.wrn | 2 + packages/auth/components/MagicLinkSignIn.wrn | 2 + packages/auth/components/OtpSignIn.wrn | 2 + packages/auth/components/PasskeyButton.wrn | 2 + packages/auth/components/RecoveryCodes.wrn | 2 + packages/auth/components/ResetPassword.wrn | 2 + packages/auth/components/SignIn.wrn | 3 + packages/auth/components/SignUp.wrn | 2 + .../auth/components/TwoFactorChallenge.wrn | 2 + packages/auth/components/VerifyEmail.wrn | 2 + packages/auth/components/VerifyPhone.wrn | 2 + packages/auth/package.json | 2 +- packages/authz/package.json | 2 +- packages/benchmark/package.json | 2 +- packages/cache/package.json | 2 +- packages/captcha/components/CaptchaField.wrn | 3 + packages/captcha/components/CaptchaStatus.wrn | 2 + packages/captcha/package.json | 2 +- packages/cli/package.json | 2 +- packages/cli/src/build.ts | 128 +++++- packages/cli/src/types.ts | 47 ++- packages/cli/src/update.ts | 10 + packages/compiler/package.json | 2 +- packages/compiler/src/client-codegen.ts | 132 ++++++- packages/compiler/src/codegen.ts | 272 ++++++++++--- packages/compiler/src/import-resolver.ts | 29 +- .../test/import-resolver-0.8.3.test.ts | 40 ++ .../compiler/test/regressions-0.8.3.test.ts | 160 ++++++++ packages/content/package.json | 2 +- packages/core/package.json | 2 +- packages/core/src/headers.ts | 1 - packages/core/test/headers.test.ts | 5 + packages/csr/package.json | 2 +- packages/csr/src/nav-runtime.ts | 5 + packages/csr/src/reactive-runtime.ts | 162 +++++++- packages/csr/src/realtime-runtime.ts | 42 +- packages/csr/test/reactive.test.ts | 121 ++++++ packages/csr/test/realtime.test.ts | 24 ++ packages/db/package.json | 2 +- packages/dev-server/package.json | 2 +- packages/dev-server/src/index.ts | 4 +- packages/dev-server/src/pipeline.ts | 146 +++++-- packages/dev-server/src/prod.ts | 10 + packages/dev-server/src/runtime.ts | 14 +- .../test/browser-bundle-0.8.3.test.ts | 90 +++++ .../dev-server/test/production-hmr.test.ts | 44 ++- packages/dev-toolbar/package.json | 2 +- packages/encryption/package.json | 2 +- packages/graphql/package.json | 2 +- packages/helpers/package.json | 2 +- packages/i18n/components/LocaleStatus.wrn | 2 + packages/i18n/package.json | 2 +- packages/identity/package.json | 2 +- packages/image/components/ImageCard.wrn | 3 + packages/image/package.json | 2 +- packages/jwt/package.json | 2 +- packages/language-server/package.json | 2 +- packages/mcp/package.json | 2 +- packages/mobile/package.json | 2 +- packages/native/package.json | 2 +- packages/oauth/package.json | 2 +- packages/observability/package.json | 2 +- packages/playground/package.json | 2 +- packages/plugin/package.json | 2 +- packages/pubsub/package.json | 2 +- packages/pwa/package.json | 2 +- packages/queue/package.json | 2 +- packages/reactive/package.json | 2 +- .../realtime/components/MessageBubble.wrn | 2 + .../realtime/components/MessageComposer.wrn | 2 + packages/realtime/components/PresenceList.wrn | 2 + packages/realtime/components/RealtimeRoom.wrn | 3 + packages/realtime/components/RoomMeta.wrn | 2 + packages/realtime/components/RoomStatus.wrn | 2 + .../realtime/components/TypingIndicator.wrn | 2 + packages/realtime/package.json | 2 +- packages/router/package.json | 2 +- packages/security/package.json | 2 +- packages/ssr/package.json | 2 +- packages/store/package.json | 2 +- packages/styles/package.json | 2 +- packages/syntax/package.json | 2 +- packages/test/package.json | 2 +- packages/tracking/package.json | 2 +- packages/typecheck/package.json | 2 +- packages/ui/components/AuthForm.wrn | 5 + packages/ui/components/FeatureGrid.wrn | 2 + packages/ui/components/FeatureIconCard.wrn | 2 + packages/ui/components/Hero.wrn | 2 + packages/ui/components/HeroActions.wrn | 2 + .../ui/components/MarketingSectionHeader.wrn | 3 + packages/ui/components/MetricGrid.wrn | 2 + packages/ui/components/PageHeader.wrn | 3 + packages/ui/components/Section.wrn | 2 + packages/ui/package.json | 2 +- .../uploader/components/UploadDropzone.wrn | 5 +- packages/uploader/components/UploadStatus.wrn | 2 + packages/uploader/package.json | 2 +- packages/uploader/src/runtime.ts | 9 + .../components/ValidationSummary.wrn | 2 + packages/validation/package.json | 2 +- scripts/build-editor-compiler.mjs | 51 ++- scripts/check-component-imports.mjs | 123 ++++++ scripts/release.ts | 6 + scripts/validate-0.8.mjs | 15 +- update-package-versions.mjs | 34 +- 156 files changed, 2608 insertions(+), 473 deletions(-) create mode 100644 docs/UPGRADE-0.8.3.md create mode 100644 docs/VALIDATION-0.8.3.md create mode 100644 packages/compiler/test/import-resolver-0.8.3.test.ts create mode 100644 packages/compiler/test/regressions-0.8.3.test.ts create mode 100644 packages/dev-server/test/browser-bundle-0.8.3.test.ts create mode 100644 scripts/check-component-imports.mjs diff --git a/.publish/ai/package.json b/.publish/ai/package.json index 3ecef289..2a5e2acc 100644 --- a/.publish/ai/package.json +++ b/.publish/ai/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ai", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.", "license": "MIT", diff --git a/.publish/authz/package.json b/.publish/authz/package.json index 5e957478..3491ed49 100644 --- a/.publish/authz/package.json +++ b/.publish/authz/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/authz", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "@wrnexus/authz — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/cli/package.json b/.publish/cli/package.json index 6dd32bf9..37d49559 100644 --- a/.publish/cli/package.json +++ b/.publish/cli/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/cli", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "@wrnexus/cli — part of the WrNexus framework.", "license": "MIT", @@ -44,22 +44,22 @@ "wrnexus": "./dist/index.js" }, "dependencies": { - "@wrnexus/core": "^0.8.2", - "@wrnexus/router": "^0.8.2", - "@wrnexus/csr": "^0.8.2", - "@wrnexus/compiler": "^0.8.2", - "@wrnexus/styles": "^0.8.2", - "@wrnexus/dev-server": "^0.8.2", - "@wrnexus/ui": "^0.8.2", - "@wrnexus/validation": "^0.8.2", - "@wrnexus/i18n": "^0.8.2", - "@wrnexus/mcp": "^0.8.2", - "@wrnexus/playground": "^0.8.2", - "@wrnexus/db": "^0.8.2", - "@wrnexus/plugin": "^0.8.2", - "@wrnexus/syntax": "^0.8.2", - "@wrnexus/typecheck": "^0.8.2", - "@wrnexus/security": "^0.8.2", + "@wrnexus/core": "^0.8.3", + "@wrnexus/router": "^0.8.3", + "@wrnexus/csr": "^0.8.3", + "@wrnexus/compiler": "^0.8.3", + "@wrnexus/styles": "^0.8.3", + "@wrnexus/dev-server": "^0.8.3", + "@wrnexus/ui": "^0.8.3", + "@wrnexus/validation": "^0.8.3", + "@wrnexus/i18n": "^0.8.3", + "@wrnexus/mcp": "^0.8.3", + "@wrnexus/playground": "^0.8.3", + "@wrnexus/db": "^0.8.3", + "@wrnexus/plugin": "^0.8.3", + "@wrnexus/syntax": "^0.8.3", + "@wrnexus/typecheck": "^0.8.3", + "@wrnexus/security": "^0.8.3", "selfsigned": "^5.5.0" }, "files": [ diff --git a/.publish/compiler/package.json b/.publish/compiler/package.json index 03cf97d5..1944428f 100644 --- a/.publish/compiler/package.json +++ b/.publish/compiler/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/compiler", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "@wrnexus/compiler — part of the WrNexus framework.", "license": "MIT", @@ -37,10 +37,10 @@ } }, "dependencies": { - "@wrnexus/csr": "^0.8.2", - "@wrnexus/syntax": "^0.8.2", - "@wrnexus/store": "^0.8.2", - "@wrnexus/validation": "^0.8.2" + "@wrnexus/csr": "^0.8.3", + "@wrnexus/syntax": "^0.8.3", + "@wrnexus/store": "^0.8.3", + "@wrnexus/validation": "^0.8.3" }, "files": [ "dist", diff --git a/.publish/core/package.json b/.publish/core/package.json index ab602f81..e134f1f1 100644 --- a/.publish/core/package.json +++ b/.publish/core/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/core", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "@wrnexus/core — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/csr/package.json b/.publish/csr/package.json index 7d9820f5..ff7d6751 100644 --- a/.publish/csr/package.json +++ b/.publish/csr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/csr", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "@wrnexus/csr — part of the WrNexus framework.", "license": "MIT", @@ -37,7 +37,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.8.2" + "@wrnexus/core": "^0.8.3" }, "files": [ "dist", diff --git a/.publish/db/package.json b/.publish/db/package.json index 2429cd43..59a36036 100644 --- a/.publish/db/package.json +++ b/.publish/db/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/db", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "Typed database drivers, migrations, instrumentation, repositories, pagination, and transaction helpers.", "license": "MIT", diff --git a/.publish/dev-server/package.json b/.publish/dev-server/package.json index 32506c10..ce626c15 100644 --- a/.publish/dev-server/package.json +++ b/.publish/dev-server/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-server", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "@wrnexus/dev-server — part of the WrNexus framework.", "license": "MIT", @@ -41,25 +41,25 @@ } }, "dependencies": { - "@wrnexus/core": "^0.8.2", - "@wrnexus/dev-toolbar": "^0.8.2", - "@wrnexus/router": "^0.8.2", - "@wrnexus/ssr": "^0.8.2", - "@wrnexus/csr": "^0.8.2", - "@wrnexus/compiler": "^0.8.2", - "@wrnexus/styles": "^0.8.2", - "@wrnexus/ui": "^0.8.2", - "@wrnexus/validation": "^0.8.2", - "@wrnexus/i18n": "^0.8.2", - "@wrnexus/db": "^0.8.2", - "@wrnexus/pubsub": "^0.8.2", - "@wrnexus/uploader": "^0.8.2", - "@wrnexus/plugin": "^0.8.2", - "@wrnexus/store": "^0.8.2", - "@wrnexus/security": "^0.8.2", - "@wrnexus/observability": "^0.8.2", - "@wrnexus/cache": "^0.8.2", - "@wrnexus/pwa": "^0.8.2" + "@wrnexus/core": "^0.8.3", + "@wrnexus/dev-toolbar": "^0.8.3", + "@wrnexus/router": "^0.8.3", + "@wrnexus/ssr": "^0.8.3", + "@wrnexus/csr": "^0.8.3", + "@wrnexus/compiler": "^0.8.3", + "@wrnexus/styles": "^0.8.3", + "@wrnexus/ui": "^0.8.3", + "@wrnexus/validation": "^0.8.3", + "@wrnexus/i18n": "^0.8.3", + "@wrnexus/db": "^0.8.3", + "@wrnexus/pubsub": "^0.8.3", + "@wrnexus/uploader": "^0.8.3", + "@wrnexus/plugin": "^0.8.3", + "@wrnexus/store": "^0.8.3", + "@wrnexus/security": "^0.8.3", + "@wrnexus/observability": "^0.8.3", + "@wrnexus/cache": "^0.8.3", + "@wrnexus/pwa": "^0.8.3" }, "files": [ "dist", diff --git a/.publish/dev-toolbar/package.json b/.publish/dev-toolbar/package.json index 0c19ec56..3ebfd611 100644 --- a/.publish/dev-toolbar/package.json +++ b/.publish/dev-toolbar/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-toolbar", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "@wrnexus/dev-toolbar — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/encryption/package.json b/.publish/encryption/package.json index ffa402d0..1adf5c4f 100644 --- a/.publish/encryption/package.json +++ b/.publish/encryption/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/encryption", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "Authenticated encryption, key rotation, hashing, and optional application-layer encrypted HTTP envelopes.", "license": "MIT", @@ -37,7 +37,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.8.2" + "@wrnexus/core": "^0.8.3" }, "files": [ "dist", diff --git a/.publish/helpers/package.json b/.publish/helpers/package.json index 32dfe39d..ce5e810e 100644 --- a/.publish/helpers/package.json +++ b/.publish/helpers/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/helpers", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "Safe convenience helpers for WrNexus request contexts and common application flows.", "license": "MIT", @@ -37,7 +37,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.8.2" + "@wrnexus/core": "^0.8.3" }, "files": [ "dist", diff --git a/.publish/i18n/package.json b/.publish/i18n/package.json index b27b98d4..880a9579 100644 --- a/.publish/i18n/package.json +++ b/.publish/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/i18n", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "Locale loading, fallback resolution, SSR/browser translations, formatters, and WRNexusJS language components.", "license": "MIT", @@ -42,9 +42,9 @@ "./components/*": "./components/*" }, "dependencies": { - "@wrnexus/core": "^0.8.2", - "@wrnexus/plugin": "^0.8.2", - "@wrnexus/ui": "^0.8.2" + "@wrnexus/core": "^0.8.3", + "@wrnexus/plugin": "^0.8.3", + "@wrnexus/ui": "^0.8.3" }, "wrnexus": { "plugin": { diff --git a/.publish/jwt/package.json b/.publish/jwt/package.json index 18d67d25..4a802485 100644 --- a/.publish/jwt/package.json +++ b/.publish/jwt/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/jwt", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "HS256 JSON Web Tokens, key rotation, access/refresh helpers, scopes, cookies, and auth middleware.", "license": "MIT", @@ -37,7 +37,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.8.2" + "@wrnexus/core": "^0.8.3" }, "files": [ "dist", diff --git a/.publish/mobile/package.json b/.publish/mobile/package.json index 2571548c..13842f56 100644 --- a/.publish/mobile/package.json +++ b/.publish/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/mobile", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "@wrnexus/mobile — part of the WrNexus framework.", "license": "MIT", @@ -37,7 +37,7 @@ } }, "dependencies": { - "@wrnexus/native": "^0.8.2" + "@wrnexus/native": "^0.8.3" }, "files": [ "dist", diff --git a/.publish/native/package.json b/.publish/native/package.json index f28c0842..36e1447a 100644 --- a/.publish/native/package.json +++ b/.publish/native/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/native", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "@wrnexus/native — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/oauth/package.json b/.publish/oauth/package.json index cb84605a..505b78eb 100644 --- a/.publish/oauth/package.json +++ b/.publish/oauth/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/oauth", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "@wrnexus/oauth — part of the WrNexus framework.", "license": "MIT", @@ -37,7 +37,7 @@ } }, "dependencies": { - "@wrnexus/jwt": "^0.8.2" + "@wrnexus/jwt": "^0.8.3" }, "files": [ "dist", diff --git a/.publish/plugin/package.json b/.publish/plugin/package.json index d359c494..07f4f81a 100644 --- a/.publish/plugin/package.json +++ b/.publish/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/plugin", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "@wrnexus/plugin — part of the WrNexus framework.", "license": "MIT", @@ -49,7 +49,7 @@ } }, "dependencies": { - "@wrnexus/syntax": "^0.8.2" + "@wrnexus/syntax": "^0.8.3" }, "files": [ "dist", diff --git a/.publish/pubsub/package.json b/.publish/pubsub/package.json index d466bf27..ae77b1de 100644 --- a/.publish/pubsub/package.json +++ b/.publish/pubsub/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/pubsub", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "@wrnexus/pubsub — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/queue/package.json b/.publish/queue/package.json index 1c9c54ca..503b120d 100644 --- a/.publish/queue/package.json +++ b/.publish/queue/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/queue", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "@wrnexus/queue — part of the WrNexus framework.", "license": "MIT", @@ -37,7 +37,7 @@ } }, "dependencies": { - "@wrnexus/core": "^0.8.2" + "@wrnexus/core": "^0.8.3" }, "files": [ "dist", diff --git a/.publish/reactive/package.json b/.publish/reactive/package.json index 854bb1b1..6c0073a2 100644 --- a/.publish/reactive/package.json +++ b/.publish/reactive/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/reactive", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "@wrnexus/reactive — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/router/package.json b/.publish/router/package.json index 19692a95..dd042f51 100644 --- a/.publish/router/package.json +++ b/.publish/router/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/router", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "@wrnexus/router — part of the WrNexus framework.", "license": "MIT", @@ -37,8 +37,8 @@ } }, "dependencies": { - "@wrnexus/compiler": "^0.8.2", - "@wrnexus/core": "^0.8.2" + "@wrnexus/compiler": "^0.8.3", + "@wrnexus/core": "^0.8.3" }, "files": [ "dist", diff --git a/.publish/ssr/package.json b/.publish/ssr/package.json index 36256453..ca19dc15 100644 --- a/.publish/ssr/package.json +++ b/.publish/ssr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ssr", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "@wrnexus/ssr — part of the WrNexus framework.", "license": "MIT", @@ -45,9 +45,9 @@ } }, "dependencies": { - "@wrnexus/core": "^0.8.2", - "@wrnexus/store": "^0.8.2", - "@wrnexus/security": "^0.8.2" + "@wrnexus/core": "^0.8.3", + "@wrnexus/store": "^0.8.3", + "@wrnexus/security": "^0.8.3" }, "files": [ "dist", diff --git a/.publish/styles/package.json b/.publish/styles/package.json index 52b782a8..31cd472b 100644 --- a/.publish/styles/package.json +++ b/.publish/styles/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/styles", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "@wrnexus/styles — part of the WrNexus framework.", "license": "MIT", @@ -37,9 +37,9 @@ } }, "dependencies": { - "@wrnexus/uploader": "^0.8.2", - "@wrnexus/core": "^0.8.2", - "@wrnexus/plugin": "^0.8.2" + "@wrnexus/uploader": "^0.8.3", + "@wrnexus/core": "^0.8.3", + "@wrnexus/plugin": "^0.8.3" }, "files": [ "dist", diff --git a/.publish/syntax/package.json b/.publish/syntax/package.json index 75170a3f..fd7d00b4 100644 --- a/.publish/syntax/package.json +++ b/.publish/syntax/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/syntax", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "@wrnexus/syntax — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/test/package.json b/.publish/test/package.json index b13f03f6..22bec8b2 100644 --- a/.publish/test/package.json +++ b/.publish/test/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/test", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "@wrnexus/test — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/tracking/package.json b/.publish/tracking/package.json index af59f5ed..0ad122ab 100644 --- a/.publish/tracking/package.json +++ b/.publish/tracking/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/tracking", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "@wrnexus/tracking — part of the WrNexus framework.", "license": "MIT", diff --git a/.publish/ui/package.json b/.publish/ui/package.json index ef06461e..1fec4c6e 100644 --- a/.publish/ui/package.json +++ b/.publish/ui/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ui", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "@wrnexus/ui — part of the WrNexus framework.", "license": "MIT", @@ -42,7 +42,7 @@ "./ui.css": "./ui.css" }, "dependencies": { - "@wrnexus/core": "^0.8.2" + "@wrnexus/core": "^0.8.3" }, "files": [ "dist", diff --git a/.publish/uploader/package.json b/.publish/uploader/package.json index f70979b1..28cea6a7 100644 --- a/.publish/uploader/package.json +++ b/.publish/uploader/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/uploader", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "Secure upload drivers, policies, client runtime, helper functions, and reusable upload components.", "license": "MIT", @@ -42,9 +42,9 @@ "./components/*": "./components/*" }, "dependencies": { - "@wrnexus/core": "^0.8.2", - "@wrnexus/plugin": "^0.8.2", - "@wrnexus/ui": "^0.8.2" + "@wrnexus/core": "^0.8.3", + "@wrnexus/plugin": "^0.8.3", + "@wrnexus/ui": "^0.8.3" }, "wrnexus": { "plugin": { diff --git a/.publish/validation/package.json b/.publish/validation/package.json index 6821dd4f..b2f64839 100644 --- a/.publish/validation/package.json +++ b/.publish/validation/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/validation", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "Shared server/browser schemas, validation helpers, form runtime, and reusable error components.", "license": "MIT", @@ -42,9 +42,9 @@ "./components/*": "./components/*" }, "dependencies": { - "@wrnexus/core": "^0.8.2", - "@wrnexus/plugin": "^0.8.2", - "@wrnexus/ui": "^0.8.2" + "@wrnexus/core": "^0.8.3", + "@wrnexus/plugin": "^0.8.3", + "@wrnexus/ui": "^0.8.3" }, "wrnexus": { "plugin": { diff --git a/CHANGELOG.md b/CHANGELOG.md index bd4be791..6fb593c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,26 @@ All notable framework changes are recorded here. Every release must also include an idempotent entry in the CLI migration registry. +## 0.8.3 - 2026-08-03 + +- Bundled hydrated browser modules so package aliases and filesystem imports do not leak into + browser-native modules. +- Fixed SSR computed values, Async branch aliases, typed object props in loops, reactive route + state, loader invalidation, realtime identity isolation, and client-function hydration. +- Fixed template-literal parsing in the reactive fallback evaluator and removed the unsupported + default `unload` Permissions Policy directive. +- Made official package components compatible with strict explicit imports and added regression + coverage for the TeamSpace application failures. +- Fixed Bun 1.3.14 and TypeScript 5.9 release-gate compatibility in client bundling, alias + resolution, and Happy DOM event tests. +- Fixed ESLint compatibility in Node release scripts by declaring Node globals and using explicit + regex space quantifiers. +- Fixed package component import validation after workspace installation by excluding generated and + dependency directories such as nested `node_modules` from first-party source scans. +- Made the VS Code embedded compiler freshness check deterministic across TypeScript patch + versions, Windows/Linux line endings, and workspace environments by verifying normalized source + and generator fingerprints while retaining exact-output checks for the same TypeScript version. + ## 0.8.0 - 2026-08-02 - Added package-owned helper and component kits across all 39 framework packages. diff --git a/bun.lock b/bun.lock index 23c9add9..30dabf36 100644 --- a/bun.lock +++ b/bun.lock @@ -102,11 +102,11 @@ }, "packages/ai": { "name": "@wrnexus/ai", - "version": "0.8.0", + "version": "0.8.3", }, "packages/auth": { "name": "@wrnexus/auth", - "version": "0.8.0", + "version": "0.8.3", "dependencies": { "@wrnexus/authz": "workspace:*", "@wrnexus/captcha": "workspace:*", @@ -128,22 +128,22 @@ }, "packages/authz": { "name": "@wrnexus/authz", - "version": "0.8.0", + "version": "0.8.3", }, "packages/benchmark": { "name": "@wrnexus/benchmark", - "version": "0.8.0", + "version": "0.8.3", }, "packages/cache": { "name": "@wrnexus/cache", - "version": "0.8.0", + "version": "0.8.3", "dependencies": { "@wrnexus/core": "workspace:*", }, }, "packages/captcha": { "name": "@wrnexus/captcha", - "version": "0.8.0", + "version": "0.8.3", "dependencies": { "@wrnexus/core": "workspace:*", "@wrnexus/plugin": "workspace:*", @@ -158,7 +158,7 @@ }, "packages/cli": { "name": "@wrnexus/cli", - "version": "0.8.0", + "version": "0.8.3", "bin": { "wrnexus": "src/index.ts", }, @@ -184,7 +184,7 @@ }, "packages/compiler": { "name": "@wrnexus/compiler", - "version": "0.8.0", + "version": "0.8.3", "dependencies": { "@wrnexus/csr": "workspace:*", "@wrnexus/store": "workspace:*", @@ -194,7 +194,7 @@ }, "packages/content": { "name": "@wrnexus/content", - "version": "0.8.0", + "version": "0.8.3", "devDependencies": { "@types/bun": "^1.3.14", "typescript": "^5.9.2", @@ -202,18 +202,18 @@ }, "packages/core": { "name": "@wrnexus/core", - "version": "0.8.0", + "version": "0.8.3", }, "packages/csr": { "name": "@wrnexus/csr", - "version": "0.8.0", + "version": "0.8.3", "dependencies": { "@wrnexus/core": "workspace:*", }, }, "packages/db": { "name": "@wrnexus/db", - "version": "0.8.0", + "version": "0.8.3", "devDependencies": { "@types/bun": "^1.3.14", "typescript": "^5.9.2", @@ -221,7 +221,7 @@ }, "packages/dev-server": { "name": "@wrnexus/dev-server", - "version": "0.8.0", + "version": "0.8.3", "dependencies": { "@wrnexus/cache": "workspace:*", "@wrnexus/compiler": "workspace:*", @@ -246,7 +246,7 @@ }, "packages/dev-toolbar": { "name": "@wrnexus/dev-toolbar", - "version": "0.8.0", + "version": "0.8.3", "devDependencies": { "@types/bun": "^1.3.14", "typescript": "^5.9.2", @@ -254,7 +254,7 @@ }, "packages/encryption": { "name": "@wrnexus/encryption", - "version": "0.8.0", + "version": "0.8.3", "dependencies": { "@wrnexus/core": "workspace:*", }, @@ -265,21 +265,21 @@ }, "packages/graphql": { "name": "@wrnexus/graphql", - "version": "0.8.0", + "version": "0.8.3", "dependencies": { "@wrnexus/plugin": "workspace:*", }, }, "packages/helpers": { "name": "@wrnexus/helpers", - "version": "0.8.0", + "version": "0.8.3", "dependencies": { "@wrnexus/core": "workspace:*", }, }, "packages/i18n": { "name": "@wrnexus/i18n", - "version": "0.8.0", + "version": "0.8.3", "dependencies": { "@wrnexus/core": "workspace:*", "@wrnexus/plugin": "workspace:*", @@ -293,7 +293,7 @@ }, "packages/identity": { "name": "@wrnexus/identity", - "version": "0.8.0", + "version": "0.8.3", "dependencies": { "@wrnexus/auth": "workspace:*", "@wrnexus/authz": "workspace:*", @@ -302,7 +302,7 @@ }, "packages/image": { "name": "@wrnexus/image", - "version": "0.8.0", + "version": "0.8.3", "dependencies": { "@wrnexus/plugin": "workspace:*", "@wrnexus/security": "workspace:*", @@ -322,7 +322,7 @@ }, "packages/jwt": { "name": "@wrnexus/jwt", - "version": "0.8.0", + "version": "0.8.3", "dependencies": { "@wrnexus/core": "workspace:*", }, @@ -333,7 +333,7 @@ }, "packages/language-server": { "name": "@wrnexus/language-server", - "version": "0.8.0", + "version": "0.8.3", "bin": { "wrnexus-language-server": "src/server.ts", }, @@ -344,39 +344,39 @@ }, "packages/mcp": { "name": "@wrnexus/mcp", - "version": "0.8.0", + "version": "0.8.3", "bin": { "wrnexus-mcp": "src/stdio.ts", }, }, "packages/mobile": { "name": "@wrnexus/mobile", - "version": "0.8.0", + "version": "0.8.3", "dependencies": { "@wrnexus/native": "workspace:*", }, }, "packages/native": { "name": "@wrnexus/native", - "version": "0.8.0", + "version": "0.8.3", }, "packages/oauth": { "name": "@wrnexus/oauth", - "version": "0.8.0", + "version": "0.8.3", "dependencies": { "@wrnexus/jwt": "workspace:*", }, }, "packages/observability": { "name": "@wrnexus/observability", - "version": "0.8.0", + "version": "0.8.3", "dependencies": { "@wrnexus/core": "workspace:*", }, }, "packages/playground": { "name": "@wrnexus/playground", - "version": "0.8.0", + "version": "0.8.3", "dependencies": { "@wrnexus/compiler": "workspace:*", "@wrnexus/syntax": "workspace:*", @@ -384,18 +384,18 @@ }, "packages/plugin": { "name": "@wrnexus/plugin", - "version": "0.8.0", + "version": "0.8.3", "dependencies": { "@wrnexus/syntax": "workspace:*", }, }, "packages/pubsub": { "name": "@wrnexus/pubsub", - "version": "0.8.0", + "version": "0.8.3", }, "packages/pwa": { "name": "@wrnexus/pwa", - "version": "0.8.0", + "version": "0.8.3", "devDependencies": { "@types/bun": "^1.3.14", "typescript": "^5.9.2", @@ -403,18 +403,18 @@ }, "packages/queue": { "name": "@wrnexus/queue", - "version": "0.8.0", + "version": "0.8.3", "dependencies": { "@wrnexus/core": "workspace:*", }, }, "packages/reactive": { "name": "@wrnexus/reactive", - "version": "0.8.0", + "version": "0.8.3", }, "packages/realtime": { "name": "@wrnexus/realtime", - "version": "0.8.0", + "version": "0.8.3", "dependencies": { "@wrnexus/core": "workspace:*", "@wrnexus/plugin": "workspace:*", @@ -428,7 +428,7 @@ }, "packages/router": { "name": "@wrnexus/router", - "version": "0.8.0", + "version": "0.8.3", "dependencies": { "@wrnexus/compiler": "workspace:*", "@wrnexus/core": "workspace:*", @@ -436,14 +436,14 @@ }, "packages/security": { "name": "@wrnexus/security", - "version": "0.8.0", + "version": "0.8.3", "dependencies": { "@wrnexus/core": "workspace:*", }, }, "packages/ssr": { "name": "@wrnexus/ssr", - "version": "0.8.0", + "version": "0.8.3", "dependencies": { "@wrnexus/core": "workspace:*", "@wrnexus/security": "workspace:*", @@ -452,11 +452,11 @@ }, "packages/store": { "name": "@wrnexus/store", - "version": "0.8.0", + "version": "0.8.3", }, "packages/styles": { "name": "@wrnexus/styles", - "version": "0.8.0", + "version": "0.8.3", "dependencies": { "@wrnexus/core": "workspace:*", "@wrnexus/plugin": "workspace:*", @@ -465,19 +465,19 @@ }, "packages/syntax": { "name": "@wrnexus/syntax", - "version": "0.8.0", + "version": "0.8.3", }, "packages/test": { "name": "@wrnexus/test", - "version": "0.8.0", + "version": "0.8.3", }, "packages/tracking": { "name": "@wrnexus/tracking", - "version": "0.8.0", + "version": "0.8.3", }, "packages/typecheck": { "name": "@wrnexus/typecheck", - "version": "0.8.0", + "version": "0.8.3", "dependencies": { "@wrnexus/syntax": "workspace:*", "typescript": "^5.5.0", @@ -485,14 +485,14 @@ }, "packages/ui": { "name": "@wrnexus/ui", - "version": "0.8.0", + "version": "0.8.3", "dependencies": { "@wrnexus/core": "workspace:*", }, }, "packages/uploader": { "name": "@wrnexus/uploader", - "version": "0.8.0", + "version": "0.8.3", "dependencies": { "@wrnexus/core": "workspace:*", "@wrnexus/plugin": "workspace:*", @@ -506,7 +506,7 @@ }, "packages/validation": { "name": "@wrnexus/validation", - "version": "0.8.0", + "version": "0.8.3", "dependencies": { "@wrnexus/core": "workspace:*", "@wrnexus/plugin": "workspace:*", diff --git a/docs/UPGRADE-0.8.3.md b/docs/UPGRADE-0.8.3.md new file mode 100644 index 00000000..33344713 --- /dev/null +++ b/docs/UPGRADE-0.8.3.md @@ -0,0 +1,59 @@ +# Upgrade to WRNexusJS 0.8.3 + +WRNexusJS 0.8.3 is a framework stability release. It fixes browser hydration modules, +SSR computed values, Async scopes, typed props in loops, realtime connection identity, +loader invalidation, strict component imports, and development diagnostics. + +## Application migration + +No application source rewrite is required. The `0.8.3-client-ssr-realtime-stability` +migration is intentionally idempotent and records the upgrade in the CLI migration registry. + +After updating all `@wrnexus/*` dependencies to `0.8.3`, remove generated artifacts so the +new browser-module cache format is used: + +```bash +rm -rf .wrnexus dist +bun install +bun run check +``` + +On Windows PowerShell: + +```powershell +Remove-Item -Recurse -Force .wrnexus, dist -ErrorAction SilentlyContinue +bun install +bun run check +``` + +## Behavior changes + +- Hydrated `.wrn` browser modules are bundled before they are served or emitted. Browser code + no longer receives unresolved bare package imports or `file:` filesystem imports. +- Generated native `client` and `shared` functions take precedence over the CSP-safe expression + fallback. The fallback also accepts template literals inside call arguments. +- Page computed values are declared for SSR in dependency order. Computed cycles fail with + `WRN-COMPUTED-CYCLE`. +- Whole-expression object and array props inside `{#each}` are serialized as typed props. +- `` and `` aliases have branch-local scope. +- Realtime sockets are keyed by room plus normalized query identity and reconnect when identity + attributes change. +- `$route` and `route` expose reactive pathname, query, hash, and params to hydrated views. +- Uploader success events dispatch cache invalidation tags. Matching client Async boundaries + refresh automatically. +- The unsupported default `unload` Permissions Policy directive has been removed. +- Official package components now satisfy `imports.mode = "explicit"`. + +## Publishing 0.8.3 + +Run the complete release gate with Bun before publishing: + +```bash +bun install --frozen-lockfile +bun run check:production +bun run release:prepare +bun run release:private +``` + +`release:prepare` rebuilds package distributions and verifies that version `0.8.3` has a CLI +migration entry. Do not publish stale `dist` directories from an older release. diff --git a/docs/VALIDATION-0.8.3.md b/docs/VALIDATION-0.8.3.md new file mode 100644 index 00000000..8a116c43 --- /dev/null +++ b/docs/VALIDATION-0.8.3.md @@ -0,0 +1,73 @@ +# WRNexusJS 0.8.3 validation report + +## Validated in the prepared source archive + +- All 47 root/package manifests use version `0.8.3` and all non-workspace internal dependency + ranges resolve to `0.8.3`. +- The CLI migration registry contains `0.8.3-client-ssr-realtime-stability`. +- All official package `.wrn` files pass parse/code-generation checks. +- All official reusable components pass explicit-import validation. +- The UI visual contract, public API baseline, workspace repair check, security framework audit, + and ASVS policy checks pass. +- Regression scripts verify request-derived SSR computed values, view-only browser imports, + typed loop props, Async aliases, template literals in fallback expressions, native client + function preservation, settings persistence, realtime identity isolation, and the TeamSpace + example browser-module graph. +- The generated VS Code compiler bundle was rebuilt from the updated syntax/compiler source. + +## Release-candidate corrections + +After the first Windows release-gate run, the source archive was corrected for four TypeScript +errors: + +- Removed the unsupported `write` property from `Bun.build()` configuration objects while keeping + in-memory artifact reads through `BuildOutput.outputs`. +- Typed the merged import-alias map as `Record`. +- Cast Happy DOM `CustomEvent` values to the exact `Window.dispatchEvent` parameter type in CSR + tests. +- Removed local `.env` and `.env.uat` files from the source archive; equivalent `.example` + templates remain included. + +A second Windows release-gate run exposed ten ESLint errors in release helper scripts. The source +archive was corrected by importing Node globals explicitly in `scripts/check-component-imports.mjs` +and replacing hard-counted regex spaces with `{4}` and `{6}` quantifiers in +`scripts/validate-0.8.mjs` and `update-package-versions.mjs`. + +A third Windows release-gate run showed that the package component validator was traversing +workspace-installed dependency copies under nested `node_modules` directories. Those files are not +first-party package sources and were being checked against the manifest of the containing workspace +package. The validator now excludes dependency and generated directories (`node_modules`, `dist`, +`coverage`, `.git`, `.wrnexus`, and `.publish`) while still scanning every official source component. +A regression fixture confirms that an invalid nested dependency component is ignored and the 146 +official source files remain validated. + +A fourth Windows release-gate run found that `editors/vscode/src/compiler.cjs` was stale after the +final compiler changes. The bundle was regenerated from all 26 current syntax/compiler TypeScript +modules. A fifth Windows run then exposed a cross-environment false positive: the archive had been +generated with TypeScript 5.8.3 while the frozen release install uses TypeScript 5.9.3, and the old +checker compared transpiled output byte-for-byte. The editor compiler generator now normalizes source +and line endings, emits source and generator SHA-256 fingerprints, and performs an exact-output check +when the TypeScript version matches. When only the TypeScript patch version differs, it validates the +source/generator fingerprints instead. Genuine source changes still fail the freshness gate. + +The corrected helper scripts pass Node syntax checks. Package component import validation passes +for all 146 official WRN files. The 0.8 validation script advances through its source-only checks +in the preparation environment and stops only when dependencies excluded from the release archive +are required. The complete TypeScript and Bun test gates still need to be rerun on the release +machine after `bun install --frozen-lockfile`. + +## Required final release verification + +The preparation environment did not include the Bun executable. Therefore the complete Bun test, +lint, formatting, package build, editor bundle comparison, staged-consumer, and publish dry-run +must still be executed on the release machine: + +```bash +bun install --frozen-lockfile +bun run check:production +bun run release:prepare +bun run validate:staging +bun run test:staged-consumers +``` + +A release should not be published if any command above fails. diff --git a/docs/ui-visual-contract-0.8.json b/docs/ui-visual-contract-0.8.json index eed5c965..a7298b70 100644 --- a/docs/ui-visual-contract-0.8.json +++ b/docs/ui-visual-contract-0.8.json @@ -7,7 +7,7 @@ "packages/ui/components/AdvancedRangeSlider.wrn": "e78b5cf825503d0cd306c6e490d71317ee3db1844882d13b500df1f7f1c408e1", "packages/ui/components/AdvancedSelect.wrn": "29d43f8019a3e0e98af7cf12440f41523665535a39a973441b4faea78e67114a", "packages/ui/components/AnnouncementBar.wrn": "5946ef5eb646f7414056f59d9cfe14680aa3c935acc9a1ca6528dc53b23caa48", - "packages/ui/components/AuthForm.wrn": "5441adc5c4fd94621633f3a178442fd7970de3afb0dd3827b7aae6cd43545e40", + "packages/ui/components/AuthForm.wrn": "e6ab0cc4a557b3fd41528368c5aa7aaf39e9adcd49d9907df8d26d77bc515870", "packages/ui/components/AuthSplitLayout.wrn": "72b82495dd14d40a2d594ccdcf3adc925d1567c155750fb1072347b8845aab7b", "packages/ui/components/AvatarGroup.wrn": "9c583f567213f8e1ad4506a7d2abc3fb462d11679fd2196d3828030f28a28964", "packages/ui/components/BackToTop.wrn": "b12c56ec8b7aabad68cbdc47f4b3237e2c21a61398f0aa51f9add6229b7357cd", @@ -39,15 +39,15 @@ "packages/ui/components/Drawer.wrn": "7ecfc54494402e8758eb3f648418df70b6cde598f38836a620a1fa179d1bb1bd", "packages/ui/components/Dropdown.wrn": "703c354dfb304ad640a600398317b0a5eefeea326df8a1fc51c87e0ed3c26ebc", "packages/ui/components/FeatureCard.wrn": "cf122df37d5de72a9556fea501b13b8bfad49ee4a5e88daec2a32d204ee9e784", - "packages/ui/components/FeatureGrid.wrn": "b9c793e47f6a86fddc0f42c010544002509a11256c92c030ae29743ab0083a55", - "packages/ui/components/FeatureIconCard.wrn": "7d053cf642ab572a254383a72fb60323118a6b41b433696cf6ac62857dea7d5e", + "packages/ui/components/FeatureGrid.wrn": "9e3c0440d08c6982861d3e732eeb20f1023413a58243844dfb9d7033bac13d2b", + "packages/ui/components/FeatureIconCard.wrn": "afe56213543dd78932b060fa19c4e645e558b211b380c42b6d4b0d18e9d543df", "packages/ui/components/FileInput.wrn": "8b63811deb90a03763620bedf0d5d3c7d05a20b8ae34292eaface9b756b32ecd", "packages/ui/components/FileUpload.wrn": "3346d8978e134c3a1e6bc742201a3fbb0cffb89da40da6fcd856a2adbb971393", "packages/ui/components/FileUploadProgress.wrn": "83bcc8558c70fbe0e0670642f65998abd222c235f1031be46bd7c65e6b5c5154", "packages/ui/components/Footer.wrn": "a5dfaa5f9548b7fdd4b556c63428e0ea8d22e5e776a5714d76619999438cf618", "packages/ui/components/Grid.wrn": "b5546b899c509c1f3e8f5edad801bc59acf9fe3008a627f0e4476cd61b5b672e", - "packages/ui/components/Hero.wrn": "c7608d8a81df53ffcde5d857abc06c2eff813d4a10223a47a70602a68e7b025c", - "packages/ui/components/HeroActions.wrn": "8f5d2d86c928c7499610f58a8b5c078e368d67f8924311472357a70c5d1dae1a", + "packages/ui/components/Hero.wrn": "e4e39dcd59c273192c7e4136f6583279e20b0956bcba4cfbc3f6e01a1fade152", + "packages/ui/components/HeroActions.wrn": "c5720efe2ce7f6c114eb2ea4ccc3e8102a1099078ba23fd13770eddeb6aec640", "packages/ui/components/Image.wrn": "4294358e2eddf06ae4384217dbff7693b609c16bf6549f54d6ad27df74d7ae27", "packages/ui/components/Input.wrn": "9ad394eed60f1b05bbd2ccd50dc2de7b78245c52c87afd46055d772c38b7b0c9", "packages/ui/components/InputGroup.wrn": "0e23ea541a60e893c9d9e6f95113911ee459468d8d6105bf03aeb55395359123", @@ -59,15 +59,15 @@ "packages/ui/components/List.wrn": "02f960a8e82fa049aac477bac9c1d9371394c86d9c47513138f61bffc79dc7d8", "packages/ui/components/ListGroup.wrn": "37e2ec61ceab20ed020a834da03208c493702b41abcf586816e29f2e50d8ee11", "packages/ui/components/Map.wrn": "7b8a0d5412a464fd7cab8977d816542c506d8c5dab8230a3c984c2caee407c91", - "packages/ui/components/MarketingSectionHeader.wrn": "5147f3d86f18404c66111a52cccb44be56ca4cbfd7a7f4e885e41f82c1212c1e", + "packages/ui/components/MarketingSectionHeader.wrn": "7d6ffcc01a9331474475ea57ca58598be757abd9428b66bdfaf6f3603c5b145b", "packages/ui/components/Marquee.wrn": "b2b35eedf3297ba12ab3776385a9f3eab001027648d87a2a1968f576eee6c025", "packages/ui/components/MegaMenu.wrn": "4a084eaf6aae77bb9023d2f3589bc6b80119b9be63982a90a80f9d280cc9c0a5", "packages/ui/components/MetricCard.wrn": "6451182739298691908f68258c0250cce2a78b0dc27c97115579ece30d7d9f92", - "packages/ui/components/MetricGrid.wrn": "49742c27d988755316be54eae3722bb88600dd1add8c851fc95d4b4498db80a8", + "packages/ui/components/MetricGrid.wrn": "6018a98c10628ed240ed236ed916c0ff60994d192c3aadafd10bc876be0f9364", "packages/ui/components/Modal.wrn": "1821428492e510403dd029c4766e71272899299f65141ae4fdaf09c4a26719e5", "packages/ui/components/Nav.wrn": "78f215c94caf68e0968449a23e23bd3409a689e0c52a6c1770aa8373c767d080", "packages/ui/components/Navbar.wrn": "e68f9d3643e500e43124e9c7a6d4c7f6722657377ea7313f3e3cf5093a81b360", - "packages/ui/components/PageHeader.wrn": "e9f6c4c2b8723f3f035aa840ba8232f393a4038fae20c55dcd2a78276ac6738c", + "packages/ui/components/PageHeader.wrn": "adb3bed81ce040044405e35a52d0304f49d5a0d162f923fbd54a4242a25c7362", "packages/ui/components/Pagination.wrn": "9169e724f89992dacd10e9492a95438c8016affb39c0fd17a4f6fe26bd21d8b4", "packages/ui/components/PinInput.wrn": "5196f584de8d548a5dfa03688c3c95da3c948cead2662b05e927386ccea74299", "packages/ui/components/Popover.wrn": "fd9982f60e37e500586f788470f41a8510d6b96d3a85c80d2859fa01cac6731e", @@ -79,7 +79,7 @@ "packages/ui/components/Rating.wrn": "a2d74dc748fc7d98892684c760518b87fa65411b1102e3611252b87245b3ffcb", "packages/ui/components/Scrollspy.wrn": "bbe0788f63c2bc8850649c1dee78391d485a1c93876015bc536d838273d5fae4", "packages/ui/components/SearchBox.wrn": "315f54f1feaaa47a815d1e2d7a35b492f09fbfadfc6d37228e1009e19e0652ad", - "packages/ui/components/Section.wrn": "dc18952a6342f3af04fdab6a44a5755b6767e65bf37a11528fd9e67dde39802e", + "packages/ui/components/Section.wrn": "21485de07d8bb986837a65c61c5dd01ba6c2f3d4e37d58c154a584c7b58de0ed", "packages/ui/components/SectionHeader.wrn": "512cb96636ee1f0540a0f4b4c75603fc0fe7536e7ed7ed7dc82173a16f48a4d3", "packages/ui/components/Select.wrn": "7f046bb11b7c2470dae26d91a4b2261c66a40ae054d0e04c69b6748912d1e204", "packages/ui/components/Sidebar.wrn": "05f4bd216ae8bfcee2460fe638e431174d5f3c8e10003f346644e5a2939e7ce5", diff --git a/editors/vscode/CHANGELOG.md b/editors/vscode/CHANGELOG.md index 071d9398..7ca507c3 100644 --- a/editors/vscode/CHANGELOG.md +++ b/editors/vscode/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.8.3 + +- Rebuilt the embedded WRN compiler with the 0.8.3 SSR, computed-value, Async-scope, and typed loop-prop fixes. +- Added compiler awareness for pruned and bundled browser hydration imports. +- Aligned the extension version with the WRNexusJS 0.8.3 framework release. + ## 0.8.0 - Fixed excessive extension-host and language-server memory growth in large workspaces. diff --git a/editors/vscode/package-lock.json b/editors/vscode/package-lock.json index 40c1ca76..6360b6b9 100644 --- a/editors/vscode/package-lock.json +++ b/editors/vscode/package-lock.json @@ -1,12 +1,12 @@ { "name": "wrnexus", - "version": "0.8.0", + "version": "0.8.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wrnexus", - "version": "0.8.0", + "version": "0.8.3", "license": "SEE LICENSE IN LICENSE", "dependencies": { "vscode-languageclient": "^10.1.0" diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 8ae934d3..f2791737 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -1,8 +1,8 @@ { "name": "wrnexus", "displayName": "WRNexus Language Support", - "description": "Complete WRNexus v0.6 language support for typed imports, props, state, outputs, runtime functions, stores, diagnostics, formatting, navigation, and migration assistance.", - "version": "0.8.0", + "description": "Complete WRNexus v0.8.3 language support for typed imports, props, state, outputs, runtime functions, stores, diagnostics, formatting, navigation, and migration assistance.", + "version": "0.8.3", "publisher": "wrnexus", "private": true, "license": "SEE LICENSE IN LICENSE", diff --git a/editors/vscode/src/compiler.cjs b/editors/vscode/src/compiler.cjs index d67fd116..ea27d8d3 100644 --- a/editors/vscode/src/compiler.cjs +++ b/editors/vscode/src/compiler.cjs @@ -1,5 +1,8 @@ "use strict"; // Generated by scripts/build-editor-compiler.mjs. Do not edit directly. +// WRN editor compiler source hash: b9e9a9ed0492c387198e368436696480ef2eca5a850ed94852c78c73c70d2959 +// WRN editor compiler generator hash: c71e7fe4258c97b73b384ff14b321f0cf0b30cc2ed0322f5f84b04e757159b18 +// Generated with TypeScript: 5.9.3 const __nodeRequire = require; const __path = __nodeRequire("node:path"); const __modules = { @@ -406,6 +409,7 @@ exports.DependencyGraph = DependencyGraph; "packages/compiler/src/client-codegen.ts": function (module, exports, require, __filename, __dirname) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); +exports.browserModuleRequired = browserModuleRequired; exports.generateBrowserModule = generateBrowserModule; const syntax_1 = require("@wrnexus/syntax"); const RESERVED_BINDINGS = new Set([ @@ -469,6 +473,91 @@ const RUNTIME_BINDINGS = new Set([ function safeIdentifier(name) { return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) && !RESERVED_BINDINGS.has(name); } +function identifierReferenced(source, name) { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`(?:^|[^A-Za-z0-9_$])${escaped}(?![A-Za-z0-9_$])`).test(source); +} +function viewReferenceSource(nodes) { + return nodes.flatMap((node) => { + if (node.type === "text") + return [node.value]; + if (node.type === "each") { + return [ + node.list, + node.key ?? "", + ...viewReferenceSource(node.body), + ...viewReferenceSource(node.empty), + ]; + } + if (node.type === "if") { + return node.branches.flatMap((branch) => [ + branch.cond ?? "", + ...viewReferenceSource(branch.body), + ]); + } + return [...node.attrs.map((attr) => attr.value), ...viewReferenceSource(node.children)]; + }); +} +function browserReferenceSource(ast, functions) { + return [ + ...functions.flatMap((fn) => [fn.source, fn.body]), + ...ast.computed.map((entry) => entry.expr), + ...ast.effects, + ast.lifecycle.mount ?? "", + ast.lifecycle.update ?? "", + ast.lifecycle.unmount ?? "", + ...viewReferenceSource(ast.view), + ].join("\n"); +} +function renderSelectedImport(entry, source) { + if (entry.typeOnly) + return null; + const isStore = entry.source.endsWith(".wrn") && /(?:^|\/)stores?\//.test(entry.source); + if (entry.source.endsWith(".wrn") && !isStore) + return null; + const defaultImport = entry.defaultImport && (isStore || identifierReferenced(source, entry.defaultImport)) + ? entry.defaultImport + : undefined; + const namespaceImport = entry.namespaceImport && (isStore || identifierReferenced(source, entry.namespaceImport)) + ? entry.namespaceImport + : undefined; + const namedImports = entry.namedImports.filter((item) => !item.typeOnly && (isStore || identifierReferenced(source, item.local))); + const bindings = [ + ...(defaultImport ? [defaultImport] : []), + ...(namespaceImport ? [namespaceImport] : []), + ...namedImports.map((item) => item.local), + ].filter(safeIdentifier); + const hasBindings = Boolean(defaultImport || namespaceImport || namedImports.length); + const sideEffectOnly = !entry.defaultImport && !entry.namespaceImport && entry.namedImports.length === 0; + if (!hasBindings && !sideEffectOnly) + return null; + if (sideEffectOnly) + return { code: entry.raw, bindings: [] }; + const clauses = []; + if (defaultImport) + clauses.push(defaultImport); + if (namespaceImport) + clauses.push(`* as ${namespaceImport}`); + if (namedImports.length) { + clauses.push(`{ ${namedImports + .map((item) => item.imported === item.local ? item.imported : `${item.imported} as ${item.local}`) + .join(", ")} }`); + } + return { + code: `import ${clauses.join(", ")} from ${JSON.stringify(entry.source)};`, + bindings, + }; +} +function selectedBrowserImports(ast, functions) { + const referenceSource = browserReferenceSource(ast, functions); + return ast.structuredImports + .map((entry) => renderSelectedImport(entry, referenceSource)) + .filter((entry) => entry !== null); +} +function browserModuleRequired(ast) { + const functions = ast.runtimeFunctions.filter((fn) => ["legacy", "client", "shared"].includes(fn.runtime)); + return functions.length > 0 || selectedBrowserImports(ast, functions).length > 0; +} function functionEntry(ast, fn, availableFunctions) { const parameterNames = new Set(fn.parameters.map((parameter) => parameter.name)); const stateNames = ast.states @@ -551,19 +640,9 @@ function generateBrowserModule(ast) { const functions = ast.runtimeFunctions.filter((fn) => ["legacy", "client", "shared"].includes(fn.runtime)); const functionNames = functions.map((fn) => fn.name); const state = ast.states.filter((entry) => entry.runtime !== "server").map((entry) => entry.name); - const storeImports = ast.structuredImports.filter((entry) => !entry.typeOnly && entry.source.endsWith(".wrn") && /(?:^|\/)stores?\//.test(entry.source)); - const imports = ast.structuredImports - .filter((entry) => !entry.typeOnly) - .filter((entry) => !entry.source.endsWith(".wrn") || /(?:^|\/)stores?\//.test(entry.source)) - .map((entry) => entry.raw) - .join("\n"); - const importedBindings = storeImports - .flatMap((entry) => [ - ...(entry.defaultImport ? [entry.defaultImport] : []), - ...(entry.namespaceImport ? [entry.namespaceImport] : []), - ...entry.namedImports.map((item) => item.local), - ]) - .filter(safeIdentifier); + const selectedImports = selectedBrowserImports(ast, functions); + const imports = selectedImports.map((entry) => entry.code).join("\n"); + const importedBindings = [...new Set(selectedImports.flatMap((entry) => entry.bindings))]; return `// generated WRNexusJS browser module for ${ast.name} ${imports} export const __wrnexusClientFunctions = { @@ -611,6 +690,7 @@ const types_ts_1 = require("./types.js"); const syntax_1 = require("@wrnexus/syntax"); const store_codegen_ts_1 = require("./store-codegen.js"); const analysis_ts_1 = require("./analysis.js"); +const client_codegen_ts_1 = require("./client-codegen.js"); function isComponentTag(tag) { return /^[A-Z][A-Za-z0-9_$]*$/.test(tag); } @@ -802,7 +882,7 @@ function evalStateSeeds(states) { * runtime keeps it live. Non-state `{expr}` and un-evaluable expressions are * left as literal client mustaches. */ -function substituteReactiveText(raw, reactive) { +function substituteReactiveText(raw, reactive, dynamicExpressions) { const text = substituteTMarkers(raw); if (!reactive || reactive.stateNames.size === 0) return text; @@ -810,6 +890,11 @@ function substituteReactiveText(raw, reactive) { const expr = inner.trim(); if (expr.startsWith("t:") || !exprRefsState(expr, reactive.stateNames)) return whole; + if (exprRefsState(expr, reactive.runtimeStateNames) && dynamicExpressions) { + dynamicExpressions.push(`\${__wrnexusEscapeHtml(${expr})}`); + const sentinel = `\x00WRNEACH${dynamicExpressions.length - 1}\x00`; + return `${sentinel}`; + } let value; try { value = new Function("with(this){return (" + expr + ");}").call(reactive.scope); @@ -845,7 +930,10 @@ function bakeLoopText(raw) { return out + escLit(raw.slice(last)); } /** Bake a loop-body attribute value (same rules as text; escapeHtml is attribute-safe). */ -function bakeLoopAttr(raw) { +function bakeLoopAttr(raw, typed = false) { + const wholeExpression = wholeAttributeExpression(raw); + if (typed && wholeExpression) + return "${__wrnexusPropAttr(" + wholeExpression + ")}"; if (!raw.includes("{")) return escLit(attrEscape(raw)); let out = ""; @@ -878,7 +966,7 @@ function renderLoopBody(node) { if (attr.boolean) { return escLit(` ${name}`); } - return escLit(` ${name}="`) + bakeLoopAttr(attr.value) + escLit(`"`); + return escLit(` ${name}="`) + bakeLoopAttr(attr.value, componentTag) + escLit(`"`); }) .join(""); const inner = node.children.map(renderLoopBody).join(""); @@ -998,7 +1086,7 @@ function collectControlExprs(nodes, out = []) { } function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive = null) { if (node.type === "text") - return substituteReactiveText(node.value, reactive); // {t:key} + state baking + return substituteReactiveText(node.value, reactive, loops); // {t:key} + state baking // Server control block (loop / conditional) → a sentinel that survives // templateEscape, swapped for its real `${…}` code after escaping. if (node.type === "each" || node.type === "if") { @@ -1033,10 +1121,11 @@ function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive if (node.tag === "Async") { const source = attrValue(node.attrs, "source") ?? "data"; const retries = attrValue(node.attrs, "retries") ?? "2"; + const tags = attrValue(node.attrs, "tags") ?? source; const serverResolved = attrValue(node.attrs, "data-wrn-async-server") === "true"; - const asyncIndex = serverResolved ? loops.push("") - 1 : -1; + const branchElement = (name) => node.children.find((child) => child.type === "element" && child.tag === name); const branch = (name) => { - const element = node.children.find((child) => child.type === "element" && child.tag === name); + const element = branchElement(name); return (element?.children ?? []) .map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive)) .join(""); @@ -1044,16 +1133,26 @@ function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive const loading = branch("Loading"); const success = branch("Success"); const error = branch("Error"); + const successElement = branchElement("Success"); + const errorElement = branchElement("Error"); + const identifier = (value, fallback) => value && isSafeGeneratedIdentifier(value) ? value : fallback; + const successAlias = identifier(successElement ? attrValue(successElement.attrs, "data") : undefined, identifier(source, "data")); + const errorAlias = identifier(errorElement ? attrValue(errorElement.attrs, "error") : undefined, "error"); + const nested = (value) => value.replace(/\\/g, "\\\\").replace(/`/g, "\\`"); + const scoped = (value, alias, expression) => { + const index = loops.push(`\${(() => { const ${alias} = ${expression}; return \`${nested(value)}\`; })()}`) - 1; + return `\x00WRNEACH${index}\x00`; + }; + const successTemplate = scoped(success, successAlias, `(ctx[${JSON.stringify(source)}] ?? {})`); + const errorTemplate = scoped(error, errorAlias, `{ message: "" }`); let initial = loading; if (serverResolved) { - const nested = (value) => value.replace(/\\/g, "\\\\").replace(/`/g, "\\`"); - const sourcePattern = source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const sourcePattern = successAlias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const serverSuccess = success.replace(new RegExp(`\\{\\s*(${sourcePattern}(?:\\.[A-Za-z_$][\\w$]*)*)\\s*\\}`, "g"), (_whole, expression) => `\${__wrnexusEscapeHtml(${expression})}`); - loops[asyncIndex] = - `\${ctx[${JSON.stringify(source)}] !== undefined ? \`${nested(serverSuccess)}\` : \`${nested(loading)}\`}`; - initial = `\x00WRNEACH${asyncIndex}\x00`; + const index = loops.push(`\${ctx[${JSON.stringify(source)}] !== undefined ? (() => { const ${successAlias} = ctx[${JSON.stringify(source)}]; return \`${nested(serverSuccess)}\`; })() : \`${nested(loading)}\`}`) - 1; + initial = `\x00WRNEACH${index}\x00`; } - return `
${initial}
`; + return `
${initial}
`; } if (node.tag === "KeepAlive") { const key = attrValue(node.attrs, "key") ?? "default"; @@ -1371,11 +1470,115 @@ function generateSsrStateAliases(stateNames) { } return `const { ${names.join(", ")} } = __state;\n`; } +function orderPageStates(entries) { + if (entries.length < 2) + return entries; + const byName = new Map(entries.map((entry) => [entry.name, entry])); + const visiting = new Set(); + const visited = new Set(); + const ordered = []; + const visit = (name) => { + if (visited.has(name)) + return; + if (visiting.has(name)) { + throw new Error(`WRN-STATE-CYCLE: state value '${name}' has a dependency cycle.`); + } + const entry = byName.get(name); + if (!entry) + return; + visiting.add(name); + for (const dependency of byName.keys()) { + if (dependency !== name && + new RegExp(`\\b${dependency.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(entry.expr)) { + visit(dependency); + } + } + visiting.delete(name); + visited.add(name); + ordered.push(entry); + }; + for (const entry of entries) + visit(entry.name); + return ordered; +} +function generateSsrStateInitializer(entries) { + const ordered = orderPageStates(entries); + const declarations = ordered + .filter((entry) => isSafeGeneratedIdentifier(entry.name)) + .map((entry) => `const ${entry.name} = (() => { try { return (${entry.expr}); } catch { return undefined; } })();`) + .join(" "); + const values = entries + .map((entry) => { + if (isSafeGeneratedIdentifier(entry.name)) { + return `${JSON.stringify(entry.name)}: ${entry.name}`; + } + return `${JSON.stringify(entry.name)}: (() => { try { return (${entry.expr}); } catch { return undefined; } })()`; + }) + .join(", "); + return `(() => { ${declarations} return { ${values} }; })()`; +} +function orderPageComputed(entries) { + if (entries.length < 2) + return entries; + const byName = new Map(entries.map((entry) => [entry.name, entry])); + const visiting = new Set(); + const visited = new Set(); + const ordered = []; + const visit = (name) => { + if (visited.has(name)) + return; + if (visiting.has(name)) { + throw new Error(`WRN-COMPUTED-CYCLE: computed value '${name}' has a dependency cycle.`); + } + const entry = byName.get(name); + if (!entry) + return; + visiting.add(name); + for (const dependency of byName.keys()) { + if (dependency !== name && + new RegExp(`\\b${dependency.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(entry.expr)) { + visit(dependency); + } + } + visiting.delete(name); + visited.add(name); + ordered.push(entry); + }; + for (const entry of entries) + visit(entry.name); + return ordered; +} +function generateSsrComputedAliases(entries) { + return orderPageComputed(entries) + .filter((entry) => isSafeGeneratedIdentifier(entry.name)) + .map((entry) => `const ${entry.name} = (() => { try { return (${entry.expr}); } catch { return undefined; } })();`) + .join("\n"); +} +function runtimeComputedNames(states, computed, runtimeRoots = []) { + const entries = [...states, ...computed]; + const runtime = new Set([ + ...runtimeRoots, + ...entries.filter((entry) => /\bctx\b/.test(entry.expr)).map((entry) => entry.name), + ]); + let changed = true; + while (changed) { + changed = false; + for (const entry of entries) { + if (runtime.has(entry.name)) + continue; + if ([...runtime].some((name) => new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(entry.expr))) { + runtime.add(entry.name); + changed = true; + } + } + } + return runtime; +} function hydrationAttribute(ast) { const strategy = ["static", "server"].includes(ast.renderMode ?? "") ? "none" : (ast.hydrate ?? "load"); - const hasBrowserModule = ast.runtimeFunctions.some((fn) => ["legacy", "client", "shared"].includes(fn.runtime)); + const hasBrowserModule = (0, client_codegen_ts_1.browserModuleRequired)(ast); const moduleAttribute = hasBrowserModule ? ' data-wrn-client-module="__WRNEXUS_CLIENT_MODULE__"' : ""; @@ -1506,9 +1709,11 @@ function generate(ast) { out.push(`export const __wrnexusNavigation = ${JSON.stringify(ast.navigation, null, 2)};`); } // --- View -> default page component --- + const orderedStates = orderPageStates(ast.states); const browserStates = ast.states.filter((state) => state.runtime !== "server"); - const seedScope = evalStateSeeds(ast.states); - for (const entry of ast.computed) { + const seedScope = evalStateSeeds(orderedStates); + const orderedComputed = orderPageComputed(ast.computed); + for (const entry of orderedComputed) { try { seedScope[entry.name] = new Function("with(this){return (" + entry.expr + ");}").call(seedScope); } @@ -1520,7 +1725,14 @@ function generate(ast) { ...browserStates.map((entry) => entry.name), ...ast.computed.map((entry) => entry.name), ]; - const runtimeStateNames = new Set(ast.states.filter((entry) => /\bctx\b/.test(entry.expr)).map((entry) => entry.name)); + const storeBindings = importedStoreBindings(ast); + const runtimeRoots = [ + ...storeBindings.map((entry) => entry.local), + ...ast.loads + .filter((load) => load.mode === "server" && !load.deferred && load.name) + .map((load) => load.name), + ]; + const runtimeStateNames = runtimeComputedNames(orderedStates, orderedComputed, runtimeRoots); const reactive = reactiveNames.length > 0 ? { stateNames: new Set(reactiveNames), runtimeStateNames, scope: seedScope } : null; @@ -1564,9 +1776,7 @@ function generate(ast) { const shellHtml = html.replace(/]*>[\s\S]*?<\/wrn-dynamic-region>/gi, ''); staticShellBody = templateEscape(shellHtml); } - const dynamicStateScope = ast.states - .map((state) => `${JSON.stringify(state.name)}: (() => { try { return (${state.expr}); } catch { return undefined; } })()`) - .join(", "); + const dynamicStateInitializer = generateSsrStateInitializer(orderedStates); const stateType = ast.states.length > 0 ? `{ ${ast.states .map((state) => `${JSON.stringify(state.name)}: ${state.valueType ?? "unknown"}`) @@ -1574,7 +1784,7 @@ function generate(ast) { : "Record"; const hydrationStateNames = JSON.stringify(browserStates.map((state) => state.name)); const ssrStateAliases = generateSsrStateAliases(ast.states.map((state) => state.name)); - const storeBindings = importedStoreBindings(ast); + const ssrComputedAliases = generateSsrComputedAliases(orderedComputed); const storeDeclarations = storeBindings .map((entry) => ` const ${entry.local} = await ctx.__wrnexusUseStore(${entry.internal});`) .join("\n"); @@ -1582,12 +1792,16 @@ function generate(ast) { .filter((load) => load.mode === "server" && !load.deferred && load.name) .map((load) => ` const ${load.name} = ctx[${JSON.stringify(load.name)}];`) .join("\n"); - loops.forEach((code, idx) => { - body = body.replace(`\x00WRNEACH${idx}\x00`, () => code); + // Inner dynamic expressions are registered before the wrapper that scopes + // them (for example an Async branch alias). Resolve from the outside in so a + // wrapper sentinel is expanded before its nested sentinels are visited. + for (let idx = loops.length - 1; idx >= 0; idx--) { + const code = loops[idx]; + body = body.replaceAll(`\x00WRNEACH${idx}\x00`, code); if (staticShellBody?.includes(`\x00WRNEACH${idx}\x00`)) { - staticShellBody = staticShellBody.replace(`\x00WRNEACH${idx}\x00`, () => code); + staticShellBody = staticShellBody.replaceAll(`\x00WRNEACH${idx}\x00`, code); } - }); + } // Server loops iterate raw SSR data. Declare a named const for every `ssr` data // binding a loop references, so `{#each as …}` can iterate the real value. const loopConsts = []; @@ -1601,9 +1815,7 @@ function generate(ast) { loopConsts.push(` const ${name} = __wrnexusEvalData(await __wrnexusCallApi(${JSON.stringify(binding.path)}, ${JSON.stringify(binding.method)}, ctx), ${JSON.stringify(binding.body)}, ${JSON.stringify(binding.helpers)}, ctx);`); } } - const needsSsrRuntime = ssrBindings.length > 0 || - loops.length > 0 || - ast.states.some((state) => /\bctx\b/.test(state.expr)); + const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0 || runtimeStateNames.size > 0; if (needsSsrRuntime) { out.push(ssrRuntimeSource()); out.push(`const __wrnexusSsrBindings = ${JSON.stringify(ssrBindings, null, 2)};`); @@ -1612,16 +1824,18 @@ function generate(ast) { ${storeDeclarations} ${serverLoadAliases} ${decls} - const __state: ${stateType} = { ${dynamicStateScope} }; + const __state: ${stateType} = ${dynamicStateInitializer}; ${ssrStateAliases} + ${ssrComputedAliases} const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, (__state as any)[key]])); const __scopeValue = Object.entries(__hydrationState) .map(([key, value]) => { - const encoded = - typeof value === "number" || typeof value === "boolean" - ? String(value) - : JSON.stringify(value == null ? "" : String(value)); - + let encoded: string; + try { + encoded = value === undefined ? "undefined" : JSON.stringify(value); + } catch { + encoded = JSON.stringify(String(value)); + } return key + ": " + encoded; }) .join(", ") @@ -1642,16 +1856,18 @@ function generate(ast) { out.push(`export default ${storeBindings.length > 0 ? "async " : ""}function ${ast.name}(ctx: any) { ${storeDeclarations} ${serverLoadAliases} - const __state: ${stateType} = { ${dynamicStateScope} }; + const __state: ${stateType} = ${dynamicStateInitializer}; ${ssrStateAliases} + ${ssrComputedAliases} const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, (__state as any)[key]])); const __scopeValue = Object.entries(__hydrationState) .map(([key, value]) => { - const encoded = - typeof value === "number" || typeof value === "boolean" - ? String(value) - : JSON.stringify(value == null ? "" : String(value)); - + let encoded: string; + try { + encoded = value === undefined ? "undefined" : JSON.stringify(value); + } catch { + encoded = JSON.stringify(String(value)); + } return key + ": " + encoded; }) .join(", ") @@ -1671,14 +1887,18 @@ function generate(ast) { ${storeDeclarations} ${serverLoadAliases} ${loopConsts.length > 0 ? loopConsts.join("\n") : ""} - const __state: ${stateType} = { ${dynamicStateScope} }; + const __state: ${stateType} = ${dynamicStateInitializer}; ${ssrStateAliases} + ${ssrComputedAliases} const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, (__state as any)[key]])); const __scopeValue = Object.entries(__hydrationState) .map(([key, value]) => { - const encoded = typeof value === "number" || typeof value === "boolean" - ? String(value) - : JSON.stringify(value == null ? "" : String(value)); + let encoded: string; + try { + encoded = value === undefined ? "undefined" : JSON.stringify(value); + } catch { + encoded = JSON.stringify(String(value)); + } return key + ": " + encoded; }) .join(", ") @@ -1845,6 +2065,13 @@ const JS_RESERVED = new Set([ "enum", "with", "debugger", + "implements", + "interface", + "package", + "private", + "protected", + "public", + "static", ]); /** A JS reference for a prop/state name (reserved words get a `__p_` prefix). */ function safeRef(name) { @@ -2739,13 +2966,28 @@ function candidates(path) { } function resolveWrnImport(declaration, importer, options) { const source = declaration.source; - if (!source.startsWith(".") && !source.startsWith("@/")) + const aliases = { + "@": "./app", + ...(options.aliases ?? {}), + }; + const alias = Object.keys(aliases) + .filter((key) => key.length > 0 && (source === key || source.startsWith(`${key}/`))) + .sort((left, right) => right.length - left.length)[0]; + if (!source.startsWith(".") && !alias) return { declaration, resolved: source }; - const aliasRoot = options.aliases?.["@"] ?? "./app"; - const base = source.startsWith("@/") - ? (0, node_path_1.resolve)(options.appRoot, aliasRoot, source.slice(2)) + const base = alias + ? (0, node_path_1.resolve)(options.appRoot, aliases[alias], source === alias ? "" : source.slice(alias.length + 1)) : (0, node_path_1.resolve)((0, node_path_1.dirname)(importer), source); - const found = candidates(base).find(node_fs_1.existsSync); + const found = candidates(base).find((candidate) => { + if (!(0, node_fs_1.existsSync)(candidate)) + return false; + try { + return (0, node_fs_1.statSync)(candidate).isFile(); + } + catch { + return false; + } + }); if (found) return { declaration, resolved: (0, node_fs_1.realpathSync)(found) }; const severity = (options.mode ?? "compatible") === "explicit" ? "error" : "warning"; diff --git a/editors/vscode/src/extension.bundle.cjs b/editors/vscode/src/extension.bundle.cjs index fb74b235..f40a228a 100644 --- a/editors/vscode/src/extension.bundle.cjs +++ b/editors/vscode/src/extension.bundle.cjs @@ -2348,7 +2348,7 @@ var require_connection = __commonJS((exports2) => { } if (responseMessage.id === null) { if (responseMessage.error) { - logger.error(`Received response message without id: Error is: + logger.error(`Received response message without id: Error is: ${JSON.stringify(responseMessage.error, undefined, 4)}`); } else { logger.error(`Received response message without id. No further error information provided.`); @@ -23058,6 +23058,7 @@ ${(0, codegen_ts_1.generate)(ast)}`, }, "packages/compiler/src/client-codegen.ts": function(module3, exports3, require2, __filename2, __dirname2) { Object.defineProperty(exports3, "__esModule", { value: true }); + exports3.browserModuleRequired = browserModuleRequired; exports3.generateBrowserModule = generateBrowserModule; const syntax_1 = require2("@wrnexus/syntax"); const RESERVED_BINDINGS = new Set([ @@ -23121,6 +23122,84 @@ ${(0, codegen_ts_1.generate)(ast)}`, function safeIdentifier(name) { return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) && !RESERVED_BINDINGS.has(name); } + function identifierReferenced(source, name) { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`(?:^|[^A-Za-z0-9_$])${escaped}(?![A-Za-z0-9_$])`).test(source); + } + function viewReferenceSource(nodes) { + return nodes.flatMap((node) => { + if (node.type === "text") + return [node.value]; + if (node.type === "each") { + return [ + node.list, + node.key ?? "", + ...viewReferenceSource(node.body), + ...viewReferenceSource(node.empty) + ]; + } + if (node.type === "if") { + return node.branches.flatMap((branch) => [ + branch.cond ?? "", + ...viewReferenceSource(branch.body) + ]); + } + return [...node.attrs.map((attr) => attr.value), ...viewReferenceSource(node.children)]; + }); + } + function browserReferenceSource(ast, functions) { + return [ + ...functions.flatMap((fn) => [fn.source, fn.body]), + ...ast.computed.map((entry) => entry.expr), + ...ast.effects, + ast.lifecycle.mount ?? "", + ast.lifecycle.update ?? "", + ast.lifecycle.unmount ?? "", + ...viewReferenceSource(ast.view) + ].join(` +`); + } + function renderSelectedImport(entry, source) { + if (entry.typeOnly) + return null; + const isStore = entry.source.endsWith(".wrn") && /(?:^|\/)stores?\//.test(entry.source); + if (entry.source.endsWith(".wrn") && !isStore) + return null; + const defaultImport = entry.defaultImport && (isStore || identifierReferenced(source, entry.defaultImport)) ? entry.defaultImport : undefined; + const namespaceImport = entry.namespaceImport && (isStore || identifierReferenced(source, entry.namespaceImport)) ? entry.namespaceImport : undefined; + const namedImports = entry.namedImports.filter((item) => !item.typeOnly && (isStore || identifierReferenced(source, item.local))); + const bindings = [ + ...defaultImport ? [defaultImport] : [], + ...namespaceImport ? [namespaceImport] : [], + ...namedImports.map((item) => item.local) + ].filter(safeIdentifier); + const hasBindings = Boolean(defaultImport || namespaceImport || namedImports.length); + const sideEffectOnly = !entry.defaultImport && !entry.namespaceImport && entry.namedImports.length === 0; + if (!hasBindings && !sideEffectOnly) + return null; + if (sideEffectOnly) + return { code: entry.raw, bindings: [] }; + const clauses = []; + if (defaultImport) + clauses.push(defaultImport); + if (namespaceImport) + clauses.push(`* as ${namespaceImport}`); + if (namedImports.length) { + clauses.push(`{ ${namedImports.map((item) => item.imported === item.local ? item.imported : `${item.imported} as ${item.local}`).join(", ")} }`); + } + return { + code: `import ${clauses.join(", ")} from ${JSON.stringify(entry.source)};`, + bindings + }; + } + function selectedBrowserImports(ast, functions) { + const referenceSource = browserReferenceSource(ast, functions); + return ast.structuredImports.map((entry) => renderSelectedImport(entry, referenceSource)).filter((entry) => entry !== null); + } + function browserModuleRequired(ast) { + const functions = ast.runtimeFunctions.filter((fn) => ["legacy", "client", "shared"].includes(fn.runtime)); + return functions.length > 0 || selectedBrowserImports(ast, functions).length > 0; + } function functionEntry(ast, fn, availableFunctions) { const parameterNames = new Set(fn.parameters.map((parameter) => parameter.name)); const stateNames = ast.states.filter((state) => state.runtime !== "server" && safeIdentifier(state.name) && !RUNTIME_BINDINGS.has(state.name) && !parameterNames.has(state.name)).map((state) => state.name); @@ -23182,14 +23261,10 @@ ${(0, codegen_ts_1.generate)(ast)}`, const functions = ast.runtimeFunctions.filter((fn) => ["legacy", "client", "shared"].includes(fn.runtime)); const functionNames = functions.map((fn) => fn.name); const state = ast.states.filter((entry) => entry.runtime !== "server").map((entry) => entry.name); - const storeImports = ast.structuredImports.filter((entry) => !entry.typeOnly && entry.source.endsWith(".wrn") && /(?:^|\/)stores?\//.test(entry.source)); - const imports = ast.structuredImports.filter((entry) => !entry.typeOnly).filter((entry) => !entry.source.endsWith(".wrn") || /(?:^|\/)stores?\//.test(entry.source)).map((entry) => entry.raw).join(` + const selectedImports = selectedBrowserImports(ast, functions); + const imports = selectedImports.map((entry) => entry.code).join(` `); - const importedBindings = storeImports.flatMap((entry) => [ - ...entry.defaultImport ? [entry.defaultImport] : [], - ...entry.namespaceImport ? [entry.namespaceImport] : [], - ...entry.namedImports.map((item) => item.local) - ]).filter(safeIdentifier); + const importedBindings = [...new Set(selectedImports.flatMap((entry) => entry.bindings))]; return `// generated WRNexusJS browser module for ${ast.name} ${imports} export const __wrnexusClientFunctions = { @@ -23220,6 +23295,7 @@ export function bindClientScope(context) { const syntax_1 = require2("@wrnexus/syntax"); const store_codegen_ts_1 = require2("./store-codegen.js"); const analysis_ts_1 = require2("./analysis.js"); + const client_codegen_ts_1 = require2("./client-codegen.js"); function isComponentTag(tag) { return /^[A-Z][A-Za-z0-9_$]*$/.test(tag); } @@ -23380,7 +23456,7 @@ export function bindClientScope(context) { } return scope; } - function substituteReactiveText(raw, reactive) { + function substituteReactiveText(raw, reactive, dynamicExpressions) { const text = substituteTMarkers(raw); if (!reactive || reactive.stateNames.size === 0) return text; @@ -23388,6 +23464,11 @@ export function bindClientScope(context) { const expr = inner.trim(); if (expr.startsWith("t:") || !exprRefsState(expr, reactive.stateNames)) return whole; + if (exprRefsState(expr, reactive.runtimeStateNames) && dynamicExpressions) { + dynamicExpressions.push(`\${__wrnexusEscapeHtml(${expr})}`); + const sentinel = `\x00WRNEACH${dynamicExpressions.length - 1}\x00`; + return `${sentinel}`; + } let value; try { value = new Function("with(this){return (" + expr + ");}").call(reactive.scope); @@ -23415,7 +23496,10 @@ export function bindClientScope(context) { } return out + escLit(raw.slice(last)); } - function bakeLoopAttr(raw) { + function bakeLoopAttr(raw, typed = false) { + const wholeExpression = wholeAttributeExpression(raw); + if (typed && wholeExpression) + return "${__wrnexusPropAttr(" + wholeExpression + ")}"; if (!raw.includes("{")) return escLit(attrEscape(raw)); let out = ""; @@ -23445,7 +23529,7 @@ export function bindClientScope(context) { if (attr.boolean) { return escLit(` ${name}`); } - return escLit(` ${name}="`) + bakeLoopAttr(attr.value) + escLit(`"`); + return escLit(` ${name}="`) + bakeLoopAttr(attr.value, componentTag) + escLit(`"`); }).join(""); const inner = node.children.map(renderLoopBody).join(""); if (node.tag === "Static") @@ -23514,7 +23598,7 @@ export function bindClientScope(context) { } function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive = null) { if (node.type === "text") - return substituteReactiveText(node.value, reactive); + return substituteReactiveText(node.value, reactive, loops); if (node.type === "each" || node.type === "if") { loops.push(node.type === "each" ? compileEachExpr(node) : compileIfExpr(node)); return `\x00WRNEACH${loops.length - 1}\x00`; @@ -23535,24 +23619,36 @@ export function bindClientScope(context) { if (node.tag === "Async") { const source = attrValue(node.attrs, "source") ?? "data"; const retries = attrValue(node.attrs, "retries") ?? "2"; + const tags = attrValue(node.attrs, "tags") ?? source; const serverResolved = attrValue(node.attrs, "data-wrn-async-server") === "true"; - const asyncIndex = serverResolved ? loops.push("") - 1 : -1; + const branchElement = (name) => node.children.find((child) => child.type === "element" && child.tag === name); const branch = (name) => { - const element = node.children.find((child) => child.type === "element" && child.tag === name); + const element = branchElement(name); return (element?.children ?? []).map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive)).join(""); }; const loading = branch("Loading"); const success = branch("Success"); const error = branch("Error"); + const successElement = branchElement("Success"); + const errorElement = branchElement("Error"); + const identifier = (value, fallback) => value && isSafeGeneratedIdentifier(value) ? value : fallback; + const successAlias = identifier(successElement ? attrValue(successElement.attrs, "data") : undefined, identifier(source, "data")); + const errorAlias = identifier(errorElement ? attrValue(errorElement.attrs, "error") : undefined, "error"); + const nested = (value) => value.replace(/\\/g, "\\\\").replace(/`/g, "\\`"); + const scoped = (value, alias, expression) => { + const index = loops.push(`\${(() => { const ${alias} = ${expression}; return \`${nested(value)}\`; })()}`) - 1; + return `\x00WRNEACH${index}\x00`; + }; + const successTemplate = scoped(success, successAlias, `(ctx[${JSON.stringify(source)}] ?? {})`); + const errorTemplate = scoped(error, errorAlias, `{ message: "" }`); let initial = loading; if (serverResolved) { - const nested = (value) => value.replace(/\\/g, "\\\\").replace(/`/g, "\\`"); - const sourcePattern = source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const sourcePattern = successAlias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const serverSuccess = success.replace(new RegExp(`\\{\\s*(${sourcePattern}(?:\\.[A-Za-z_$][\\w$]*)*)\\s*\\}`, "g"), (_whole, expression) => `\${__wrnexusEscapeHtml(${expression})}`); - loops[asyncIndex] = `\${ctx[${JSON.stringify(source)}] !== undefined ? \`${nested(serverSuccess)}\` : \`${nested(loading)}\`}`; - initial = `\x00WRNEACH${asyncIndex}\x00`; + const index = loops.push(`\${ctx[${JSON.stringify(source)}] !== undefined ? (() => { const ${successAlias} = ctx[${JSON.stringify(source)}]; return \`${nested(serverSuccess)}\`; })() : \`${nested(loading)}\`}`) - 1; + initial = `\x00WRNEACH${index}\x00`; } - return `
${initial}
`; + return `
${initial}
`; } if (node.tag === "KeepAlive") { const key = attrValue(node.attrs, "key") ?? "default"; @@ -23836,9 +23932,104 @@ ${css} return `const { ${names.join(", ")} } = __state; `; } + function orderPageStates(entries) { + if (entries.length < 2) + return entries; + const byName = new Map(entries.map((entry) => [entry.name, entry])); + const visiting = new Set; + const visited = new Set; + const ordered = []; + const visit = (name) => { + if (visited.has(name)) + return; + if (visiting.has(name)) { + throw new Error(`WRN-STATE-CYCLE: state value '${name}' has a dependency cycle.`); + } + const entry = byName.get(name); + if (!entry) + return; + visiting.add(name); + for (const dependency of byName.keys()) { + if (dependency !== name && new RegExp(`\\b${dependency.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(entry.expr)) { + visit(dependency); + } + } + visiting.delete(name); + visited.add(name); + ordered.push(entry); + }; + for (const entry of entries) + visit(entry.name); + return ordered; + } + function generateSsrStateInitializer(entries) { + const ordered = orderPageStates(entries); + const declarations = ordered.filter((entry) => isSafeGeneratedIdentifier(entry.name)).map((entry) => `const ${entry.name} = (() => { try { return (${entry.expr}); } catch { return undefined; } })();`).join(" "); + const values = entries.map((entry) => { + if (isSafeGeneratedIdentifier(entry.name)) { + return `${JSON.stringify(entry.name)}: ${entry.name}`; + } + return `${JSON.stringify(entry.name)}: (() => { try { return (${entry.expr}); } catch { return undefined; } })()`; + }).join(", "); + return `(() => { ${declarations} return { ${values} }; })()`; + } + function orderPageComputed(entries) { + if (entries.length < 2) + return entries; + const byName = new Map(entries.map((entry) => [entry.name, entry])); + const visiting = new Set; + const visited = new Set; + const ordered = []; + const visit = (name) => { + if (visited.has(name)) + return; + if (visiting.has(name)) { + throw new Error(`WRN-COMPUTED-CYCLE: computed value '${name}' has a dependency cycle.`); + } + const entry = byName.get(name); + if (!entry) + return; + visiting.add(name); + for (const dependency of byName.keys()) { + if (dependency !== name && new RegExp(`\\b${dependency.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(entry.expr)) { + visit(dependency); + } + } + visiting.delete(name); + visited.add(name); + ordered.push(entry); + }; + for (const entry of entries) + visit(entry.name); + return ordered; + } + function generateSsrComputedAliases(entries) { + return orderPageComputed(entries).filter((entry) => isSafeGeneratedIdentifier(entry.name)).map((entry) => `const ${entry.name} = (() => { try { return (${entry.expr}); } catch { return undefined; } })();`).join(` +`); + } + function runtimeComputedNames(states, computed, runtimeRoots = []) { + const entries = [...states, ...computed]; + const runtime = new Set([ + ...runtimeRoots, + ...entries.filter((entry) => /\bctx\b/.test(entry.expr)).map((entry) => entry.name) + ]); + let changed = true; + while (changed) { + changed = false; + for (const entry of entries) { + if (runtime.has(entry.name)) + continue; + if ([...runtime].some((name) => new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(entry.expr))) { + runtime.add(entry.name); + changed = true; + } + } + } + return runtime; + } function hydrationAttribute(ast) { const strategy = ["static", "server"].includes(ast.renderMode ?? "") ? "none" : ast.hydrate ?? "load"; - const hasBrowserModule = ast.runtimeFunctions.some((fn) => ["legacy", "client", "shared"].includes(fn.runtime)); + const hasBrowserModule = (0, client_codegen_ts_1.browserModuleRequired)(ast); const moduleAttribute = hasBrowserModule ? ' data-wrn-client-module="__WRNEXUS_CLIENT_MODULE__"' : ""; return ` data-wrn-hydration="${attrEscape(hydrationId(ast))}" data-wrn-hydrate="${attrEscape(strategy)}" data-wrn-runtime="${attrEscape(ast.runtime ?? "universal")}"${moduleAttribute}`; } @@ -23960,9 +24151,11 @@ ${helpers}`); if (Object.keys(ast.navigation).length > 0) { out.push(`export const __wrnexusNavigation = ${JSON.stringify(ast.navigation, null, 2)};`); } + const orderedStates = orderPageStates(ast.states); const browserStates = ast.states.filter((state) => state.runtime !== "server"); - const seedScope = evalStateSeeds(ast.states); - for (const entry of ast.computed) { + const seedScope = evalStateSeeds(orderedStates); + const orderedComputed = orderPageComputed(ast.computed); + for (const entry of orderedComputed) { try { seedScope[entry.name] = new Function("with(this){return (" + entry.expr + ");}").call(seedScope); } catch { @@ -23973,7 +24166,12 @@ ${helpers}`); ...browserStates.map((entry) => entry.name), ...ast.computed.map((entry) => entry.name) ]; - const runtimeStateNames = new Set(ast.states.filter((entry) => /\bctx\b/.test(entry.expr)).map((entry) => entry.name)); + const storeBindings = importedStoreBindings(ast); + const runtimeRoots = [ + ...storeBindings.map((entry) => entry.local), + ...ast.loads.filter((load) => load.mode === "server" && !load.deferred && load.name).map((load) => load.name) + ]; + const runtimeStateNames = runtimeComputedNames(orderedStates, orderedComputed, runtimeRoots); const reactive = reactiveNames.length > 0 ? { stateNames: new Set(reactiveNames), runtimeStateNames, scope: seedScope } : null; const loops = []; let html = ast.view.map((node) => renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive)).join(""); @@ -24007,21 +24205,22 @@ ${helpers}`); const shellHtml = html.replace(/]*>[\s\S]*?<\/wrn-dynamic-region>/gi, ''); staticShellBody = templateEscape(shellHtml); } - const dynamicStateScope = ast.states.map((state) => `${JSON.stringify(state.name)}: (() => { try { return (${state.expr}); } catch { return undefined; } })()`).join(", "); + const dynamicStateInitializer = generateSsrStateInitializer(orderedStates); const stateType = ast.states.length > 0 ? `{ ${ast.states.map((state) => `${JSON.stringify(state.name)}: ${state.valueType ?? "unknown"}`).join("; ")} }` : "Record"; const hydrationStateNames = JSON.stringify(browserStates.map((state) => state.name)); const ssrStateAliases = generateSsrStateAliases(ast.states.map((state) => state.name)); - const storeBindings = importedStoreBindings(ast); + const ssrComputedAliases = generateSsrComputedAliases(orderedComputed); const storeDeclarations = storeBindings.map((entry) => ` const ${entry.local} = await ctx.__wrnexusUseStore(${entry.internal});`).join(` `); const serverLoadAliases = ast.loads.filter((load) => load.mode === "server" && !load.deferred && load.name).map((load) => ` const ${load.name} = ctx[${JSON.stringify(load.name)}];`).join(` `); - loops.forEach((code, idx) => { - body = body.replace(`\x00WRNEACH${idx}\x00`, () => code); + for (let idx = loops.length - 1;idx >= 0; idx--) { + const code = loops[idx]; + body = body.replaceAll(`\x00WRNEACH${idx}\x00`, code); if (staticShellBody?.includes(`\x00WRNEACH${idx}\x00`)) { - staticShellBody = staticShellBody.replace(`\x00WRNEACH${idx}\x00`, () => code); + staticShellBody = staticShellBody.replaceAll(`\x00WRNEACH${idx}\x00`, code); } - }); + } const loopConsts = []; if (loops.length > 0) { const lists = collectControlExprs(ast.view); @@ -24033,7 +24232,7 @@ ${helpers}`); loopConsts.push(` const ${name} = __wrnexusEvalData(await __wrnexusCallApi(${JSON.stringify(binding.path)}, ${JSON.stringify(binding.method)}, ctx), ${JSON.stringify(binding.body)}, ${JSON.stringify(binding.helpers)}, ctx);`); } } - const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0 || ast.states.some((state) => /\bctx\b/.test(state.expr)); + const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0 || runtimeStateNames.size > 0; if (needsSsrRuntime) { out.push(ssrRuntimeSource()); out.push(`const __wrnexusSsrBindings = ${JSON.stringify(ssrBindings, null, 2)};`); @@ -24044,16 +24243,18 @@ ${helpers}`); ${storeDeclarations} ${serverLoadAliases} ${decls} - const __state: ${stateType} = { ${dynamicStateScope} }; + const __state: ${stateType} = ${dynamicStateInitializer}; ${ssrStateAliases} + ${ssrComputedAliases} const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, (__state as any)[key]])); const __scopeValue = Object.entries(__hydrationState) .map(([key, value]) => { - const encoded = - typeof value === "number" || typeof value === "boolean" - ? String(value) - : JSON.stringify(value == null ? "" : String(value)); - + let encoded: string; + try { + encoded = value === undefined ? "undefined" : JSON.stringify(value); + } catch { + encoded = JSON.stringify(String(value)); + } return key + ": " + encoded; }) .join(", ") @@ -24073,16 +24274,18 @@ ${helpers}`); out.push(`export default ${storeBindings.length > 0 ? "async " : ""}function ${ast.name}(ctx: any) { ${storeDeclarations} ${serverLoadAliases} - const __state: ${stateType} = { ${dynamicStateScope} }; + const __state: ${stateType} = ${dynamicStateInitializer}; ${ssrStateAliases} + ${ssrComputedAliases} const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, (__state as any)[key]])); const __scopeValue = Object.entries(__hydrationState) .map(([key, value]) => { - const encoded = - typeof value === "number" || typeof value === "boolean" - ? String(value) - : JSON.stringify(value == null ? "" : String(value)); - + let encoded: string; + try { + encoded = value === undefined ? "undefined" : JSON.stringify(value); + } catch { + encoded = JSON.stringify(String(value)); + } return key + ": " + encoded; }) .join(", ") @@ -24103,14 +24306,18 @@ ${helpers}`); ${serverLoadAliases} ${loopConsts.length > 0 ? loopConsts.join(` `) : ""} - const __state: ${stateType} = { ${dynamicStateScope} }; + const __state: ${stateType} = ${dynamicStateInitializer}; ${ssrStateAliases} + ${ssrComputedAliases} const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, (__state as any)[key]])); const __scopeValue = Object.entries(__hydrationState) .map(([key, value]) => { - const encoded = typeof value === "number" || typeof value === "boolean" - ? String(value) - : JSON.stringify(value == null ? "" : String(value)); + let encoded: string; + try { + encoded = value === undefined ? "undefined" : JSON.stringify(value); + } catch { + encoded = JSON.stringify(String(value)); + } return key + ": " + encoded; }) .join(", ") @@ -24270,7 +24477,14 @@ ${handlers.join(` "await", "enum", "with", - "debugger" + "debugger", + "implements", + "interface", + "package", + "private", + "protected", + "public", + "static" ]); function safeRef(name) { return JS_RESERVED.has(name) ? `__p_${name}` : name; @@ -25044,11 +25258,23 @@ function __wireRaw(v: any): string { } function resolveWrnImport(declaration, importer, options) { const source = declaration.source; - if (!source.startsWith(".") && !source.startsWith("@/")) + const aliases = { + "@": "./app", + ...options.aliases ?? {} + }; + const alias = Object.keys(aliases).filter((key) => key.length > 0 && (source === key || source.startsWith(`${key}/`))).sort((left, right) => right.length - left.length)[0]; + if (!source.startsWith(".") && !alias) return { declaration, resolved: source }; - const aliasRoot = options.aliases?.["@"] ?? "./app"; - const base = source.startsWith("@/") ? (0, node_path_1.resolve)(options.appRoot, aliasRoot, source.slice(2)) : (0, node_path_1.resolve)((0, node_path_1.dirname)(importer), source); - const found = candidates(base).find(node_fs_1.existsSync); + const base = alias ? (0, node_path_1.resolve)(options.appRoot, aliases[alias], source === alias ? "" : source.slice(alias.length + 1)) : (0, node_path_1.resolve)((0, node_path_1.dirname)(importer), source); + const found = candidates(base).find((candidate) => { + if (!(0, node_fs_1.existsSync)(candidate)) + return false; + try { + return (0, node_fs_1.statSync)(candidate).isFile(); + } catch { + return false; + } + }); if (found) return { declaration, resolved: (0, node_fs_1.realpathSync)(found) }; const severity = (options.mode ?? "compatible") === "explicit" ? "error" : "warning"; diff --git a/editors/vscode/src/language-server.cjs b/editors/vscode/src/language-server.cjs index bb70e4c0..05e152ba 100644 --- a/editors/vscode/src/language-server.cjs +++ b/editors/vscode/src/language-server.cjs @@ -40,12 +40,12 @@ var require_typescript = __commonJS((exports2, module2) => { Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 - + THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE, MERCHANTABLITY OR NON-INFRINGEMENT. - + See the Apache Version 2.0 License for specific language governing permissions and limitations under the License. ***************************************************************************** */ diff --git a/package.json b/package.json index 820418f8..f7c6a5fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wrnexus", - "version": "0.8.2", + "version": "0.8.3", "private": true, "type": "module", "description": "An SSR-first full-stack web framework with server-rendered reactive components. Bun-first, Node-friendly.", @@ -33,7 +33,7 @@ "lint:fix": "eslint . --fix", "format": "prettier . --write", "format:check": "prettier . --check", - "check": "bun run typecheck && bun run lint && bun run test && bun run format:check", + "check": "bun run typecheck && bun run lint && bun run check:component-imports && bun run test && bun run format:check", "verify:0.3": "node scripts/verify-0.3.mjs", "verify:0.4": "node scripts/verify-0.4.mjs", "validate:0.4": "node scripts/validate-0.4.mjs", @@ -51,7 +51,7 @@ "generate:ui-visual": "node scripts/check-ui-visual-contract.mjs --write", "sbom": "node scripts/generate-sbom.mjs", "benchmark:framework": "node --experimental-transform-types scripts/benchmark-framework.mjs", - "check:production": "bun run check:workspace && bun run check:public-api && bun run check:ui-visual && bun run validate:0.8 && bun run security:framework && bun run security:asvs && bun run check", + "check:production": "bun run check:workspace && bun run check:public-api && bun run check:ui-visual && bun run validate:0.8 && bun run security:framework && bun run security:asvs && bun run check:editor-compiler && bun run check:editor-language-server && bun run check:editor-extension && bun run check", "validate:staging": "node --experimental-transform-types scripts/test-package-integrity.mjs", "stage:packages": "bun run scripts/publish-packages.ts", "test:staged-consumers": "node scripts/test-staged-consumers.mjs", @@ -61,7 +61,8 @@ "validate:0.8": "node scripts/validate-0.8.mjs", "audit:packages": "node scripts/audit-package-kits.mjs", "generate:package-audit": "node scripts/audit-package-kits.mjs --write", - "test:package-kits": "node --experimental-transform-types scripts/test-package-kits.mjs" + "test:package-kits": "node --experimental-transform-types scripts/test-package-kits.mjs", + "check:component-imports": "node scripts/check-component-imports.mjs" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/packages/ai/package.json b/packages/ai/package.json index 278eb8d6..24b1fc74 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ai", - "version": "0.8.2", + "version": "0.8.3", "private": true, "type": "module", "description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.", diff --git a/packages/auth/components/AccountStatus.wrn b/packages/auth/components/AccountStatus.wrn index 09bf3135..e421802c 100644 --- a/packages/auth/components/AccountStatus.wrn +++ b/packages/auth/components/AccountStatus.wrn @@ -1,3 +1,5 @@ +import { Badge, Button, Card } from "@wrnexus/ui" + component AccountStatus { props { status: string = "active" diff --git a/packages/auth/components/AuthProviderButtons.wrn b/packages/auth/components/AuthProviderButtons.wrn index de0ac62c..f4acbb3a 100644 --- a/packages/auth/components/AuthProviderButtons.wrn +++ b/packages/auth/components/AuthProviderButtons.wrn @@ -1,3 +1,5 @@ +import { Button } from "@wrnexus/ui" + component AuthProviderButtons { outputs { select(payload: { provider: string; href: string }) diff --git a/packages/auth/components/AuthSecurityNotice.wrn b/packages/auth/components/AuthSecurityNotice.wrn index 76cf776f..cbe90d51 100644 --- a/packages/auth/components/AuthSecurityNotice.wrn +++ b/packages/auth/components/AuthSecurityNotice.wrn @@ -1,3 +1,5 @@ +import { Alert } from "@wrnexus/ui" + component AuthSecurityNotice { props { title: string = "Security notice" diff --git a/packages/auth/components/AuthShell.wrn b/packages/auth/components/AuthShell.wrn index 00442c5f..cf4be5e9 100644 --- a/packages/auth/components/AuthShell.wrn +++ b/packages/auth/components/AuthShell.wrn @@ -1,3 +1,5 @@ +import { Card } from "@wrnexus/ui" + component AuthShell { props { title: string = "Welcome" diff --git a/packages/auth/components/AuthenticatorSetup.wrn b/packages/auth/components/AuthenticatorSetup.wrn index 80d29cec..6d20379b 100644 --- a/packages/auth/components/AuthenticatorSetup.wrn +++ b/packages/auth/components/AuthenticatorSetup.wrn @@ -1,3 +1,5 @@ +import { Button, Input, PinInput } from "@wrnexus/ui" + component AuthenticatorSetup { props { setupAction = "/api/auth/totp/setup" diff --git a/packages/auth/components/DeviceSessions.wrn b/packages/auth/components/DeviceSessions.wrn index 7ff1373a..a93f6c2f 100644 --- a/packages/auth/components/DeviceSessions.wrn +++ b/packages/auth/components/DeviceSessions.wrn @@ -1,3 +1,5 @@ +import { Avatar, Badge, Button, Card } from "@wrnexus/ui" + component DeviceSessions { props { sessions: unknown[] = [] diff --git a/packages/auth/components/ForgotPassword.wrn b/packages/auth/components/ForgotPassword.wrn index bc36e7a9..9ccc16fe 100644 --- a/packages/auth/components/ForgotPassword.wrn +++ b/packages/auth/components/ForgotPassword.wrn @@ -1,3 +1,5 @@ +import { Button, Input } from "@wrnexus/ui" + component ForgotPassword { props { action = "/api/auth/password/request" diff --git a/packages/auth/components/ImpersonationBanner.wrn b/packages/auth/components/ImpersonationBanner.wrn index 0090287c..09fc3c8a 100644 --- a/packages/auth/components/ImpersonationBanner.wrn +++ b/packages/auth/components/ImpersonationBanner.wrn @@ -1,3 +1,5 @@ +import { Alert, Button } from "@wrnexus/ui" + component ImpersonationBanner { props { visible: boolean = true diff --git a/packages/auth/components/InvitationAccept.wrn b/packages/auth/components/InvitationAccept.wrn index 3b5ffe9e..de627c00 100644 --- a/packages/auth/components/InvitationAccept.wrn +++ b/packages/auth/components/InvitationAccept.wrn @@ -1,3 +1,5 @@ +import { Button, Input, StrongPassword } from "@wrnexus/ui" + component InvitationAccept { props { action = "/api/auth/invitations/accept" diff --git a/packages/auth/components/MagicLinkSignIn.wrn b/packages/auth/components/MagicLinkSignIn.wrn index 1fde0fe5..51092e2d 100644 --- a/packages/auth/components/MagicLinkSignIn.wrn +++ b/packages/auth/components/MagicLinkSignIn.wrn @@ -1,3 +1,5 @@ +import { Button, Input } from "@wrnexus/ui" + component MagicLinkSignIn { props { action = "/api/auth/magic-link/request" diff --git a/packages/auth/components/OtpSignIn.wrn b/packages/auth/components/OtpSignIn.wrn index 80f4277e..acf755f8 100644 --- a/packages/auth/components/OtpSignIn.wrn +++ b/packages/auth/components/OtpSignIn.wrn @@ -1,3 +1,5 @@ +import { Button, Input, PinInput } from "@wrnexus/ui" + component OtpSignIn { props { requestAction = "/api/auth/otp/login/request" diff --git a/packages/auth/components/PasskeyButton.wrn b/packages/auth/components/PasskeyButton.wrn index 4740e2c5..2df7b0df 100644 --- a/packages/auth/components/PasskeyButton.wrn +++ b/packages/auth/components/PasskeyButton.wrn @@ -1,3 +1,5 @@ +import { Button } from "@wrnexus/ui" + component PasskeyButton { outputs { passkeyRegistered(payload: { credentialId: string; response?: object }) diff --git a/packages/auth/components/RecoveryCodes.wrn b/packages/auth/components/RecoveryCodes.wrn index 6d3f2709..8f44d4e2 100644 --- a/packages/auth/components/RecoveryCodes.wrn +++ b/packages/auth/components/RecoveryCodes.wrn @@ -1,3 +1,5 @@ +import { Alert, Button, Card } from "@wrnexus/ui" + component RecoveryCodes { props { codes: unknown[] = [] diff --git a/packages/auth/components/ResetPassword.wrn b/packages/auth/components/ResetPassword.wrn index 81a658f4..cd44da74 100644 --- a/packages/auth/components/ResetPassword.wrn +++ b/packages/auth/components/ResetPassword.wrn @@ -1,3 +1,5 @@ +import { Button, StrongPassword } from "@wrnexus/ui" + component ResetPassword { props { action = "/api/auth/password/reset" diff --git a/packages/auth/components/SignIn.wrn b/packages/auth/components/SignIn.wrn index 047a2a01..af2e1fb3 100644 --- a/packages/auth/components/SignIn.wrn +++ b/packages/auth/components/SignIn.wrn @@ -1,3 +1,6 @@ +import PasskeyButton from "./PasskeyButton.wrn" +import { Button, Checkbox, Input, TogglePassword } from "@wrnexus/ui" + component SignIn { props { action = "/api/auth/login" diff --git a/packages/auth/components/SignUp.wrn b/packages/auth/components/SignUp.wrn index 43fadffa..c7c1fdfc 100644 --- a/packages/auth/components/SignUp.wrn +++ b/packages/auth/components/SignUp.wrn @@ -1,3 +1,5 @@ +import { Button, Checkbox, Input, StrongPassword } from "@wrnexus/ui" + component SignUp { props { action = "/api/auth/register" diff --git a/packages/auth/components/TwoFactorChallenge.wrn b/packages/auth/components/TwoFactorChallenge.wrn index b7654040..f8890013 100644 --- a/packages/auth/components/TwoFactorChallenge.wrn +++ b/packages/auth/components/TwoFactorChallenge.wrn @@ -1,3 +1,5 @@ +import { Button, PinInput, Select } from "@wrnexus/ui" + component TwoFactorChallenge { props { action = "/api/auth/mfa/complete" diff --git a/packages/auth/components/VerifyEmail.wrn b/packages/auth/components/VerifyEmail.wrn index 7bd19bcd..04610a6f 100644 --- a/packages/auth/components/VerifyEmail.wrn +++ b/packages/auth/components/VerifyEmail.wrn @@ -1,3 +1,5 @@ +import { Button, Input } from "@wrnexus/ui" + component VerifyEmail { props { kind = "email" diff --git a/packages/auth/components/VerifyPhone.wrn b/packages/auth/components/VerifyPhone.wrn index a5c9ff04..f91a2709 100644 --- a/packages/auth/components/VerifyPhone.wrn +++ b/packages/auth/components/VerifyPhone.wrn @@ -1,3 +1,5 @@ +import { Button, PinInput } from "@wrnexus/ui" + component VerifyPhone { props { kind = "phone" diff --git a/packages/auth/package.json b/packages/auth/package.json index 9d0388ad..8dfc51d5 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/auth", - "version": "0.8.2", + "version": "0.8.3", "description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.", "type": "module", "sideEffects": false, diff --git a/packages/authz/package.json b/packages/authz/package.json index 23a4312a..30bffdc7 100644 --- a/packages/authz/package.json +++ b/packages/authz/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/authz", - "version": "0.8.2", + "version": "0.8.3", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/benchmark/package.json b/packages/benchmark/package.json index 7325e6a3..dc7c5f6d 100644 --- a/packages/benchmark/package.json +++ b/packages/benchmark/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/benchmark", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "Deterministic benchmark runner and performance regression budgets for WRNexusJS.", "main": "src/index.ts", diff --git a/packages/cache/package.json b/packages/cache/package.json index 01b1ab0a..7770b1c8 100644 --- a/packages/cache/package.json +++ b/packages/cache/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/cache", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "Tag-aware memory and response caching with stale-while-revalidate for WRNexusJS.", "main": "src/index.ts", diff --git a/packages/captcha/components/CaptchaField.wrn b/packages/captcha/components/CaptchaField.wrn index 2dfe2068..62a59a6f 100644 --- a/packages/captcha/components/CaptchaField.wrn +++ b/packages/captcha/components/CaptchaField.wrn @@ -1,3 +1,6 @@ +import Captcha from "./Captcha.wrn" +import { Card } from "@wrnexus/ui" + component CaptchaField { props { title: string = "Security verification" diff --git a/packages/captcha/components/CaptchaStatus.wrn b/packages/captcha/components/CaptchaStatus.wrn index adb2cea5..851cf94b 100644 --- a/packages/captcha/components/CaptchaStatus.wrn +++ b/packages/captcha/components/CaptchaStatus.wrn @@ -1,3 +1,5 @@ +import { Alert } from "@wrnexus/ui" + component CaptchaStatus { props { success: boolean = false diff --git a/packages/captcha/package.json b/packages/captcha/package.json index 2cb052bc..8762f931 100644 --- a/packages/captcha/package.json +++ b/packages/captcha/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/captcha", - "version": "0.8.2", + "version": "0.8.3", "description": "First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI.", "type": "module", "sideEffects": false, diff --git a/packages/cli/package.json b/packages/cli/package.json index d5609e68..9ec1f2e7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/cli", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts index 54d71a0f..607cb9d2 100644 --- a/packages/cli/src/build.ts +++ b/packages/cli/src/build.ts @@ -29,6 +29,7 @@ import { analyzeRuntimeRequirements, assertValidAst, generate, + generateTargets, parse, type RuntimeRequirements, type DeploymentRuntime, @@ -106,6 +107,7 @@ export async function runBuild(appRoot: string): Promise { const appDir = join(root, "app"); const distDir = join(root, "dist"); const compiledDir = join(distDir, "compiled"); + const clientModulesDir = join(distDir, "client"); const reactivePath = join(distDir, "reactive.js"); const publicDir = join(root, "public"); const distPublicDir = join(distDir, "public"); @@ -117,6 +119,7 @@ export async function runBuild(appRoot: string): Promise { // Clean output. rmSync(distDir, { recursive: true, force: true }); mkdirSync(compiledDir, { recursive: true }); + mkdirSync(clientModulesDir, { recursive: true }); if (existsSync(publicDir)) { cpSync(publicDir, distPublicDir, { recursive: true }); console.log(`✓ Public: ${distPublicDir}`); @@ -169,15 +172,29 @@ export async function runBuild(appRoot: string): Promise { // keep the exact compiler path and output contract by default. let compiledCount = 0; const compiledFiles = new Map(); + const clientFiles = new Map(); const runtimeAnalysis = new Map(); const partialStaticFiles = new Set(); const compileWrn = async (file: string): Promise => { if (!file.endsWith(".wrn") || compiledFiles.has(file)) return; - const out = join(compiledDir, `route${compiledCount++}.ts`); - // Reserve the artifact before resolving imports so cycles terminate and - // mutually dependent generated modules can point at deterministic paths. - compiledFiles.set(file, out); const source = readFileSync(file, "utf8"); + const artifactId = compiledCount++; + const out = join(compiledDir, `route${artifactId}.ts`); + const clientHash = createHash("sha256") + .update(fwd(file)) + .update("\0") + .update(source) + .digest("hex") + .slice(0, 16); + const clientOut = join(clientModulesDir, `${basename(file, ".wrn")}-${clientHash}.mjs`); + const clientUrl = `/__wrnexus/client/${basename(clientOut)}`; + const clientEntry = join(compiledDir, `client${artifactId}.entry.mjs`); + + // Reserve both artifacts before resolving imports so dependency cycles + // terminate and every generated import has a deterministic target. + compiledFiles.set(file, out); + clientFiles.set(file, clientOut); + let ast = parse(source); assertValidAst(ast, { file, accessibility: true }); ast = await pluginRunner.transformAst(ast, file); @@ -193,18 +210,35 @@ export async function runBuild(appRoot: string): Promise { errors.map((diagnostic) => `[${diagnostic.code}] ${diagnostic.message}`).join("\n"), ); } + try { - let code = `// compiled from .wrn\n${generate(ast)}`; + const targets = generateTargets(ast); + let code = `// compiled from ${fwd(relative(root, file))}\n${generate(ast)}`.replaceAll( + "__WRNEXUS_CLIENT_MODULE__", + clientUrl, + ); + let browserCode = `// browser module compiled from ${fwd(relative(root, file))}\n${targets.browser}`; code = await pluginRunner.transformCode(code, file); + browserCode = await pluginRunner.transformCode(browserCode, file); + for (const [id, target] of virtualModules) { const sourcePattern = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const relativeTarget = relative(dirname(out), target).replace(/\\/g, "/"); - const specifier = relativeTarget.startsWith(".") ? relativeTarget : `./${relativeTarget}`; + const mainRelative = relative(dirname(out), target).replace(/\\/g, "/"); + const mainSpecifier = mainRelative.startsWith(".") ? mainRelative : `./${mainRelative}`; code = code.replace( new RegExp(`(["'])${sourcePattern}\\1`, "g"), - JSON.stringify(specifier), + JSON.stringify(mainSpecifier), + ); + const browserRelative = relative(dirname(clientEntry), target).replace(/\\/g, "/"); + const browserSpecifier = browserRelative.startsWith(".") + ? browserRelative + : `./${browserRelative}`; + browserCode = browserCode.replace( + new RegExp(`(["'])${sourcePattern}\\1`, "g"), + JSON.stringify(browserSpecifier), ); } + const resolvedImports = resolveWrnImports(ast.structuredImports, file, { appRoot: root, mode: config.imports?.mode ?? "compatible", @@ -214,23 +248,74 @@ export async function runBuild(appRoot: string): Promise { if (imported.diagnostic?.severity === "error") { throw new Error(`${imported.diagnostic.code}: ${imported.diagnostic.message}`); } - if (!imported.resolved || !imported.declaration.source.startsWith(".")) continue; - let target = imported.resolved; - if (target.endsWith(".wrn")) { - await compileWrn(target); - target = compiledFiles.get(target)!; - } + if (!imported.resolved) continue; const sourcePattern = imported.declaration.source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const relativeTarget = relative(dirname(out), target).replace(/\\/g, "/"); - const specifier = relativeTarget.startsWith(".") ? relativeTarget : `./${relativeTarget}`; - code = code.replace( - new RegExp(`(["'])${sourcePattern}\\1`, "g"), - JSON.stringify(specifier), + + const importAliases = { "@": "./app", ...(config.imports?.aliases ?? {}) }; + const isApplicationImport = + imported.declaration.source.startsWith(".") || + Object.keys(importAliases).some( + (alias) => + imported.declaration.source === alias || + imported.declaration.source.startsWith(`${alias}/`), + ); + if (isApplicationImport) { + let target = imported.resolved; + if (target.endsWith(".wrn")) { + await compileWrn(target); + target = compiledFiles.get(target)!; + } + const relativeTarget = relative(dirname(out), target).replace(/\\/g, "/"); + const specifier = relativeTarget.startsWith(".") ? relativeTarget : `./${relativeTarget}`; + code = code.replace( + new RegExp(`(["'])${sourcePattern}\\1`, "g"), + JSON.stringify(specifier), + ); + } + + // Browser modules keep only imports referenced by client/shared code. + // Resolve those imports relative to the original .wrn source before + // bundling so no filesystem or bare package specifier reaches browsers. + if (isApplicationImport && new RegExp(`(["'])${sourcePattern}\\1`).test(browserCode)) { + let browserTarget = imported.resolved; + if (browserTarget.endsWith(".wrn")) { + await compileWrn(browserTarget); + browserTarget = clientFiles.get(browserTarget)!; + } + const browserRelative = relative(dirname(clientEntry), browserTarget).replace(/\\/g, "/"); + const browserSpecifier = browserRelative.startsWith(".") + ? browserRelative + : `./${browserRelative}`; + browserCode = browserCode.replace( + new RegExp(`(["'])${sourcePattern}\\1`, "g"), + JSON.stringify(browserSpecifier), + ); + } + } + + writeFileSync(out, code, "utf8"); + writeFileSync(clientEntry, browserCode, "utf8"); + const browserResult = await Bun.build({ + entrypoints: [clientEntry], + target: "browser", + format: "esm", + splitting: false, + minify: true, + sourcemap: config.build?.sourceMaps ? "inline" : "none", + }); + if (!browserResult.success || !browserResult.outputs.length) { + throw new Error( + `WRN-CLIENT-BUNDLE: failed to bundle ${file}\n${browserResult.logs.map(String).join("\n")}`, ); } - writeFileSync(out, code, "utf8"); + writeFileSync(clientOut, await browserResult.outputs[0]!.text(), "utf8"); + rmSync(clientEntry, { force: true }); } catch (error) { compiledFiles.delete(file); + clientFiles.delete(file); + rmSync(out, { force: true }); + rmSync(clientOut, { force: true }); + rmSync(clientEntry, { force: true }); throw error; } }; @@ -369,6 +454,8 @@ export async function runBuild(appRoot: string): Promise { console.log(`✓ Partial shells: ${partialShells.size} build-time route shell(s)`); } const assetHash = createHash("sha256"); + for (const file of clientFiles.values()) assetHash.update(readFileSync(file)); + if (clientFiles.size) console.log(`✓ Client modules: ${clientFiles.size} bundled module(s)`); const emittedPluginAssets = await emitPluginAssets( pluginContributions, distDir, @@ -533,6 +620,7 @@ await createProductionServer( }, { reactivePath: join(import.meta.dir, "reactive.js"), + clientModulesDir: join(import.meta.dir, "client"), themePath: join(import.meta.dir, "theme.css"), themeJsPath: join(import.meta.dir, "theme.js"), theme: ${JSON.stringify(theme)}, diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 163cbc96..3d06a88a 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -3,6 +3,7 @@ import { basename, extname, join, relative, resolve } from "node:path"; import { buildRouter, createRouteManifest, nameRoutes } from "@wrnexus/router"; import { checkWrnFile, type WrnTypeDiagnostic } from "@wrnexus/typecheck"; import { parse } from "@wrnexus/syntax"; +import { generate, generateTargets } from "@wrnexus/compiler"; import { regenerateRoutes } from "./routes.ts"; import { loadAppConfig } from "@wrnexus/styles"; import { createPluginRunner, discoverPlugins, type PluginContributions } from "@wrnexus/plugin"; @@ -258,11 +259,51 @@ export async function generateApplicationTypesWithPlugins( return generateApplicationTypes(root, await runner.contributions()); } +function codegenDiagnostics(file: string): WrnTypeDiagnostic[] { + try { + const ast = parse(readFileSync(file, "utf8")); + const main = generate(ast); + const targets = generateTargets(ast); + const bun = ( + globalThis as unknown as { + Bun?: { + Transpiler?: new (options: { loader: string }) => { + transformSync(code: string): unknown; + }; + }; + } + ).Bun; + if (bun?.Transpiler) { + const transpiler = new bun.Transpiler({ loader: "ts" }); + transpiler.transformSync(main); + transpiler.transformSync(targets.browser); + transpiler.transformSync(targets.server); + } + return []; + } catch (error) { + return [ + { + code: "WRN-CODEGEN", + category: "error", + message: error instanceof Error ? error.message : String(error), + file, + line: 1, + column: 1, + length: 1, + hint: "Fix the reported SSR/client code generation issue before starting or building the app.", + }, + ]; + } +} + export function checkApplication(appRoot: string): WrnTypeDiagnostic[] { const root = resolve(appRoot); - return files(join(root, "app"), (path) => extname(path) === ".wrn").flatMap((file) => - checkWrnFile(file, { appRoot: root }), - ); + return files(join(root, "app"), (path) => extname(path) === ".wrn").flatMap((file) => { + const diagnostics = checkWrnFile(file, { appRoot: root }); + return diagnostics.some((item) => item.category === "error") + ? diagnostics + : [...diagnostics, ...codegenDiagnostics(file)]; + }); } export async function runTypecheck(appRoot: string): Promise { diff --git a/packages/cli/src/update.ts b/packages/cli/src/update.ts index d9a29b6d..de03e600 100644 --- a/packages/cli/src/update.ts +++ b/packages/cli/src/update.ts @@ -1989,6 +1989,16 @@ const MIGRATIONS: Migration[] = [ // Runtime stability fixes require no application source rewrite. }, }, + { + version: "0.8.3", + id: "0.8.3-client-ssr-realtime-stability", + description: + "Fixes browser function bundling, SSR computed values, Async aliases, typed loop props, realtime identity isolation, loader invalidation, and strict package imports.", + apply() { + // Framework runtime and compiler fixes require no application source rewrite. + // The compile-cache generation changes automatically invalidate old artifacts. + }, + }, ]; /** Release tooling uses this to require an explicit migration entry per version. */ diff --git a/packages/compiler/package.json b/packages/compiler/package.json index ebd29d3b..6847ad93 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/compiler", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/compiler/src/client-codegen.ts b/packages/compiler/src/client-codegen.ts index d023770b..0a034836 100644 --- a/packages/compiler/src/client-codegen.ts +++ b/packages/compiler/src/client-codegen.ts @@ -1,4 +1,10 @@ -import { eraseFunctionTypes, type PageAst, type RuntimeFunctionDecl } from "@wrnexus/syntax"; +import { + eraseFunctionTypes, + type PageAst, + type RuntimeFunctionDecl, + type StructuredImportDecl, + type ViewNode, +} from "@wrnexus/syntax"; const RESERVED_BINDINGS = new Set([ "await", @@ -63,6 +69,111 @@ function safeIdentifier(name: string): boolean { return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) && !RESERVED_BINDINGS.has(name); } +function identifierReferenced(source: string, name: string): boolean { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`(?:^|[^A-Za-z0-9_$])${escaped}(?![A-Za-z0-9_$])`).test(source); +} + +function viewReferenceSource(nodes: ViewNode[]): string[] { + return nodes.flatMap((node): string[] => { + if (node.type === "text") return [node.value]; + if (node.type === "each") { + return [ + node.list, + node.key ?? "", + ...viewReferenceSource(node.body), + ...viewReferenceSource(node.empty), + ]; + } + if (node.type === "if") { + return node.branches.flatMap((branch) => [ + branch.cond ?? "", + ...viewReferenceSource(branch.body), + ]); + } + return [...node.attrs.map((attr) => attr.value), ...viewReferenceSource(node.children)]; + }); +} + +function browserReferenceSource(ast: PageAst, functions: RuntimeFunctionDecl[]): string { + return [ + ...functions.flatMap((fn) => [fn.source, fn.body]), + ...ast.computed.map((entry) => entry.expr), + ...ast.effects, + ast.lifecycle.mount ?? "", + ast.lifecycle.update ?? "", + ast.lifecycle.unmount ?? "", + ...viewReferenceSource(ast.view), + ].join("\n"); +} + +function renderSelectedImport( + entry: StructuredImportDecl, + source: string, +): { code: string; bindings: string[] } | null { + if (entry.typeOnly) return null; + const isStore = entry.source.endsWith(".wrn") && /(?:^|\/)stores?\//.test(entry.source); + if (entry.source.endsWith(".wrn") && !isStore) return null; + + const defaultImport = + entry.defaultImport && (isStore || identifierReferenced(source, entry.defaultImport)) + ? entry.defaultImport + : undefined; + const namespaceImport = + entry.namespaceImport && (isStore || identifierReferenced(source, entry.namespaceImport)) + ? entry.namespaceImport + : undefined; + const namedImports = entry.namedImports.filter( + (item) => !item.typeOnly && (isStore || identifierReferenced(source, item.local)), + ); + + const bindings = [ + ...(defaultImport ? [defaultImport] : []), + ...(namespaceImport ? [namespaceImport] : []), + ...namedImports.map((item) => item.local), + ].filter(safeIdentifier); + + const hasBindings = Boolean(defaultImport || namespaceImport || namedImports.length); + const sideEffectOnly = + !entry.defaultImport && !entry.namespaceImport && entry.namedImports.length === 0; + if (!hasBindings && !sideEffectOnly) return null; + if (sideEffectOnly) return { code: entry.raw, bindings: [] }; + + const clauses: string[] = []; + if (defaultImport) clauses.push(defaultImport); + if (namespaceImport) clauses.push(`* as ${namespaceImport}`); + if (namedImports.length) { + clauses.push( + `{ ${namedImports + .map((item) => + item.imported === item.local ? item.imported : `${item.imported} as ${item.local}`, + ) + .join(", ")} }`, + ); + } + return { + code: `import ${clauses.join(", ")} from ${JSON.stringify(entry.source)};`, + bindings, + }; +} + +function selectedBrowserImports( + ast: PageAst, + functions: RuntimeFunctionDecl[], +): { code: string; bindings: string[] }[] { + const referenceSource = browserReferenceSource(ast, functions); + return ast.structuredImports + .map((entry) => renderSelectedImport(entry, referenceSource)) + .filter((entry): entry is { code: string; bindings: string[] } => entry !== null); +} + +export function browserModuleRequired(ast: PageAst): boolean { + const functions = ast.runtimeFunctions.filter((fn) => + ["legacy", "client", "shared"].includes(fn.runtime), + ); + return functions.length > 0 || selectedBrowserImports(ast, functions).length > 0; +} + function functionEntry( ast: PageAst, fn: RuntimeFunctionDecl, @@ -164,22 +275,9 @@ export function generateBrowserModule(ast: PageAst): string { ); const functionNames = functions.map((fn) => fn.name); const state = ast.states.filter((entry) => entry.runtime !== "server").map((entry) => entry.name); - const storeImports = ast.structuredImports.filter( - (entry) => - !entry.typeOnly && entry.source.endsWith(".wrn") && /(?:^|\/)stores?\//.test(entry.source), - ); - const imports = ast.structuredImports - .filter((entry) => !entry.typeOnly) - .filter((entry) => !entry.source.endsWith(".wrn") || /(?:^|\/)stores?\//.test(entry.source)) - .map((entry) => entry.raw) - .join("\n"); - const importedBindings = storeImports - .flatMap((entry) => [ - ...(entry.defaultImport ? [entry.defaultImport] : []), - ...(entry.namespaceImport ? [entry.namespaceImport] : []), - ...entry.namedImports.map((item) => item.local), - ]) - .filter(safeIdentifier); + const selectedImports = selectedBrowserImports(ast, functions); + const imports = selectedImports.map((entry) => entry.code).join("\n"); + const importedBindings = [...new Set(selectedImports.flatMap((entry) => entry.bindings))]; return `// generated WRNexusJS browser module for ${ast.name} ${imports} export const __wrnexusClientFunctions = { diff --git a/packages/compiler/src/codegen.ts b/packages/compiler/src/codegen.ts index 99d2af97..7bab5c2a 100644 --- a/packages/compiler/src/codegen.ts +++ b/packages/compiler/src/codegen.ts @@ -22,6 +22,7 @@ import { eraseFunctionTypes, runtimeTypeOf } from "./types.ts"; import { stripRuntimeFunctionModifiers } from "@wrnexus/syntax"; import { generateStoreModule } from "./store-codegen.ts"; import { optimizeAst } from "./analysis.ts"; +import { browserModuleRequired } from "./client-codegen.ts"; interface RenderBinding { method: string; @@ -264,12 +265,21 @@ function evalStateSeeds(states: { name: string; expr: string }[]): Record { const expr = inner.trim(); if (expr.startsWith("t:") || !exprRefsState(expr, reactive.stateNames)) return whole; + if (exprRefsState(expr, reactive.runtimeStateNames) && dynamicExpressions) { + dynamicExpressions.push(`\${__wrnexusEscapeHtml(${expr})}`); + const sentinel = `\x00WRNEACH${dynamicExpressions.length - 1}\x00`; + return `${sentinel}`; + } let value: unknown; try { value = new Function("with(this){return (" + expr + ");}").call(reactive.scope); @@ -308,7 +318,9 @@ function bakeLoopText(raw: string): string { } /** Bake a loop-body attribute value (same rules as text; escapeHtml is attribute-safe). */ -function bakeLoopAttr(raw: string): string { +function bakeLoopAttr(raw: string, typed = false): string { + const wholeExpression = wholeAttributeExpression(raw); + if (typed && wholeExpression) return "${__wrnexusPropAttr(" + wholeExpression + ")}"; if (!raw.includes("{")) return escLit(attrEscape(raw)); let out = ""; let last = 0; @@ -347,7 +359,7 @@ function renderLoopBody(node: ViewNode): string { return escLit(` ${name}`); } - return escLit(` ${name}="`) + bakeLoopAttr(attr.value) + escLit(`"`); + return escLit(` ${name}="`) + bakeLoopAttr(attr.value, componentTag) + escLit(`"`); }) .join(""); @@ -492,7 +504,7 @@ function renderNode( loops: string[], reactive: PageReactive | null = null, ): string { - if (node.type === "text") return substituteReactiveText(node.value, reactive); // {t:key} + state baking + if (node.type === "text") return substituteReactiveText(node.value, reactive, loops); // {t:key} + state baking // Server control block (loop / conditional) → a sentinel that survives // templateEscape, swapped for its real `${…}` code after escaping. @@ -532,13 +544,15 @@ function renderNode( if (node.tag === "Async") { const source = attrValue(node.attrs, "source") ?? "data"; const retries = attrValue(node.attrs, "retries") ?? "2"; + const tags = attrValue(node.attrs, "tags") ?? source; const serverResolved = attrValue(node.attrs, "data-wrn-async-server") === "true"; - const asyncIndex = serverResolved ? loops.push("") - 1 : -1; - const branch = (name: string) => { - const element = node.children.find( + const branchElement = (name: string) => + node.children.find( (child): child is Extract => child.type === "element" && child.tag === name, ); + const branch = (name: string) => { + const element = branchElement(name); return (element?.children ?? []) .map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive)) .join(""); @@ -546,19 +560,42 @@ function renderNode( const loading = branch("Loading"); const success = branch("Success"); const error = branch("Error"); + const successElement = branchElement("Success"); + const errorElement = branchElement("Error"); + const identifier = (value: string | undefined, fallback: string) => + value && isSafeGeneratedIdentifier(value) ? value : fallback; + const successAlias = identifier( + successElement ? attrValue(successElement.attrs, "data") : undefined, + identifier(source, "data"), + ); + const errorAlias = identifier( + errorElement ? attrValue(errorElement.attrs, "error") : undefined, + "error", + ); + const nested = (value: string) => value.replace(/\\/g, "\\\\").replace(/`/g, "\\`"); + const scoped = (value: string, alias: string, expression: string) => { + const index = + loops.push( + `\${(() => { const ${alias} = ${expression}; return \`${nested(value)}\`; })()}`, + ) - 1; + return `\x00WRNEACH${index}\x00`; + }; + const successTemplate = scoped(success, successAlias, `(ctx[${JSON.stringify(source)}] ?? {})`); + const errorTemplate = scoped(error, errorAlias, `{ message: "" }`); let initial = loading; if (serverResolved) { - const nested = (value: string) => value.replace(/\\/g, "\\\\").replace(/`/g, "\\`"); - const sourcePattern = source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const sourcePattern = successAlias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const serverSuccess = success.replace( new RegExp(`\\{\\s*(${sourcePattern}(?:\\.[A-Za-z_$][\\w$]*)*)\\s*\\}`, "g"), (_whole, expression: string) => `\${__wrnexusEscapeHtml(${expression})}`, ); - loops[asyncIndex] = - `\${ctx[${JSON.stringify(source)}] !== undefined ? \`${nested(serverSuccess)}\` : \`${nested(loading)}\`}`; - initial = `\x00WRNEACH${asyncIndex}\x00`; + const index = + loops.push( + `\${ctx[${JSON.stringify(source)}] !== undefined ? (() => { const ${successAlias} = ctx[${JSON.stringify(source)}]; return \`${nested(serverSuccess)}\`; })() : \`${nested(loading)}\`}`, + ) - 1; + initial = `\x00WRNEACH${index}\x00`; } - return `
${initial}
`; + return `
${initial}
`; } if (node.tag === "KeepAlive") { @@ -965,13 +1002,133 @@ function generateSsrStateAliases(stateNames: string[]): string { return `const { ${names.join(", ")} } = __state;\n`; } +function orderPageStates(entries: PageAst["states"]): PageAst["states"] { + if (entries.length < 2) return entries; + const byName = new Map(entries.map((entry) => [entry.name, entry])); + const visiting = new Set(); + const visited = new Set(); + const ordered: PageAst["states"] = []; + + const visit = (name: string): void => { + if (visited.has(name)) return; + if (visiting.has(name)) { + throw new Error(`WRN-STATE-CYCLE: state value '${name}' has a dependency cycle.`); + } + const entry = byName.get(name); + if (!entry) return; + visiting.add(name); + for (const dependency of byName.keys()) { + if ( + dependency !== name && + new RegExp(`\\b${dependency.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(entry.expr) + ) { + visit(dependency); + } + } + visiting.delete(name); + visited.add(name); + ordered.push(entry); + }; + + for (const entry of entries) visit(entry.name); + return ordered; +} + +function generateSsrStateInitializer(entries: PageAst["states"]): string { + const ordered = orderPageStates(entries); + const declarations = ordered + .filter((entry) => isSafeGeneratedIdentifier(entry.name)) + .map( + (entry) => + `const ${entry.name} = (() => { try { return (${entry.expr}); } catch { return undefined; } })();`, + ) + .join(" "); + const values = entries + .map((entry) => { + if (isSafeGeneratedIdentifier(entry.name)) { + return `${JSON.stringify(entry.name)}: ${entry.name}`; + } + return `${JSON.stringify(entry.name)}: (() => { try { return (${entry.expr}); } catch { return undefined; } })()`; + }) + .join(", "); + return `(() => { ${declarations} return { ${values} }; })()`; +} + +function orderPageComputed(entries: PageAst["computed"]): PageAst["computed"] { + if (entries.length < 2) return entries; + const byName = new Map(entries.map((entry) => [entry.name, entry])); + const visiting = new Set(); + const visited = new Set(); + const ordered: PageAst["computed"] = []; + + const visit = (name: string): void => { + if (visited.has(name)) return; + if (visiting.has(name)) { + throw new Error(`WRN-COMPUTED-CYCLE: computed value '${name}' has a dependency cycle.`); + } + const entry = byName.get(name); + if (!entry) return; + visiting.add(name); + for (const dependency of byName.keys()) { + if ( + dependency !== name && + new RegExp(`\\b${dependency.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(entry.expr) + ) { + visit(dependency); + } + } + visiting.delete(name); + visited.add(name); + ordered.push(entry); + }; + + for (const entry of entries) visit(entry.name); + return ordered; +} + +function generateSsrComputedAliases(entries: PageAst["computed"]): string { + return orderPageComputed(entries) + .filter((entry) => isSafeGeneratedIdentifier(entry.name)) + .map( + (entry) => + `const ${entry.name} = (() => { try { return (${entry.expr}); } catch { return undefined; } })();`, + ) + .join("\n"); +} + +function runtimeComputedNames( + states: PageAst["states"], + computed: PageAst["computed"], + runtimeRoots: Iterable = [], +): Set { + const entries = [...states, ...computed]; + const runtime = new Set([ + ...runtimeRoots, + ...entries.filter((entry) => /\bctx\b/.test(entry.expr)).map((entry) => entry.name), + ]); + let changed = true; + while (changed) { + changed = false; + for (const entry of entries) { + if (runtime.has(entry.name)) continue; + if ( + [...runtime].some((name) => + new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(entry.expr), + ) + ) { + runtime.add(entry.name); + changed = true; + } + } + } + return runtime; +} + function hydrationAttribute(ast: PageAst): string { const strategy = ["static", "server"].includes(ast.renderMode ?? "") ? "none" : (ast.hydrate ?? "load"); - const hasBrowserModule = ast.runtimeFunctions.some((fn) => - ["legacy", "client", "shared"].includes(fn.runtime), - ); + const hasBrowserModule = browserModuleRequired(ast); const moduleAttribute = hasBrowserModule ? ' data-wrn-client-module="__WRNEXUS_CLIENT_MODULE__"' : ""; @@ -1119,9 +1276,11 @@ export function generate(ast: PageAst): string { } // --- View -> default page component --- + const orderedStates = orderPageStates(ast.states); const browserStates = ast.states.filter((state) => state.runtime !== "server"); - const seedScope = evalStateSeeds(ast.states); - for (const entry of ast.computed) { + const seedScope = evalStateSeeds(orderedStates); + const orderedComputed = orderPageComputed(ast.computed); + for (const entry of orderedComputed) { try { seedScope[entry.name] = new Function("with(this){return (" + entry.expr + ");}").call( seedScope, @@ -1134,9 +1293,14 @@ export function generate(ast: PageAst): string { ...browserStates.map((entry) => entry.name), ...ast.computed.map((entry) => entry.name), ]; - const runtimeStateNames = new Set( - ast.states.filter((entry) => /\bctx\b/.test(entry.expr)).map((entry) => entry.name), - ); + const storeBindings = importedStoreBindings(ast); + const runtimeRoots = [ + ...storeBindings.map((entry) => entry.local), + ...ast.loads + .filter((load) => load.mode === "server" && !load.deferred && load.name) + .map((load) => load.name!), + ]; + const runtimeStateNames = runtimeComputedNames(orderedStates, orderedComputed, runtimeRoots); const reactive: PageReactive | null = reactiveNames.length > 0 ? { stateNames: new Set(reactiveNames), runtimeStateNames, scope: seedScope } @@ -1187,12 +1351,7 @@ export function generate(ast: PageAst): string { ); staticShellBody = templateEscape(shellHtml); } - const dynamicStateScope = ast.states - .map( - (state) => - `${JSON.stringify(state.name)}: (() => { try { return (${state.expr}); } catch { return undefined; } })()`, - ) - .join(", "); + const dynamicStateInitializer = generateSsrStateInitializer(orderedStates); const stateType = ast.states.length > 0 ? `{ ${ast.states @@ -1202,8 +1361,8 @@ export function generate(ast: PageAst): string { const hydrationStateNames = JSON.stringify(browserStates.map((state) => state.name)); const ssrStateAliases = generateSsrStateAliases(ast.states.map((state) => state.name)); + const ssrComputedAliases = generateSsrComputedAliases(orderedComputed); - const storeBindings = importedStoreBindings(ast); const storeDeclarations = storeBindings .map( (entry) => ` const ${entry.local} = await ctx.__wrnexusUseStore(${entry.internal});`, @@ -1214,12 +1373,16 @@ export function generate(ast: PageAst): string { .map((load) => ` const ${load.name} = ctx[${JSON.stringify(load.name)}];`) .join("\n"); - loops.forEach((code, idx) => { - body = body.replace(`\x00WRNEACH${idx}\x00`, () => code); + // Inner dynamic expressions are registered before the wrapper that scopes + // them (for example an Async branch alias). Resolve from the outside in so a + // wrapper sentinel is expanded before its nested sentinels are visited. + for (let idx = loops.length - 1; idx >= 0; idx--) { + const code = loops[idx]!; + body = body.replaceAll(`\x00WRNEACH${idx}\x00`, code); if (staticShellBody?.includes(`\x00WRNEACH${idx}\x00`)) { - staticShellBody = staticShellBody.replace(`\x00WRNEACH${idx}\x00`, () => code); + staticShellBody = staticShellBody.replaceAll(`\x00WRNEACH${idx}\x00`, code); } - }); + } // Server loops iterate raw SSR data. Declare a named const for every `ssr` data // binding a loop references, so `{#each as …}` can iterate the real value. @@ -1235,10 +1398,7 @@ export function generate(ast: PageAst): string { } } - const needsSsrRuntime = - ssrBindings.length > 0 || - loops.length > 0 || - ast.states.some((state) => /\bctx\b/.test(state.expr)); + const needsSsrRuntime = ssrBindings.length > 0 || loops.length > 0 || runtimeStateNames.size > 0; if (needsSsrRuntime) { out.push(ssrRuntimeSource()); out.push(`const __wrnexusSsrBindings = ${JSON.stringify(ssrBindings, null, 2)};`); @@ -1248,16 +1408,18 @@ export function generate(ast: PageAst): string { ${storeDeclarations} ${serverLoadAliases} ${decls} - const __state: ${stateType} = { ${dynamicStateScope} }; + const __state: ${stateType} = ${dynamicStateInitializer}; ${ssrStateAliases} + ${ssrComputedAliases} const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, (__state as any)[key]])); const __scopeValue = Object.entries(__hydrationState) .map(([key, value]) => { - const encoded = - typeof value === "number" || typeof value === "boolean" - ? String(value) - : JSON.stringify(value == null ? "" : String(value)); - + let encoded: string; + try { + encoded = value === undefined ? "undefined" : JSON.stringify(value); + } catch { + encoded = JSON.stringify(String(value)); + } return key + ": " + encoded; }) .join(", ") @@ -1279,16 +1441,18 @@ export function generate(ast: PageAst): string { `export default ${storeBindings.length > 0 ? "async " : ""}function ${ast.name}(ctx: any) { ${storeDeclarations} ${serverLoadAliases} - const __state: ${stateType} = { ${dynamicStateScope} }; + const __state: ${stateType} = ${dynamicStateInitializer}; ${ssrStateAliases} + ${ssrComputedAliases} const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, (__state as any)[key]])); const __scopeValue = Object.entries(__hydrationState) .map(([key, value]) => { - const encoded = - typeof value === "number" || typeof value === "boolean" - ? String(value) - : JSON.stringify(value == null ? "" : String(value)); - + let encoded: string; + try { + encoded = value === undefined ? "undefined" : JSON.stringify(value); + } catch { + encoded = JSON.stringify(String(value)); + } return key + ": " + encoded; }) .join(", ") @@ -1311,14 +1475,18 @@ export function generate(ast: PageAst): string { ${storeDeclarations} ${serverLoadAliases} ${loopConsts.length > 0 ? loopConsts.join("\n") : ""} - const __state: ${stateType} = { ${dynamicStateScope} }; + const __state: ${stateType} = ${dynamicStateInitializer}; ${ssrStateAliases} + ${ssrComputedAliases} const __hydrationState = Object.fromEntries(${hydrationStateNames}.map((key) => [key, (__state as any)[key]])); const __scopeValue = Object.entries(__hydrationState) .map(([key, value]) => { - const encoded = typeof value === "number" || typeof value === "boolean" - ? String(value) - : JSON.stringify(value == null ? "" : String(value)); + let encoded: string; + try { + encoded = value === undefined ? "undefined" : JSON.stringify(value); + } catch { + encoded = JSON.stringify(String(value)); + } return key + ": " + encoded; }) .join(", ") diff --git a/packages/compiler/src/import-resolver.ts b/packages/compiler/src/import-resolver.ts index 310e1674..12601ff8 100644 --- a/packages/compiler/src/import-resolver.ts +++ b/packages/compiler/src/import-resolver.ts @@ -1,4 +1,4 @@ -import { existsSync, realpathSync } from "node:fs"; +import { existsSync, realpathSync, statSync } from "node:fs"; import { dirname, extname, join, resolve } from "node:path"; import type { StructuredImportDecl } from "@wrnexus/syntax"; @@ -33,12 +33,29 @@ export function resolveWrnImport( options: ImportResolverOptions, ): ResolvedImport { const source = declaration.source; - if (!source.startsWith(".") && !source.startsWith("@/")) return { declaration, resolved: source }; - const aliasRoot = options.aliases?.["@"] ?? "./app"; - const base = source.startsWith("@/") - ? resolve(options.appRoot, aliasRoot, source.slice(2)) + const aliases: Record = { + "@": "./app", + ...(options.aliases ?? {}), + }; + const alias = Object.keys(aliases) + .filter((key) => key.length > 0 && (source === key || source.startsWith(`${key}/`))) + .sort((left, right) => right.length - left.length)[0]; + if (!source.startsWith(".") && !alias) return { declaration, resolved: source }; + const base = alias + ? resolve( + options.appRoot, + aliases[alias]!, + source === alias ? "" : source.slice(alias.length + 1), + ) : resolve(dirname(importer), source); - const found = candidates(base).find(existsSync); + const found = candidates(base).find((candidate) => { + if (!existsSync(candidate)) return false; + try { + return statSync(candidate).isFile(); + } catch { + return false; + } + }); if (found) return { declaration, resolved: realpathSync(found) }; const severity = (options.mode ?? "compatible") === "explicit" ? "error" : "warning"; return { diff --git a/packages/compiler/test/import-resolver-0.8.3.test.ts b/packages/compiler/test/import-resolver-0.8.3.test.ts new file mode 100644 index 00000000..4a66d696 --- /dev/null +++ b/packages/compiler/test/import-resolver-0.8.3.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parse, resolveWrnImports } from "../src/index.ts"; + +test("configured import aliases resolve application client modules", () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-import-alias-")); + const app = join(root, "app"); + const page = join(app, "settings.wrn"); + const helper = join(app, "client", "storage.ts"); + const clientIndex = join(app, "client", "index.ts"); + mkdirSync(join(app, "client"), { recursive: true }); + writeFileSync(helper, "export function persist(value: unknown) { return value; }\n"); + writeFileSync(clientIndex, "export const storageReady = true;\n"); + + try { + const ast = parse(`import { persist } from "~/client/storage.ts" +page Settings { view { } }`); + const [resolved] = resolveWrnImports(ast.structuredImports, page, { + appRoot: root, + mode: "explicit", + aliases: { "~": "./app" }, + }); + expect(resolved?.diagnostic).toBeUndefined(); + expect(resolved?.resolved).toBe(helper); + + const directoryAst = parse(`import { storageReady } from "~client" +page ClientIndex { view {

Ready

} }`); + const [directoryResolved] = resolveWrnImports(directoryAst.structuredImports, page, { + appRoot: root, + mode: "explicit", + aliases: { "~client": "./app/client" }, + }); + expect(directoryResolved?.diagnostic).toBeUndefined(); + expect(directoryResolved?.resolved).toBe(clientIndex); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/test/regressions-0.8.3.test.ts b/packages/compiler/test/regressions-0.8.3.test.ts new file mode 100644 index 00000000..f7105b83 --- /dev/null +++ b/packages/compiler/test/regressions-0.8.3.test.ts @@ -0,0 +1,160 @@ +import { expect, test } from "bun:test"; +import { generate, generateTargets, parse } from "../src/index.ts"; + +test("page computed values derived from request state are declared during SSR", () => { + const code = generate( + parse(`import UserCard from "./UserCard.wrn" +page Chat { + state { + currentUserId = ctx.url.searchParams.get("as") ?? "asha" + users = [{ id: "asha", name: "Asha" }, { id: "rohan", name: "Rohan" }] + } + computed { + currentUser = users.find((user) => user.id === currentUserId) ?? users[0] + } + view { +

{currentUser.name}

+ {#each users as user} + +
+ {/each} + } +}`), + ); + + expect(code).toContain("const currentUser ="); + expect(code).toContain("__wrnexusEscapeHtml(currentUser.name)"); + expect(code).toContain("__wrnexusPropAttr(user)"); + expect(code).toContain("__wrnexusEscapeHtml(user.id)"); + expect(code).not.toContain("__wrnexusPropAttr(user.id)"); + expect(code).toContain('encoded = value === undefined ? "undefined" : JSON.stringify(value)'); + expect(code).not.toContain("String((__state as any)[key])"); +}); + +test("computed values that reference ctx directly stay dynamic during SSR", () => { + const code = generate( + parse(`page DirectRequestComputed { + computed { identity = ctx.url.searchParams.get("as") ?? "asha" } + view {

{identity}

} +}`), + ); + expect(code).toContain("const identity ="); + expect(code).toContain("__wrnexusEscapeHtml(identity)"); +}); + +test("computed values derived from server loads stay dynamic during SSR", () => { + const code = generate( + parse(`page LoadedComputed { + load server overview { return { count: 4 } } + computed { total = overview.count + 1 } + view {

{total}

} +}`), + ); + expect(code).toContain('const overview = ctx["overview"]'); + expect(code).toContain("__wrnexusEscapeHtml(total)"); +}); + +test("request-derived state dependencies stay dynamic during SSR", async () => { + const code = generate( + parse(`page Identity { + state { + userId = ctx.url.searchParams.get("as") ?? "asha" + label = "User: " + userId + } + view {

{label}

} +}`), + ); + expect(code).toContain("__wrnexusEscapeHtml(label)"); + expect(code).not.toContain(''); + const javascript = new Bun.Transpiler({ loader: "ts" }).transformSync(code); + const moduleUrl = `data:text/javascript;base64,${Buffer.from(javascript).toString("base64")}`; + const rendered = await ( + await import(moduleUrl) + ).default({ + url: new URL("http://localhost/?as=rohan"), + }); + expect(rendered).toContain("User: rohan"); +}); + +test("Async aliases and invalidation tags are scoped in generated templates", () => { + const code = generate( + parse(`page Uploads { + load client uploads { return { name: "report.pdf" } } + view { + + Loading + {file.name} +

{problem.message}

+
+ } +}`), + ); + + expect(code).toContain('data-wrn-async-tags="uploads,files"'); + expect(code).toContain('data-wrn-async-alias="file"'); + expect(code).toContain('data-wrn-async-alias="problem"'); + expect(code).toContain("const file ="); + expect(code).toContain("const problem ="); +}); + +test("browser codegen prunes SSR-only UI imports and keeps client dependencies", () => { + const browser = generateTargets( + parse(`import { Button } from "@wrnexus/ui" +import { save } from "./helper.ts" +component Settings { + state { enabled = true } + functions { + client function persist(): void { save(enabled) } + } + view { + + } +}`), + ).browser; + + expect(browser).not.toContain("@wrnexus/ui"); + expect(browser).toContain('import { save } from "./helper.ts"'); + expect(browser).toContain('"persist": function'); +}); + +test("reactive view imports trigger a browser module without hydrating static SSR helpers", () => { + const reactiveAst = parse(`import { formatName } from "./format.ts" +page Profile { + state { name = "Asha" } + view {

{formatName(name)}

} +}`); + const staticAst = parse(`import { readFileSync } from "node:fs" +page StaticProfile { + runtime = "server" + view {

{readFileSync("profile.txt", "utf8")}

} +}`); + + expect(generate(reactiveAst)).toContain('data-wrn-client-module="__WRNEXUS_CLIENT_MODULE__"'); + expect(generateTargets(reactiveAst).browser).toContain( + 'import { formatName } from "./format.ts"', + ); + expect(generate(staticAst)).not.toContain("data-wrn-client-module"); +}); + +test("state dependency cycles fail code generation", () => { + expect(() => + generate( + parse(`page CyclicState { + state { first = second + 1 second = first + 1 } + view {

{first}

} +}`), + ), + ).toThrow("WRN-STATE-CYCLE"); +}); + +test("computed dependency cycles fail code generation", () => { + expect(() => + generate( + parse(`page Cyclic { + computed { first = second + 1 second = first + 1 } + view {

{first}

} +}`), + ), + ).toThrow("WRN-COMPUTED-CYCLE"); +}); diff --git a/packages/content/package.json b/packages/content/package.json index 50b1cd5e..70c95142 100644 --- a/packages/content/package.json +++ b/packages/content/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/content", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "Typed Markdown content collections, loaders, indexes, feeds, and preview workflows for WRNexusJS.", "main": "./src/index.ts", diff --git a/packages/core/package.json b/packages/core/package.json index e18d3ce1..0eab294e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/core", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/core/src/headers.ts b/packages/core/src/headers.ts index 8cb883f1..7ba027c7 100644 --- a/packages/core/src/headers.ts +++ b/packages/core/src/headers.ts @@ -135,7 +135,6 @@ const DEFAULT_PERMISSIONS_POLICY: PermissionsPolicyConfig = { payment: [], "picture-in-picture": [], "sync-xhr": [], - unload: [], usb: [], "xr-spatial-tracking": [], }; diff --git a/packages/core/test/headers.test.ts b/packages/core/test/headers.test.ts index 47be9289..b63b4148 100644 --- a/packages/core/test/headers.test.ts +++ b/packages/core/test/headers.test.ts @@ -72,3 +72,8 @@ test("resolveRequestUrl honors standard Forwarded headers behind trusted proxies expect(resolveRequestUrl(request, true).href).toBe("https://workroot.in/path"); expect(resolveRequestUrl(request, false).href).toBe("http://127.0.0.1:3000/path"); }); + +test("default permissions policy does not emit unsupported unload", () => { + const res = withSecurityHeaders(req(), new Response("x"), "development"); + expect(res.headers.get("permissions-policy") ?? "").not.toContain("unload="); +}); diff --git a/packages/csr/package.json b/packages/csr/package.json index 6637f1e9..0b9b685e 100644 --- a/packages/csr/package.json +++ b/packages/csr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/csr", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/csr/src/nav-runtime.ts b/packages/csr/src/nav-runtime.ts index 98c96e26..cf3a626a 100644 --- a/packages/csr/src/nav-runtime.ts +++ b/packages/csr/src/nav-runtime.ts @@ -702,5 +702,10 @@ export const NAV_RUNTIME = String.raw` false, ); }; + + /** Re-render the current route without adding a duplicate history entry. */ + window.__wrnexusRefresh = function () { + navigate(location.href, true); + }; })(); `.trim(); diff --git a/packages/csr/src/reactive-runtime.ts b/packages/csr/src/reactive-runtime.ts index 48f76f73..f28202fb 100644 --- a/packages/csr/src/reactive-runtime.ts +++ b/packages/csr/src/reactive-runtime.ts @@ -363,6 +363,26 @@ export const REACTIVE_RUNTIME = String.raw` }, }; } + + function routeSnapshot() { + var url = new URL(window.location.href); + var params = window.__wrnexusRouteParams || {}; + var marker = document.querySelector("[data-wrn-route-params]"); + if (marker) { + try { params = JSON.parse(marker.getAttribute("data-wrn-route-params") || "{}"); } + catch (_) { params = {}; } + window.__wrnexusRouteParams = params; + marker.remove(); + } + return { + url: url.href, + pathname: url.pathname, + search: url.search, + hash: url.hash, + searchParams: url.searchParams, + params: params, + }; + } function sanitizeReactiveUrl(value) { var raw = String(value == null ? "" : value); var compact = raw.trim().replace(/[\u0000-\u0020]+/g, "").toLowerCase(); @@ -755,6 +775,20 @@ export const REACTIVE_RUNTIME = String.raw` var anyStateListeners = new Set(); var cleanupCallbacks = []; var disposed = false; + var routeValue = signal(routeSnapshot()); + + function updateRouteValue() { + routeValue.set(routeSnapshot()); + } + + window.addEventListener("wrnexus:navigated", updateRouteValue); + window.addEventListener("popstate", updateRouteValue); + window.addEventListener("hashchange", updateRouteValue); + cleanupCallbacks.push(function () { + window.removeEventListener("wrnexus:navigated", updateRouteValue); + window.removeEventListener("popstate", updateRouteValue); + window.removeEventListener("hashchange", updateRouteValue); + }); function readGlobal(name) { if (declaredEvents.has(name)) { @@ -785,6 +819,10 @@ export const REACTIVE_RUNTIME = String.raw` if (name === "location") return window.location; if (name === "history") return window.history; if (name === "navigator") return window.navigator; + if (name === "$route" || name === "route") { + if (currentRenderer) routeValue.subscribe(currentRenderer); + return routeValue.get(); + } if (name === "setTimeout") return window.setTimeout.bind(window); if (name === "clearTimeout") return window.clearTimeout.bind(window); if (name === "setInterval") return window.setInterval.bind(window); @@ -965,6 +1003,12 @@ export const REACTIVE_RUNTIME = String.raw` function installBehaviorFunctions(source) { extractBehaviorFunctions(source || "").forEach(function (definition) { + // A generated browser module contains the real JavaScript implementation + // of client/shared functions. Keep that implementation when it has already + // been installed instead of replacing it with the small CSP-safe fallback + // interpreter. The fallback intentionally supports only a bounded subset of + // JavaScript and must never shadow the compiled function. + if (typeof behaviorFunctions[definition.name] === "function") return; behaviorFunctions[ definition.name ] = function () { @@ -3665,15 +3709,8 @@ export const REACTIVE_RUNTIME = String.raw` return special.value; } - // Template literals are evaluated without eval so they remain compatible - // with a strict CSP. Each interpolation uses the same bounded expression - // evaluator as every other reactive binding. - if ( - expr.length >= 2 && - expr[0] === "\`" && - expr[expr.length - 1] === "\`" - ) { - var template = expr.slice(1, -1); + function evaluateTemplateLiteral(source) { + var template = source.slice(1, -1); var rendered = ""; var cursor = 0; @@ -3703,7 +3740,7 @@ export const REACTIVE_RUNTIME = String.raw` else if (character === "}" && --depth === 0) break; } - if (depth !== 0) throw new Error("Unclosed template interpolation in '" + expr + "'"); + if (depth !== 0) throw new Error("Unclosed template interpolation in '" + source + "'"); rendered += String(evaluateExpression(template.slice(expressionStart, expressionEnd), read)); cursor = expressionEnd + 1; continue; @@ -3715,6 +3752,18 @@ export const REACTIVE_RUNTIME = String.raw` return rendered; } + // Template literals are evaluated without eval so they remain compatible + // with a strict CSP. Each interpolation uses the same bounded expression + // evaluator as every other reactive binding. They are accepted both as a + // complete expression and as a primary inside a larger expression. + if ( + expr.length >= 2 && + expr[0] === "\`" && + expr[expr.length - 1] === "\`" + ) { + return evaluateTemplateLiteral(expr); + } + var tokens = tokenizeExpression(expr); @@ -3731,6 +3780,7 @@ export const REACTIVE_RUNTIME = String.raw` var t = next(); if (!t) throw new Error("Unexpected end of expression in '" + expr + "'"); if (t.type === "number" || t.type === "string") return { value: t.value }; + if (t.type === "template") return { value: evaluateTemplateLiteral(t.value) }; if (t.type === "ident") { if (t.value === "true") return { value: true }; if (t.value === "false") return { value: false }; @@ -3874,6 +3924,37 @@ export const REACTIVE_RUNTIME = String.raw` tokens.push({ type: "string", value: value }); continue; } + if (ch === "\`") { + var templateStart = i++; + var templateDepth = 0; + var templateQuote = ""; + while (i < input.length) { + ch = input[i++]; + if (templateQuote) { + if (ch === "\\") i++; + else if (ch === templateQuote) templateQuote = ""; + continue; + } + if (ch === "\\") { + i++; + continue; + } + if (ch === "\`" && templateDepth === 0) break; + if (ch === "$" && input[i] === "{") { + templateDepth++; + i++; + continue; + } + if (templateDepth > 0) { + if (ch === '"' || ch === "'") templateQuote = ch; + else if (ch === "{") templateDepth++; + else if (ch === "}") templateDepth--; + } + } + if (input[i - 1] !== "\`") throw new Error("Unclosed template literal"); + tokens.push({ type: "template", value: input.slice(templateStart, i) }); + continue; + } if (/[A-Za-z_$]/.test(ch)) { var s = i++; while (i < input.length && /[A-Za-z0-9_$]/.test(input[i])) i++; @@ -4047,6 +4128,23 @@ export const REACTIVE_RUNTIME = String.raw` Array.from(node.childNodes || []).forEach(visit); } visit(root); + root.querySelectorAll("*").forEach(function (node) { + Array.from(node.attributes || []).forEach(function (attribute) { + if (attribute.name.indexOf("data-wrn-async-") === 0) return; + if (attribute.value.indexOf("{") === -1) return; + var nextValue = String(attribute.value).replace(/\{\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\}/g, function (whole, path) { + var parts = path.split("."); + if (parts[0] === prefix) parts.shift(); + if (prefix === "error" && parts[0] !== "message") return whole; + var value = asyncValueAt(model, parts.join(".")); + return typeof value === "object" ? JSON.stringify(value) : String(value); + }); + if (/^(?:href|src|action|formaction)$/i.test(attribute.name)) { + nextValue = sanitizeReactiveUrl(nextValue); + } + node.setAttribute(attribute.name, nextValue); + }); + }); root.querySelectorAll("[data-wrn-async-value]").forEach(function (node) { var path = node.getAttribute("data-wrn-async-value") || ""; var value = asyncValueAt(model, path); @@ -4058,7 +4156,9 @@ export const REACTIVE_RUNTIME = String.raw` var content = boundary.querySelector("[data-wrn-async-content]"); if (!template || !content) return; var fragment = template.content.cloneNode(true); - interpolateAsync(fragment, model, state === "error" ? "error" : boundary.getAttribute("data-wrn-async")); + var alias = template.getAttribute("data-wrn-async-alias") || + (state === "error" ? "error" : boundary.getAttribute("data-wrn-async")); + interpolateAsync(fragment, model, alias); content.replaceChildren(fragment); boundary.setAttribute("aria-busy", state === "loading" ? "true" : "false"); boundary.setAttribute("data-wrn-async-state", state); @@ -4073,7 +4173,7 @@ export const REACTIVE_RUNTIME = String.raw` } var name = boundary.getAttribute("data-wrn-async") || ""; if (!/^[A-Za-z_$][\w$]{0,63}$/.test(name)) return showAsync(boundary, "error", { message: "Invalid data source" }); - var url = "/__wrnexus/client-load?route=" + encodeURIComponent(location.pathname) + "&name=" + encodeURIComponent(name); + var url = "/__wrnexus/client-load?route=" + encodeURIComponent(location.pathname) + "&search=" + encodeURIComponent(location.search) + "&name=" + encodeURIComponent(name); var attempts = Math.min(5, Math.max(0, Number(boundary.getAttribute("data-wrn-async-retries")) || 0)); var controller = new AbortController(); boundary.__wrnexusAsyncAbort = controller; @@ -4102,6 +4202,44 @@ export const REACTIVE_RUNTIME = String.raw` (root || document).querySelectorAll("[data-wrn-async]").forEach(setupAsyncBoundary); } + var invalidationRefreshPending = false; + function asyncBoundaryTags(boundary) { + var raw = boundary.getAttribute("data-wrn-async-tags") || boundary.getAttribute("data-wrn-async") || ""; + return raw.split(",").map(function (tag) { return tag.trim(); }).filter(Boolean); + } + function refreshAsyncBoundary(boundary) { + if (boundary.__wrnexusAsyncAbort) { + try { boundary.__wrnexusAsyncAbort.abort(); } catch (_) {} + } + boundary.__wrnexusAsync = false; + boundary.removeAttribute("data-wrn-async-resolved"); + setupAsyncBoundary(boundary); + } + function handleCacheInvalidation(event) { + var detail = event && event.detail && typeof event.detail === "object" ? event.detail : {}; + var tags = Array.isArray(detail.tags) ? detail.tags.map(String) : []; + if (!tags.length) return; + var matchedServerBoundary = false; + document.querySelectorAll("[data-wrn-async]").forEach(function (boundary) { + var matches = asyncBoundaryTags(boundary).some(function (tag) { return tags.indexOf(tag) !== -1; }); + if (!matches) return; + if (boundary.getAttribute("data-wrn-async-resolved") === "true") { + matchedServerBoundary = true; + return; + } + refreshAsyncBoundary(boundary); + }); + if (matchedServerBoundary && !invalidationRefreshPending) { + invalidationRefreshPending = true; + Promise.resolve().then(function () { + invalidationRefreshPending = false; + if (typeof window.__wrnexusRefresh === "function") window.__wrnexusRefresh(); + else window.location.reload(); + }); + } + } + window.addEventListener("wrnexus:cache:invalidate", handleCacheInvalidation); + function mountClientRoots(root) { (root || document).querySelectorAll("template[data-wrn-client-template]").forEach(function (template) { var id = template.getAttribute("data-wrn-client-template"); diff --git a/packages/csr/src/realtime-runtime.ts b/packages/csr/src/realtime-runtime.ts index 654215f2..f3f37ff3 100644 --- a/packages/csr/src/realtime-runtime.ts +++ b/packages/csr/src/realtime-runtime.ts @@ -23,10 +23,29 @@ export const REALTIME_RUNTIME = String.raw` if (!("WebSocket" in window)) return; var wire = (window.wire = window.wire || {}); if (wire.room) return; // already installed - var open = {}; // name -> room connection + var open = {}; // normalized room+query key -> room connection + + function normalizedQuery(query) { + var params = new URLSearchParams(String(query || "").replace(/^\?/, "")); + var pairs = []; + params.forEach(function (value, name) { pairs.push([name, value]); }); + pairs.sort(function (a, b) { + return a[0] === b[0] ? String(a[1]).localeCompare(String(b[1])) : String(a[0]).localeCompare(String(b[0])); + }); + var normalized = new URLSearchParams(); + pairs.forEach(function (pair) { normalized.append(pair[0], pair[1]); }); + return normalized.toString(); + } + + function roomKey(name, query) { + var normalized = normalizedQuery(query); + return String(name || "") + (normalized ? "?" + normalized : ""); + } function openRoom(name, query) { - if (open[name]) return open[name]; + query = normalizedQuery(query); + var key = roomKey(name, query); + if (open[key]) return open[key]; var ws = null, queue = [], listeners = [], attempts = 0, timer = null, closed = false; function url() { @@ -69,6 +88,8 @@ export const REALTIME_RUNTIME = String.raw` var api = { name: name, + key: key, + query: query, send: function (obj) { var payload = typeof obj === "string" ? obj : JSON.stringify(obj); if (ws && ws.readyState === 1) ws.send(payload); @@ -80,9 +101,9 @@ export const REALTIME_RUNTIME = String.raw` listeners.push({ type: type, cb: cb }); return api; }, - close: function () { closed = true; clearTimeout(timer); if (ws) try { ws.close(); } catch (_) {} ws = null; delete open[name]; }, + close: function () { closed = true; clearTimeout(timer); if (ws) try { ws.close(); } catch (_) {} ws = null; delete open[key]; }, }; - open[name] = api; + open[key] = api; connect(); return api; } @@ -114,11 +135,14 @@ export const REALTIME_RUNTIME = String.raw` } function bindContainer(el) { - if (el.__wireRoomBound) return; - el.__wireRoomBound = true; var name = el.getAttribute("data-room"); var user = el.getAttribute("data-room-user"); - var room = openRoom(name, user ? "user=" + encodeURIComponent(user) : ""); + var query = el.getAttribute("data-room-query") || (user ? "user=" + encodeURIComponent(user) : ""); + var key = roomKey(name, query); + if (el.__wireRoomBound && el.__wireRoomKey === key) return; + el.__wireRoomBound = true; + el.__wireRoomKey = key; + var room = openRoom(name, query); el.__wireRoom = room; var log = el.querySelector("[data-room-log]"); @@ -161,7 +185,7 @@ export const REALTIME_RUNTIME = String.raw` var input = form.elements[i]; if (input.name) data[input.name] = input.value; } - room.send(data); + if (el.__wireRoom) el.__wireRoom.send(data); for (var j = 0; j < form.elements.length; j++) { if (form.elements[j].hasAttribute("data-room-reset")) form.elements[j].value = ""; } @@ -173,8 +197,8 @@ export const REALTIME_RUNTIME = String.raw` var containers = (root || document).querySelectorAll("[data-room]"); var present = {}; for (var i = 0; i < containers.length; i++) { - present[containers[i].getAttribute("data-room")] = true; bindContainer(containers[i]); + if (containers[i].__wireRoomKey) present[containers[i].__wireRoomKey] = true; } // Close rooms whose container has left the page (client-side navigation). for (var nm in open) if (!present[nm]) open[nm].close(); diff --git a/packages/csr/test/reactive.test.ts b/packages/csr/test/reactive.test.ts index 79120a13..d2d823fd 100644 --- a/packages/csr/test/reactive.test.ts +++ b/packages/csr/test/reactive.test.ts @@ -452,3 +452,124 @@ test("$emit inside component functions does not depend on a global browser event expect(win.document.querySelector("span")!.textContent).toBe("true"); expect(detail).toEqual({ source: "click" }); }); + +test("template literals work inside browser API call arguments", () => { + const behavior = Buffer.from( + JSON.stringify({ + functions: `function save() { + window.localStorage.setItem(\`prefs:\${count}\`, JSON.stringify(count)) + message = \`Saved \${count}\` + }`, + watches: [], + lifecycle: {}, + }), + ).toString("base64"); + const win = mount( + `
` + + `
`, + ); + win.document.querySelector("button")!.click(); + expect(win.localStorage.getItem("prefs:3")).toBe("3"); + expect(win.document.querySelector("button")!.textContent).toBe("Saved 3"); +}); + +test("compiled client functions are not overwritten by fallback behavior parsing", () => { + const behavior = Buffer.from( + JSON.stringify({ + functions: `function save() { message = \`fallback\` }`, + watches: [], + lifecycle: {}, + }), + ).toString("base64"); + const win = new Window() as unknown as Window & Record; + win.document.body.innerHTML = + `
` + + `
`; + const scope = win.document.getElementById("scope") as unknown as HTMLElement & { + __wrnexusClientModule?: unknown; + }; + scope.__wrnexusClientModule = { + bindClientScope(context: { state: Record }) { + return { + save() { + context.state.message = "native"; + }, + }; + }, + }; + (globalThis as Record).window = win; + (globalThis as Record).document = win.document; + (globalThis as Record).location = win.location; + (globalThis as Record).NodeFilter = ( + win as unknown as { NodeFilter: unknown } + ).NodeFilter; + (globalThis as Record).MutationObserver = ( + win as unknown as { MutationObserver: unknown } + ).MutationObserver; + (0, eval)(REACTIVE_RUNTIME); + (win as unknown as { __wrnexusHydrateScopes?: (root: unknown) => void }).__wrnexusHydrateScopes?.( + win.document, + ); + win.document.querySelector("button")!.click(); + expect(win.document.querySelector("button")!.textContent).toBe("native"); +}); + +test("$route updates after client navigation", () => { + const win = new Window({ url: "http://localhost/" }) as unknown as Window & + Record; + win.document.body.innerHTML = + `` + + `
{$route.pathname}{$route.params.id}
`; + (globalThis as Record).window = win; + (globalThis as Record).document = win.document; + (globalThis as Record).location = win.location; + (globalThis as Record).NodeFilter = ( + win as unknown as { NodeFilter: unknown } + ).NodeFilter; + (globalThis as Record).MutationObserver = ( + win as unknown as { MutationObserver: unknown } + ).MutationObserver; + (0, eval)(REACTIVE_RUNTIME); + (win as unknown as { __wrnexusHydrateScopes?: (root: unknown) => void }).__wrnexusHydrateScopes?.( + win.document, + ); + expect(win.document.querySelector("strong")!.textContent).toBe("/"); + expect(win.document.querySelector("em")!.textContent).toBe("42"); + expect(win.document.querySelector("[data-wrn-route-params]")).toBeNull(); + win.history.pushState({}, "", "/settings?tab=chat"); + win.dispatchEvent( + new (win as unknown as { CustomEvent: typeof CustomEvent }).CustomEvent( + "wrnexus:navigated", + ) as unknown as Parameters[0], + ); + expect(win.document.querySelector("strong")!.textContent).toBe("/settings"); +}); + +test("cache invalidation refetches matching client Async boundaries", async () => { + let requests = 0; + (globalThis as Record).fetch = () => { + requests++; + return Promise.resolve(Response.json({ data: { value: requests } })); + }; + const win = mount( + `
` + + `
` + + `` + + `` + + `` + + `
`, + ); + const runtime = win as unknown as { __wrnexusHydrateAsyncBoundaries?: (root: unknown) => void }; + runtime.__wrnexusHydrateAsyncBoundaries?.(win.document); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(win.document.querySelector("b")?.textContent).toBe("1"); + win.dispatchEvent( + new (win as unknown as { CustomEvent: typeof CustomEvent }).CustomEvent( + "wrnexus:cache:invalidate", + { detail: { tags: ["uploads"] } }, + ) as unknown as Parameters[0], + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(requests).toBe(2); + expect(win.document.querySelector("b")?.textContent).toBe("2"); +}); diff --git a/packages/csr/test/realtime.test.ts b/packages/csr/test/realtime.test.ts index 78a146a3..9818d88d 100644 --- a/packages/csr/test/realtime.test.ts +++ b/packages/csr/test/realtime.test.ts @@ -121,3 +121,27 @@ test("programmatic wire.room() sends and receives", () => { sockets[0]!.fireMessage({ type: "ping", n: 1 }); expect(got).toEqual([{ type: "ping", n: 1 }]); }); + +test("same room with different users opens identity-specific sockets", () => { + boot(` +
+
+ `); + expect(sockets.map((socket) => socket.url).sort()).toEqual([ + "ws://localhost/realtime/teamspace?user=asha", + "ws://localhost/realtime/teamspace?user=rohan", + ]); +}); + +test("declarative room reconnects when its user identity changes", () => { + const win = boot( + `
`, + ) as unknown as Window & { + wire: { bindRooms: (root?: unknown) => void }; + }; + expect(sockets[0]?.url).toBe("ws://localhost/realtime/teamspace?user=asha"); + win.document.getElementById("room")!.setAttribute("data-room-user", "neha"); + win.wire.bindRooms(win.document); + expect(sockets[0]?.readyState).toBe(3); + expect(sockets[1]?.url).toBe("ws://localhost/realtime/teamspace?user=neha"); +}); diff --git a/packages/db/package.json b/packages/db/package.json index ee5e232c..bd42a3e8 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/db", - "version": "0.8.2", + "version": "0.8.3", "private": true, "type": "module", "main": "./src/index.ts", diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index b70a666e..97298b3a 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-server", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/dev-server/src/index.ts b/packages/dev-server/src/index.ts index 9ea669ae..4519d03b 100644 --- a/packages/dev-server/src/index.ts +++ b/packages/dev-server/src/index.ts @@ -40,7 +40,7 @@ import { setCompileCacheDir, setCompileImportOptions, setDevCompilerPipeline, - wrnBrowserArtifactUrl, + wrnBrowserArtifactUrlAsync, } from "./pipeline.ts"; import { createRpcHandler } from "@wrnexus/ssr/rpc"; import { createHandlers, type WsData } from "./runtime.ts"; @@ -632,7 +632,7 @@ export async function startServer(opts: ServeOptions): Promise { storeUpdates.push({ name: declaration[2]!, kind: declaration[1]!, - url: wrnBrowserArtifactUrl(absolute), + url: await wrnBrowserArtifactUrlAsync(absolute), }); } catch (error) { console.warn(`[wrnexus] failed to prepare store HMR for ${absolute}`, error); diff --git a/packages/dev-server/src/pipeline.ts b/packages/dev-server/src/pipeline.ts index 8eb6f376..030d574b 100644 --- a/packages/dev-server/src/pipeline.ts +++ b/packages/dev-server/src/pipeline.ts @@ -14,7 +14,7 @@ import { unlinkSync, existsSync, } from "node:fs"; -import { dirname, join, basename, extname, resolve } from "node:path"; +import { dirname, join, basename, extname, relative, resolve } from "node:path"; import { compile, generate, @@ -91,6 +91,16 @@ export function setCompileImportOptions( const compileInProgress = new Map(); +function isApplicationImportSource( + source: string, + aliases: Record | undefined, +): boolean { + if (source.startsWith(".")) return true; + return Object.keys(aliases ?? {}).some( + (alias) => source === alias || source.startsWith(`${alias}/`), + ); +} + function projectRootForFile(file: string): string { let current = dirname(resolve(file)); while (true) { @@ -101,6 +111,19 @@ function projectRootForFile(file: string): string { } } +function importOptionsHash(file: string): string { + const root = resolve(projectRootForFile(file)); + const options = compileImportOptions.get(root) ?? { + mode: "compatible" as const, + aliases: { "@": "./app" }, + autoImport: true, + }; + const aliases = Object.fromEntries( + Object.entries(options.aliases).sort(([left], [right]) => left.localeCompare(right)), + ); + return hashPath(JSON.stringify({ ...options, aliases })); +} + function rewriteArtifactImports( code: string, ast: PageAst, @@ -132,8 +155,7 @@ function rewriteArtifactImports( } } if (!entry.resolved || !entry.declaration.source) continue; - if (!entry.declaration.source.startsWith(".") && !entry.declaration.source.startsWith("@/")) - continue; + if (!isApplicationImportSource(entry.declaration.source, importOptions.aliases)) continue; let replacement = entry.resolved; if (entry.resolved.endsWith(".wrn")) { const dependencySource = readFileSync(entry.resolved, "utf8"); @@ -148,13 +170,22 @@ function rewriteArtifactImports( output = output.replace(entry.declaration.raw, ""); continue; } - replacement = `/__wrnexus/client/${basename(dependency.browser).replace(/\.client\.mjs$/, ".mjs")}`; + replacement = dependency.browser; } else { replacement = target === "server" ? dependency.server : dependency.main; } } const escaped = entry.declaration.source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const specifier = replacement.startsWith("/") ? replacement : pathToFileURL(replacement).href; + const specifier = + target === "browser" + ? (() => { + const cacheDir = compileCacheDir ?? join(dirname(importer), ".wrnexus"); + const relativeTarget = relative(cacheDir, replacement).replace(/\\/g, "/"); + return relativeTarget.startsWith(".") ? relativeTarget : `./${relativeTarget}`; + })() + : replacement.startsWith("/") + ? replacement + : pathToFileURL(replacement).href; output = output.replace(new RegExp(`(["'])${escaped}\\1`, "g"), JSON.stringify(specifier)); } return output; @@ -169,10 +200,15 @@ async function rewriteArtifactImportsAsync( let output = code; for (const [id, replacement] of devCompilerPipeline?.virtualModules ?? []) { const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - output = output.replace( - new RegExp(`(["'])${escaped}\\1`, "g"), - JSON.stringify(pathToFileURL(replacement).href), - ); + const specifier = + target === "browser" + ? (() => { + const cacheDir = compileCacheDir ?? join(dirname(importer), ".wrnexus"); + const relativeTarget = relative(cacheDir, replacement).replace(/\\/g, "/"); + return relativeTarget.startsWith(".") ? relativeTarget : `./${relativeTarget}`; + })() + : pathToFileURL(replacement).href; + output = output.replace(new RegExp(`(["'])${escaped}\\1`, "g"), JSON.stringify(specifier)); } if (!ast.structuredImports.length) return output; const root = projectRootForFile(importer); @@ -189,8 +225,7 @@ async function rewriteArtifactImportsAsync( for (const entry of resolved) { if (entry.diagnostic?.severity === "error") throw new Error(entry.diagnostic.message); if (!entry.resolved || !entry.declaration.source) continue; - if (!entry.declaration.source.startsWith(".") && !entry.declaration.source.startsWith("@/")) - continue; + if (!isApplicationImportSource(entry.declaration.source, importOptions.aliases)) continue; let replacement = entry.resolved; if (replacement.endsWith(".wrn")) { const dependencySource = readFileSync(replacement, "utf8"); @@ -204,11 +239,20 @@ async function rewriteArtifactImportsAsync( output = output.replace(entry.declaration.raw, ""); continue; } - replacement = `/__wrnexus/client/${basename(dependency.browser).replace(/\.client\.mjs$/, ".mjs")}`; + replacement = dependency.browser; } else replacement = target === "server" ? dependency.server : dependency.main; } const escaped = entry.declaration.source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const specifier = replacement.startsWith("/") ? replacement : pathToFileURL(replacement).href; + const specifier = + target === "browser" + ? (() => { + const cacheDir = compileCacheDir ?? join(dirname(importer), ".wrnexus"); + const relativeTarget = relative(cacheDir, replacement).replace(/\\/g, "/"); + return relativeTarget.startsWith(".") ? relativeTarget : `./${relativeTarget}`; + })() + : replacement.startsWith("/") + ? replacement + : pathToFileURL(replacement).href; output = output.replace(new RegExp(`(["'])${escaped}\\1`, "g"), JSON.stringify(specifier)); } return output; @@ -257,14 +301,14 @@ export async function loadModule(file: string): Promise> * When unset, compilation falls back to a sibling `.wrnexus/` next to each file. */ let compileCacheDir: string | null = null; -const WRN_COMPILE_CACHE_VERSION = "v2"; +const WRN_COMPILE_CACHE_VERSION = "v3"; /** * Point all `.wrn` compilation at ONE cache dir (typically `/.wrnexus`) * instead of scattering a `.wrnexus/` folder next to every `.wrn` source. Called * once by the dev server at startup. */ -export function setCompileCacheDir(dir: string): void { +export function setCompileCacheDir(dir: string | null): void { compileCacheDir = dir; } @@ -357,18 +401,68 @@ const compileMetrics: WrnCompileMetrics = { }; const asyncCompileInProgress = new Map>(); +async function bundleBrowserArtifact( + code: string, + file: string, + cacheDir: string, + stem: string, +): Promise { + const hasModuleImport = /(?:^|\n)\s*import(?:\s|["'])|\bimport\s*\(/m.test(code); + if (!hasModuleImport || code.includes("wrnexus-client-bundled")) return code; + const bun = (globalThis as any).Bun; + if (!bun?.build) { + throw new Error( + `WRN-CLIENT-BUNDLE: ${file} has browser imports, but the Bun bundler is unavailable.`, + ); + } + const entry = join(cacheDir, `${stem}.browser-entry.mjs`); + writeFileSync(entry, code, "utf8"); + try { + const result = await bun.build({ + entrypoints: [entry], + target: "browser", + format: "esm", + splitting: false, + minify: false, + sourcemap: "inline", + }); + if (!result.success || !result.outputs?.length) { + const detail = (result.logs ?? []).map(String).join("\n"); + throw new Error(`WRN-CLIENT-BUNDLE: failed to bundle ${file}${detail ? `\n${detail}` : ""}`); + } + return `// wrnexus-client-bundled\n${await result.outputs[0].text()}`; + } finally { + try { + unlinkSync(entry); + } catch { + // Best-effort cleanup; cache pruning removes stale temporary entries. + } + } +} + export function compileWireArtifactsAsync(file: string, version = 0): Promise { - if (!devCompilerPipeline) return Promise.resolve(compileWireArtifacts(file, version)); const key = `${file}:${version}`; const active = asyncCompileInProgress.get(key); if (active) return active; const task = (async () => { + if (!devCompilerPipeline) { + const artifacts = compileWireArtifacts(file, version); + const code = readFileSync(artifacts.browser, "utf8"); + const bundled = await bundleBrowserArtifact( + code, + file, + dirname(artifacts.browser), + basename(artifacts.browser, ".client.mjs"), + ); + if (bundled !== code) writeFileSync(artifacts.browser, bundled, "utf8"); + return artifacts; + } const cacheDir = compileCacheDir ?? join(dirname(file), ".wrnexus"); const name = basename(file).replace(/\.wrn$/, ""); const suffix = version ? `-hmr-${version}` : ""; const source = readFileSync(file, "utf8"); // Plugin output affects the artifact, so use a separate cache generation. - const stem = `${name}-${WRN_COMPILE_CACHE_VERSION}-plugin-${hashPath(file)}-${hashPath(source)}${suffix}`; + const stem = `${name}-${WRN_COMPILE_CACHE_VERSION}-plugin-${hashPath(file)}-${hashPath(source)}-${importOptionsHash(file)}${suffix}`; const artifacts: WrnCompileArtifacts = { main: join(cacheDir, `${stem}.wrn.ts`), browser: join(cacheDir, `${stem}.client.mjs`), @@ -394,11 +488,11 @@ export function compileWireArtifactsAsync(file: string, version = 0): Promise { + const artifact = (await compileWireArtifactsAsync(file, moduleVersions.get(file) ?? 0)).browser; + return `/__wrnexus/client/${basename(artifact).replace(/\.client\.mjs$/, ".mjs")}`; +} + export function serveWrnBrowserArtifact(pathname: string): Response | null { const artifact = browserArtifactPaths.get(pathname); if (!artifact || !existsSync(artifact)) return null; diff --git a/packages/dev-server/src/prod.ts b/packages/dev-server/src/prod.ts index 5cc7185a..b0c14201 100644 --- a/packages/dev-server/src/prod.ts +++ b/packages/dev-server/src/prod.ts @@ -9,6 +9,7 @@ * supervised `dev --production-runtime` mode can explicitly enable it. */ +import { join } from "node:path"; import type { Middleware, Mode, SecurityConfig, SeoConfig } from "@wrnexus/core"; import { compileRoutePattern, @@ -88,6 +89,8 @@ export interface ProdOptions { stylesIncludeFramework?: boolean; /** Absolute path to the pre-built reactive runtime. */ reactivePath?: string; + /** Absolute directory containing bundled per-WRN browser modules. */ + clientModulesDir?: string; /** Absolute path to the pre-built theme stylesheet (`theme.css`). */ themePath?: string; /** Absolute path to the pre-built theme runtime (`theme.js`). */ @@ -267,6 +270,13 @@ async function serveFile(path: string | undefined, headers: Record { + if (pathname.startsWith("/__wrnexus/client/")) { + const name = pathname.slice("/__wrnexus/client/".length); + if (!opts.clientModulesDir || !/^[A-Za-z0-9._-]+\.mjs$/.test(name)) { + return new Response("Not Found", { status: 404 }); + } + return serveFile(join(opts.clientModulesDir, name), JS_HEADERS); + } if (pathname === "/__wrnexus/reactive.js") { if (opts.reactivePath) { const file = Bun.file(opts.reactivePath); diff --git a/packages/dev-server/src/runtime.ts b/packages/dev-server/src/runtime.ts index d14e8c79..e8534bc2 100644 --- a/packages/dev-server/src/runtime.ts +++ b/packages/dev-server/src/runtime.ts @@ -1279,11 +1279,14 @@ export function createHandlers(deps: RuntimeDeps): Handlers { async function handleClientLoad(ctx: Context): Promise { const routePath = ctx.url.searchParams.get("route") ?? ""; + const routeSearch = ctx.url.searchParams.get("search") ?? ""; const name = ctx.url.searchParams.get("name") ?? ""; if ( !routePath.startsWith("/") || routePath.startsWith("/__wrnexus/") || !isSafeRequestPath(routePath) || + (routeSearch !== "" && + (!routeSearch.startsWith("?") || routeSearch.includes("#") || routeSearch.length > 4096)) || !/^[A-Za-z_$][\w$]{0,63}$/.test(name) ) { return new Response("Not Found", { status: 404 }); @@ -1294,8 +1297,11 @@ export function createHandlers(deps: RuntimeDeps): Handlers { const load = pageModule.__wrnexusClientLoad; if (typeof load !== "function") return new Response("Not Found", { status: 404 }); try { - ctx.params = page.params; - const values = await load(ctx); + const routeUrl = new URL(routePath + routeSearch, ctx.url.origin); + if (routeUrl.pathname !== routePath) return new Response("Not Found", { status: 404 }); + const routeRequest = new Request(routeUrl, { method: "GET", headers: ctx.req.headers }); + const loadCtx: Context = { ...ctx, req: routeRequest, url: routeUrl, params: page.params }; + const values = await load(loadCtx); if (!values || typeof values !== "object" || !(name in values)) { return new Response("Not Found", { status: 404 }); } @@ -1627,6 +1633,10 @@ export function createHandlers(deps: RuntimeDeps): Handlers { if (partial) { body = typeof precomputedShell === "string" ? precomputedShell : (pagePartial?.shell ?? body); } + const routeParamsMarker = ``; + body = `${routeParamsMarker}${body}`; if (body.includes("data-wrn-action=")) { body = body.replace( /(]*\bdata-wrn-action=(?:"[^"]+"|'[^']+')[^>]*>)/gi, diff --git a/packages/dev-server/test/browser-bundle-0.8.3.test.ts b/packages/dev-server/test/browser-bundle-0.8.3.test.ts new file mode 100644 index 00000000..0607aead --- /dev/null +++ b/packages/dev-server/test/browser-bundle-0.8.3.test.ts @@ -0,0 +1,90 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + compileWireArtifactsAsync, + setCompileCacheDir, + setCompileImportOptions, +} from "../src/pipeline.ts"; + +test("development browser artifacts bundle local client imports", async () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-client-bundle-")); + const app = join(root, "app"); + const cache = join(root, ".wrnexus"); + mkdirSync(app, { recursive: true }); + writeFileSync(join(app, "helper.ts"), "export function save(value: unknown) { return value; }\n"); + const page = join(app, "settings.wrn"); + writeFileSync( + page, + `import { save } from "./helper.ts" +page Settings { + state { value = 1 } + functions { client function persist() { return save(value) } } + view { } +}`, + ); + + try { + setCompileCacheDir(cache); + const artifacts = await compileWireArtifactsAsync(page); + const browser = readFileSync(artifacts.browser, "utf8"); + expect(browser).toContain("wrnexus-client-bundled"); + expect(browser).not.toContain("file://"); + expect(browser).not.toContain('from "./helper.ts"'); + } finally { + setCompileCacheDir(null); + rmSync(root, { recursive: true, force: true }); + } +}); + +test("development browser artifacts bundle configured alias imports", async () => { + const root = mkdtempSync(join(tmpdir(), "wrnexus-client-alias-bundle-")); + const app = join(root, "app"); + const cache = join(root, ".wrnexus"); + mkdirSync(join(app, "client"), { recursive: true }); + writeFileSync( + join(app, "client", "storage.ts"), + "export function persist(value: unknown) { return value; }\n", + ); + const page = join(app, "settings.wrn"); + writeFileSync( + page, + `import { persist } from "~/client/storage.ts" +page Settings { + state { value = 1 } + functions { client function save() { return persist(value) } } + view { } +}`, + ); + + try { + setCompileCacheDir(cache); + setCompileImportOptions(root, { + mode: "explicit", + aliases: { "~": "./app" }, + autoImport: false, + }); + const artifacts = await compileWireArtifactsAsync(page); + const browser = readFileSync(artifacts.browser, "utf8"); + expect(browser).toContain("wrnexus-client-bundled"); + expect(browser).not.toContain("~/client/storage.ts"); + expect(browser).not.toContain("file://"); + + mkdirSync(join(root, "alternate", "client"), { recursive: true }); + writeFileSync( + join(root, "alternate", "client", "storage.ts"), + "export function persist(value: unknown) { return value; }\n", + ); + setCompileImportOptions(root, { + mode: "explicit", + aliases: { "~": "./alternate" }, + autoImport: false, + }); + const changed = await compileWireArtifactsAsync(page); + expect(changed.browser).not.toBe(artifacts.browser); + } finally { + setCompileCacheDir(null); + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/dev-server/test/production-hmr.test.ts b/packages/dev-server/test/production-hmr.test.ts index c7759284..c778b35d 100644 --- a/packages/dev-server/test/production-hmr.test.ts +++ b/packages/dev-server/test/production-hmr.test.ts @@ -62,7 +62,9 @@ test("production streams request regions into the build-time static shell", asyn test("normal production output remains free of development HMR", async () => { const handlers = createProductionHandlers(manifest, {}); const response = await handlers.fetch(new Request("http://localhost/"), server); - expect(await (response as Response).text()).not.toContain("/__wrnexus/hmr"); + const html = await (response as Response).text(); + expect(html).not.toContain("/__wrnexus/hmr"); + expect(html).toContain('data-wrn-route-params="{}"'); }); test("production client-load endpoint returns only the requested named result", async () => { @@ -121,3 +123,43 @@ test("production prefers fast gzip for dynamic HTML and cached Brotli for immuta )) as Response; expect(head.headers.get("content-encoding")).toBeNull(); }); + +test("client-load preserves the active route query string", async () => { + const queryManifest: ProdManifest = { + ...manifest, + pages: [ + ...manifest.pages, + { + raw: "/query", + mod: { + default: () => "
Query page
", + __wrnexusClientLoad: async (ctx: { url: URL }) => ({ + identity: ctx.url.searchParams.get("as"), + }), + }, + }, + ], + }; + const handlers = createProductionHandlers(queryManifest, {}); + const response = (await handlers.fetch( + new Request( + "http://localhost/__wrnexus/client-load?route=%2Fquery&search=%3Fas%3Drohan&name=identity", + ), + server, + )) as Response; + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ data: "rohan" }); +}); + +test("production serves generated WRN browser modules from the client directory", async () => { + const directory = mkdtempSync(join(tmpdir(), "wrnexus-client-modules-")); + writeFileSync(join(directory, "settings-abc123.mjs"), "export const ready = true;\n"); + const handlers = createProductionHandlers(manifest, { clientModulesDir: directory }); + const response = (await handlers.fetch( + new Request("http://localhost/__wrnexus/client/settings-abc123.mjs"), + server, + )) as Response; + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("text/javascript"); + expect(await response.text()).toContain("ready = true"); +}); diff --git a/packages/dev-toolbar/package.json b/packages/dev-toolbar/package.json index 91fae38e..7a72f241 100644 --- a/packages/dev-toolbar/package.json +++ b/packages/dev-toolbar/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-toolbar", - "version": "0.8.2", + "version": "0.8.3", "private": true, "type": "module", "sideEffects": false, diff --git a/packages/encryption/package.json b/packages/encryption/package.json index 045f97b2..e3418d56 100644 --- a/packages/encryption/package.json +++ b/packages/encryption/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/encryption", - "version": "0.8.2", + "version": "0.8.3", "private": true, "type": "module", "main": "./src/index.ts", diff --git a/packages/graphql/package.json b/packages/graphql/package.json index a36cd98e..f914813c 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/graphql", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "main": "./src/index.ts", "exports": { diff --git a/packages/helpers/package.json b/packages/helpers/package.json index a3b4d6d2..6d3234cd 100644 --- a/packages/helpers/package.json +++ b/packages/helpers/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/helpers", - "version": "0.8.2", + "version": "0.8.3", "private": true, "type": "module", "description": "Safe convenience helpers for WrNexus request contexts and common application flows.", diff --git a/packages/i18n/components/LocaleStatus.wrn b/packages/i18n/components/LocaleStatus.wrn index 8c6d081e..24f3fc58 100644 --- a/packages/i18n/components/LocaleStatus.wrn +++ b/packages/i18n/components/LocaleStatus.wrn @@ -1,3 +1,5 @@ +import { Badge, Card } from "@wrnexus/ui" + component LocaleStatus { props { locale: string = "en" diff --git a/packages/i18n/package.json b/packages/i18n/package.json index b2545a62..cf933c9d 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/i18n", - "version": "0.8.2", + "version": "0.8.3", "private": true, "type": "module", "main": "./src/index.ts", diff --git a/packages/identity/package.json b/packages/identity/package.json index fd49ac90..8a08b70f 100644 --- a/packages/identity/package.json +++ b/packages/identity/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/identity", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "Enterprise federation, provisioning, machine identity, and privacy governance for WRNexusJS.", "main": "src/index.ts", diff --git a/packages/image/components/ImageCard.wrn b/packages/image/components/ImageCard.wrn index 34c328d9..14064c7d 100644 --- a/packages/image/components/ImageCard.wrn +++ b/packages/image/components/ImageCard.wrn @@ -1,3 +1,6 @@ +import OptimizedImage from "./OptimizedImage.wrn" +import { Card } from "@wrnexus/ui" + component ImageCard { props { title: string = "" diff --git a/packages/image/package.json b/packages/image/package.json index 505b7ecf..1646eb48 100644 --- a/packages/image/package.json +++ b/packages/image/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/image", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "Responsive image planning, secure remote image policies, and performance auditing for WRNexusJS.", "main": "./src/index.ts", diff --git a/packages/jwt/package.json b/packages/jwt/package.json index 6ecb82c4..0fceb691 100644 --- a/packages/jwt/package.json +++ b/packages/jwt/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/jwt", - "version": "0.8.2", + "version": "0.8.3", "private": true, "type": "module", "main": "./src/index.ts", diff --git a/packages/language-server/package.json b/packages/language-server/package.json index 43f96b2e..f240d418 100644 --- a/packages/language-server/package.json +++ b/packages/language-server/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/language-server", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "Editor-neutral Language Server Protocol implementation for WRNexus .wrn files.", "main": "src/index.ts", diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 8e090447..a107e1fd 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/mcp", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "Model Context Protocol server exposing WRNexus application and framework context.", "main": "src/index.ts", diff --git a/packages/mobile/package.json b/packages/mobile/package.json index b1e898b0..687faf7a 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/mobile", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/native/package.json b/packages/native/package.json index 624d657e..2da17559 100644 --- a/packages/native/package.json +++ b/packages/native/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/native", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/oauth/package.json b/packages/oauth/package.json index 4b5e2a24..b70efccf 100644 --- a/packages/oauth/package.json +++ b/packages/oauth/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/oauth", - "version": "0.8.2", + "version": "0.8.3", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/observability/package.json b/packages/observability/package.json index b14de546..7ec6f5e1 100644 --- a/packages/observability/package.json +++ b/packages/observability/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/observability", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "Metrics, Web Vitals collection, request instrumentation, and exporter adapters for WRNexusJS.", "main": "src/index.ts", diff --git a/packages/playground/package.json b/packages/playground/package.json index 09774154..cffd13e7 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/playground", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "Secure, shareable WRNexus compiler and UI playground.", "main": "src/index.ts", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 59e2d7e2..f58e3cad 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/plugin", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/pubsub/package.json b/packages/pubsub/package.json index 4ff74777..7c7eff4a 100644 --- a/packages/pubsub/package.json +++ b/packages/pubsub/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/pubsub", - "version": "0.8.2", + "version": "0.8.3", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/pwa/package.json b/packages/pwa/package.json index 4c0a162d..ad98507f 100644 --- a/packages/pwa/package.json +++ b/packages/pwa/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/pwa", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "Service workers, manifests, offline queues, background sync, push, and conflict resolution for WRNexusJS.", "main": "./src/index.ts", diff --git a/packages/queue/package.json b/packages/queue/package.json index 75ee09ab..4ed158ab 100644 --- a/packages/queue/package.json +++ b/packages/queue/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/queue", - "version": "0.8.2", + "version": "0.8.3", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/reactive/package.json b/packages/reactive/package.json index 7c139da5..b0a66fe0 100644 --- a/packages/reactive/package.json +++ b/packages/reactive/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/reactive", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/realtime/components/MessageBubble.wrn b/packages/realtime/components/MessageBubble.wrn index 8c401ed9..26c9de56 100644 --- a/packages/realtime/components/MessageBubble.wrn +++ b/packages/realtime/components/MessageBubble.wrn @@ -1,3 +1,5 @@ +import { ChatBubble } from "@wrnexus/ui" + component RealtimeMessageBubble { props { id: string = "" diff --git a/packages/realtime/components/MessageComposer.wrn b/packages/realtime/components/MessageComposer.wrn index 11dc8103..50f408dc 100644 --- a/packages/realtime/components/MessageComposer.wrn +++ b/packages/realtime/components/MessageComposer.wrn @@ -1,3 +1,5 @@ +import { Button, Textarea } from "@wrnexus/ui" + component MessageComposer { props { name: string = "text" diff --git a/packages/realtime/components/PresenceList.wrn b/packages/realtime/components/PresenceList.wrn index 5b29c713..c5faaa88 100644 --- a/packages/realtime/components/PresenceList.wrn +++ b/packages/realtime/components/PresenceList.wrn @@ -1,3 +1,5 @@ +import { Avatar, Badge, Card } from "@wrnexus/ui" + component PresenceList { props { members: unknown[] = [] diff --git a/packages/realtime/components/RealtimeRoom.wrn b/packages/realtime/components/RealtimeRoom.wrn index bbb46ca3..7b35888c 100644 --- a/packages/realtime/components/RealtimeRoom.wrn +++ b/packages/realtime/components/RealtimeRoom.wrn @@ -1,3 +1,6 @@ +import RoomStatus from "./RoomStatus.wrn" +import { Card } from "@wrnexus/ui" + component RealtimeRoom { props { room: string = "" diff --git a/packages/realtime/components/RoomMeta.wrn b/packages/realtime/components/RoomMeta.wrn index 24f417bb..49742deb 100644 --- a/packages/realtime/components/RoomMeta.wrn +++ b/packages/realtime/components/RoomMeta.wrn @@ -1,3 +1,5 @@ +import { Badge, Card } from "@wrnexus/ui" + component RoomMeta { props { name: string = "Room" diff --git a/packages/realtime/components/RoomStatus.wrn b/packages/realtime/components/RoomStatus.wrn index 10fca6de..fa42e9d6 100644 --- a/packages/realtime/components/RoomStatus.wrn +++ b/packages/realtime/components/RoomStatus.wrn @@ -1,3 +1,5 @@ +import { Badge } from "@wrnexus/ui" + component RoomStatus { props { label: string = "Connecting…" diff --git a/packages/realtime/components/TypingIndicator.wrn b/packages/realtime/components/TypingIndicator.wrn index 738e6949..52b1f99c 100644 --- a/packages/realtime/components/TypingIndicator.wrn +++ b/packages/realtime/components/TypingIndicator.wrn @@ -1,3 +1,5 @@ +import { Alert } from "@wrnexus/ui" + component TypingIndicator { props { names: unknown[] = [] diff --git a/packages/realtime/package.json b/packages/realtime/package.json index 301de06d..6478c17c 100644 --- a/packages/realtime/package.json +++ b/packages/realtime/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/realtime", - "version": "0.8.2", + "version": "0.8.3", "description": "Typed realtime rooms, message helpers, presence utilities, package UI blocks, and WRNexusJS integration.", "type": "module", "sideEffects": false, diff --git a/packages/router/package.json b/packages/router/package.json index f071dcf5..9edb7b8d 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/router", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/security/package.json b/packages/security/package.json index 43fec958..f75daeec 100644 --- a/packages/security/package.json +++ b/packages/security/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/security", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "description": "Security policies, safe serialization, SSRF protection, request limits, and secure cookie helpers for WRNexusJS.", "main": "src/index.ts", diff --git a/packages/ssr/package.json b/packages/ssr/package.json index 6c105cde..60d7a175 100644 --- a/packages/ssr/package.json +++ b/packages/ssr/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ssr", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/store/package.json b/packages/store/package.json index 7e9f7415..56c49de0 100644 --- a/packages/store/package.json +++ b/packages/store/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/store", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/styles/package.json b/packages/styles/package.json index cf3efa4f..14e9edb7 100644 --- a/packages/styles/package.json +++ b/packages/styles/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/styles", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/syntax/package.json b/packages/syntax/package.json index f5ec3b84..b99970a3 100644 --- a/packages/syntax/package.json +++ b/packages/syntax/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/syntax", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/test/package.json b/packages/test/package.json index ca1028c4..135eefe9 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/test", - "version": "0.8.2", + "version": "0.8.3", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/tracking/package.json b/packages/tracking/package.json index 8967b559..33e6aea4 100644 --- a/packages/tracking/package.json +++ b/packages/tracking/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/tracking", - "version": "0.8.2", + "version": "0.8.3", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/typecheck/package.json b/packages/typecheck/package.json index cd93cd01..5c150274 100644 --- a/packages/typecheck/package.json +++ b/packages/typecheck/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/typecheck", - "version": "0.8.2", + "version": "0.8.3", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/ui/components/AuthForm.wrn b/packages/ui/components/AuthForm.wrn index 700e11c6..b5aee449 100644 --- a/packages/ui/components/AuthForm.wrn +++ b/packages/ui/components/AuthForm.wrn @@ -1,3 +1,8 @@ +import Button from "./button.wrn" +import Checkbox from "./Checkbox.wrn" +import Input from "./Input.wrn" +import PinInput from "./PinInput.wrn" + component AuthForm { outputs { submit(payload: { event: Event; mode: string; action: string }) diff --git a/packages/ui/components/FeatureGrid.wrn b/packages/ui/components/FeatureGrid.wrn index c746d7d3..50337067 100644 --- a/packages/ui/components/FeatureGrid.wrn +++ b/packages/ui/components/FeatureGrid.wrn @@ -1,3 +1,5 @@ +import FeatureCard from "./FeatureCard.wrn" + component FeatureGrid { props { size: string = "default" diff --git a/packages/ui/components/FeatureIconCard.wrn b/packages/ui/components/FeatureIconCard.wrn index 6e873123..1cc766c4 100644 --- a/packages/ui/components/FeatureIconCard.wrn +++ b/packages/ui/components/FeatureIconCard.wrn @@ -1,3 +1,5 @@ +import TextLink from "./TextLink.wrn" + component FeatureIconCard { props { icon: string = "icon-[lucide--sparkles]" diff --git a/packages/ui/components/Hero.wrn b/packages/ui/components/Hero.wrn index 467bc6bb..cdc0fceb 100644 --- a/packages/ui/components/Hero.wrn +++ b/packages/ui/components/Hero.wrn @@ -1,3 +1,5 @@ +import Badge from "./Badge.wrn" + component Hero { props { eyebrow: string = "" diff --git a/packages/ui/components/HeroActions.wrn b/packages/ui/components/HeroActions.wrn index a4fde7f1..ed7be41e 100644 --- a/packages/ui/components/HeroActions.wrn +++ b/packages/ui/components/HeroActions.wrn @@ -1,3 +1,5 @@ +import Button from "./button.wrn" + component HeroActions { props { actions: unknown[] = [] diff --git a/packages/ui/components/MarketingSectionHeader.wrn b/packages/ui/components/MarketingSectionHeader.wrn index b3ca1fb6..d3eb5a6b 100644 --- a/packages/ui/components/MarketingSectionHeader.wrn +++ b/packages/ui/components/MarketingSectionHeader.wrn @@ -1,3 +1,6 @@ +import SectionHeader from "./SectionHeader.wrn" +import TextLink from "./TextLink.wrn" + component MarketingSectionHeader { props { id: string = "" diff --git a/packages/ui/components/MetricGrid.wrn b/packages/ui/components/MetricGrid.wrn index b38ef3ae..0c1cf965 100644 --- a/packages/ui/components/MetricGrid.wrn +++ b/packages/ui/components/MetricGrid.wrn @@ -1,3 +1,5 @@ +import MetricCard from "./MetricCard.wrn" + component MetricGrid { outputs { select(payload: { item: string | number | boolean | null | object; itemIndex: number; sourceEvent: Event }) diff --git a/packages/ui/components/PageHeader.wrn b/packages/ui/components/PageHeader.wrn index bd256912..34f6cccb 100644 --- a/packages/ui/components/PageHeader.wrn +++ b/packages/ui/components/PageHeader.wrn @@ -1,3 +1,6 @@ +import Breadcrumb from "./Breadcrumb.wrn" +import Button from "./button.wrn" + component PageHeader { props { eyebrow: string = "" diff --git a/packages/ui/components/Section.wrn b/packages/ui/components/Section.wrn index e4559e40..cc05729b 100644 --- a/packages/ui/components/Section.wrn +++ b/packages/ui/components/Section.wrn @@ -1,3 +1,5 @@ +import Container from "./Container.wrn" + component Section { props { id: string = "" diff --git a/packages/ui/package.json b/packages/ui/package.json index 636a77e5..465bdcb8 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/ui", - "version": "0.8.2", + "version": "0.8.3", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/uploader/components/UploadDropzone.wrn b/packages/uploader/components/UploadDropzone.wrn index 1ff625b6..dacb1c08 100644 --- a/packages/uploader/components/UploadDropzone.wrn +++ b/packages/uploader/components/UploadDropzone.wrn @@ -1,3 +1,5 @@ +import { Card } from "@wrnexus/ui" + component UploadDropzone { props { store: string = "default" @@ -6,6 +8,7 @@ component UploadDropzone { maxBytes: number = 0 multiple: boolean = false field: string = "file" + invalidate: string = "uploads" label: string = "Drag files here or click to browse" title: string = "Upload files" description: string = "Files are validated and uploaded securely." @@ -15,7 +18,7 @@ component UploadDropzone { } view { -
+
} } diff --git a/packages/uploader/components/UploadStatus.wrn b/packages/uploader/components/UploadStatus.wrn index d945112a..716b3d9a 100644 --- a/packages/uploader/components/UploadStatus.wrn +++ b/packages/uploader/components/UploadStatus.wrn @@ -1,3 +1,5 @@ +import { Alert, Badge, Card } from "@wrnexus/ui" + component UploadStatus { props { files: unknown[] = [] diff --git a/packages/uploader/package.json b/packages/uploader/package.json index dbdd66da..2d577ee4 100644 --- a/packages/uploader/package.json +++ b/packages/uploader/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/uploader", - "version": "0.8.2", + "version": "0.8.3", "private": true, "type": "module", "main": "./src/index.ts", diff --git a/packages/uploader/src/runtime.ts b/packages/uploader/src/runtime.ts index d4f9db34..3753f165 100644 --- a/packages/uploader/src/runtime.ts +++ b/packages/uploader/src/runtime.ts @@ -92,6 +92,10 @@ export const UPLOAD_RUNTIME = ` var accept = root.getAttribute("data-accept") || ""; var maxBytes = parseInt(root.getAttribute("data-max") || "0", 10) || 0; var field = root.getAttribute("data-field") || (multiple ? "files" : "file"); + var invalidate = String(root.getAttribute("data-invalidate") || "") + .split(",") + .map(function (tag) { return tag.trim(); }) + .filter(Boolean); var promptText = root.getAttribute("data-label") || "Drag files here or click to browse"; root.classList.add("wire-uploader"); @@ -183,6 +187,11 @@ export const UPLOAD_RUNTIME = ` ui.fill.style.width = "100%"; ui.status.textContent = "\\u2713 Uploaded"; root.dispatchEvent(new CustomEvent("wrnexus:upload", { bubbles: true, detail: { file: file, result: info } })); + if (invalidate.length) { + window.dispatchEvent(new CustomEvent("wrnexus:cache:invalidate", { + detail: { tags: invalidate, source: "uploader", file: file, result: info } + })); + } } function fail(ui, msg, file) { ui.li.classList.add("is-error"); diff --git a/packages/validation/components/ValidationSummary.wrn b/packages/validation/components/ValidationSummary.wrn index 8c78e7e2..f265e5a1 100644 --- a/packages/validation/components/ValidationSummary.wrn +++ b/packages/validation/components/ValidationSummary.wrn @@ -1,3 +1,5 @@ +import { Alert } from "@wrnexus/ui" + component ValidationSummary { props { errors: unknown[] = [] diff --git a/packages/validation/package.json b/packages/validation/package.json index f8356ded..a32892e3 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/validation", - "version": "0.8.2", + "version": "0.8.3", "private": true, "type": "module", "main": "./src/index.ts", diff --git a/scripts/build-editor-compiler.mjs b/scripts/build-editor-compiler.mjs index 9d5f7169..98ae3b41 100644 --- a/scripts/build-editor-compiler.mjs +++ b/scripts/build-editor-compiler.mjs @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { dirname, join, relative, resolve, sep } from "node:path"; @@ -7,6 +8,15 @@ import { fileURLToPath } from "node:url"; const require = createRequire(import.meta.url); const here = dirname(fileURLToPath(import.meta.url)); const root = resolve(here, ".."); +const scriptPath = fileURLToPath(import.meta.url); + +function normalizeText(value) { + return value.replace(/\r\n?/g, "\n"); +} + +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} function loadTypeScript() { const candidates = [process.env.TYPESCRIPT_PATH, "typescript"].filter(Boolean); @@ -44,9 +54,15 @@ const sourceFiles = [ ...walk(join(root, "packages", "compiler", "src")), ].sort(); +const sourceHashBuilder = createHash("sha256"); const modules = []; for (const file of sourceFiles) { - const source = readFileSync(file, "utf8"); + const id = moduleId(file); + const source = normalizeText(readFileSync(file, "utf8")); + sourceHashBuilder.update(id); + sourceHashBuilder.update("\0"); + sourceHashBuilder.update(source); + sourceHashBuilder.update("\0"); const result = ts.transpileModule(source, { fileName: file, reportDiagnostics: true, @@ -60,6 +76,7 @@ for (const file of sourceFiles) { inlineSourceMap: false, removeComments: false, rewriteRelativeImportExtensions: true, + newLine: ts.NewLineKind.LineFeed, }, }); const diagnostics = result.diagnostics ?? []; @@ -72,12 +89,18 @@ for (const file of sourceFiles) { throw new Error(message); } modules.push( - `${JSON.stringify(moduleId(file))}: function (module, exports, require, __filename, __dirname) {\n${result.outputText}\n}`, + `${JSON.stringify(id)}: function (module, exports, require, __filename, __dirname) {\n${normalizeText(result.outputText)}\n}`, ); } +const sourceHash = sourceHashBuilder.digest("hex"); +const generatorHash = sha256(normalizeText(readFileSync(scriptPath, "utf8"))); + const output = `"use strict"; // Generated by scripts/build-editor-compiler.mjs. Do not edit directly. +// WRN editor compiler source hash: ${sourceHash} +// WRN editor compiler generator hash: ${generatorHash} +// Generated with TypeScript: ${ts.version} const __nodeRequire = require; const __path = __nodeRequire("node:path"); const __modules = { @@ -129,17 +152,35 @@ module.exports = __load("packages/compiler/src/index.ts"); const destination = join(root, "editors", "vscode", "src", "compiler.cjs"); if (process.argv.includes("--check")) { - if (!existsSync(destination) || readFileSync(destination, "utf8") !== output) { + const existing = existsSync(destination) ? normalizeText(readFileSync(destination, "utf8")) : ""; + const embeddedSourceHash = existing.match( + /^\/\/ WRN editor compiler source hash: ([a-f0-9]{64})$/m, + )?.[1]; + const embeddedGeneratorHash = existing.match( + /^\/\/ WRN editor compiler generator hash: ([a-f0-9]{64})$/m, + )?.[1]; + const embeddedTypeScriptVersion = existing.match(/^\/\/ Generated with TypeScript: (.+)$/m)?.[1]; + + const hashesMatch = embeddedSourceHash === sourceHash && embeddedGeneratorHash === generatorHash; + const exactMatch = existing === normalizeText(output); + const compatibleCompilerDifference = + hashesMatch && Boolean(embeddedTypeScriptVersion) && embeddedTypeScriptVersion !== ts.version; + + if (!hashesMatch || (!exactMatch && !compatibleCompilerDifference)) { throw new Error( `${relative(root, destination)} is stale. Run bun run --cwd editors/vscode build and commit the result.`, ); } + + const versionDetail = exactMatch + ? `TypeScript ${ts.version}` + : `source-compatible output generated with TypeScript ${embeddedTypeScriptVersion}; current TypeScript is ${ts.version}`; process.stdout.write( - `Verified ${relative(root, destination)} matches ${sourceFiles.length} TypeScript modules.\n`, + `Verified ${relative(root, destination)} matches ${sourceFiles.length} TypeScript modules (${versionDetail}).\n`, ); } else { writeFileSync(destination, output, "utf8"); process.stdout.write( - `Built ${relative(root, destination)} from ${sourceFiles.length} TypeScript modules.\n`, + `Built ${relative(root, destination)} from ${sourceFiles.length} TypeScript modules with TypeScript ${ts.version}.\n`, ); } diff --git a/scripts/check-component-imports.mjs b/scripts/check-component-imports.mjs new file mode 100644 index 00000000..3c0b8074 --- /dev/null +++ b/scripts/check-component-imports.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { join, relative, resolve, sep } from "node:path"; +import console from "node:console"; +import process from "node:process"; +import { URL, fileURLToPath } from "node:url"; + +const root = resolve(fileURLToPath(new URL("..", import.meta.url))); +const packagesDir = join(root, "packages"); +const BUILT_INS = new Set([ + "Async", + "Loading", + "Success", + "Error", + "Static", + "Dynamic", + "Portal", + "Transition", + "Component", + "KeepAlive", + "Slot", +]); + +const IGNORED_DIRECTORIES = new Set([ + "node_modules", + "dist", + "coverage", + ".git", + ".wrnexus", + ".publish", +]); + +function walk(directory, extension, output = []) { + if (!existsSync(directory)) return output; + for (const name of readdirSync(directory)) { + if (IGNORED_DIRECTORIES.has(name)) continue; + const file = join(directory, name); + const stat = statSync(file); + if (stat.isDirectory()) walk(file, extension, output); + else if (file.endsWith(extension)) output.push(file); + } + return output; +} + +const packageManifests = new Map(); +for (const name of readdirSync(packagesDir)) { + const manifestPath = join(packagesDir, name, "package.json"); + if (!existsSync(manifestPath)) continue; + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + packageManifests.set(name, { + name: manifest.name, + dependencies: new Set([ + ...Object.keys(manifest.dependencies ?? {}), + ...Object.keys(manifest.peerDependencies ?? {}), + ...Object.keys(manifest.optionalDependencies ?? {}), + ]), + }); +} + +const files = walk(packagesDir, ".wrn"); +const declared = new Map(); +for (const file of files) { + const source = readFileSync(file, "utf8"); + const match = /\b(?:component|layout|page)\s+([A-Z][A-Za-z0-9_$]*)\b/.exec(source); + if (match) declared.set(match[1], file); +} + +const failures = []; +for (const file of files) { + const source = readFileSync(file, "utf8"); + const packageName = relative(packagesDir, file).split(sep)[0]; + const manifest = packageManifests.get(packageName); + for (const match of source.matchAll(/\bfrom\s+["'](@wrnexus\/[^"']+)["']/g)) { + const dependency = match[1].split("/").slice(0, 2).join("/"); + if (dependency === manifest?.name || manifest?.dependencies.has(dependency)) continue; + failures.push( + `${relative(root, file).replace(/\\/g, "/")}: ${dependency} must be declared as a package dependency`, + ); + } + const imported = new Set(); + for (const match of source.matchAll( + /^\s*import\s+([A-Z][A-Za-z0-9_$]*)\s+from\s+["'][^"']+["']/gm, + )) { + imported.add(match[1]); + } + for (const match of source.matchAll(/^\s*import\s*\{([^}]+)\}\s*from\s*["'][^"']+["']/gm)) { + for (const part of match[1].split(",")) { + const binding = part + .trim() + .replace(/^type\s+/, "") + .split(/\s+as\s+/) + .pop() + ?.trim(); + if (binding) imported.add(binding); + } + } + const own = /\b(?:component|layout|page)\s+([A-Z][A-Za-z0-9_$]*)\b/.exec(source)?.[1]; + const used = new Set([...source.matchAll(/<([A-Z][A-Za-z0-9_$]*)\b/g)].map((match) => match[1])); + for (const name of used) { + if (name === own || BUILT_INS.has(name) || !declared.has(name) || imported.has(name)) continue; + failures.push( + `${relative(root, file).replace(/\\/g, "/")}: <${name}> requires an explicit import`, + ); + } + const layoutSymbol = /\blayout\s*=\s*([A-Z][A-Za-z0-9_$]*)\b/.exec(source)?.[1]; + if ( + layoutSymbol && + layoutSymbol !== own && + declared.has(layoutSymbol) && + !imported.has(layoutSymbol) + ) { + failures.push( + `${relative(root, file).replace(/\\/g, "/")}: layout ${layoutSymbol} requires an explicit import`, + ); + } +} + +if (failures.length) { + console.error(`WRN-IMPORT-PACKAGE: ${failures.length} package component import error(s):`); + for (const failure of failures) console.error(`- ${failure}`); + process.exit(1); +} +console.log(`✓ Package component imports are explicit (${files.length} WRN files checked)`); diff --git a/scripts/release.ts b/scripts/release.ts index 3bad8a7b..766e6198 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -230,6 +230,9 @@ function prepare(all: PackageInfo[], version: string) { run(process.execPath, ["run", "sbom"]); run(process.execPath, ["run", "benchmark:framework"]); run(process.execPath, ["run", "check"]); + run(process.execPath, ["run", "check:editor-compiler"]); + run(process.execPath, ["run", "check:editor-language-server"]); + run(process.execPath, ["run", "check:editor-extension"]); run(process.execPath, ["run", "scripts/publish-packages.ts"]); run(process.execPath, ["run", "test:staged-consumers"]); validateStaging(all, version); @@ -305,6 +308,9 @@ function publish(all: PackageInfo[], version: string) { run(process.execPath, ["run", "check:workspace"]); run(process.execPath, ["run", "validate:0.8"]); run(process.execPath, ["run", "security:framework"]); + run(process.execPath, ["run", "check:editor-compiler"]); + run(process.execPath, ["run", "check:editor-language-server"]); + run(process.execPath, ["run", "check:editor-extension"]); requireCleanAndPushed(root, "Framework"); requireCleanAndPushed(docsRoot, "Docs"); diff --git a/scripts/validate-0.8.mjs b/scripts/validate-0.8.mjs index 7ebc3092..3ad7e87b 100644 --- a/scripts/validate-0.8.mjs +++ b/scripts/validate-0.8.mjs @@ -62,6 +62,16 @@ check( `all framework packages use ${releaseVersion}`, packageManifests.every((manifest) => manifest.version === releaseVersion), ); +const bunLockPackageVersions = [ + ...text("bun.lock").matchAll(/^ {4}"packages\/[^"\r\n]+": \{\r?\n([\s\S]*?)^ {4}\},?$/gm), +].map((match) => /^ {6}"version": "([^"]+)"/m.exec(match[1])?.[1]); +check( + `bun.lock framework workspaces use ${releaseVersion}`, + bunLockPackageVersions.length === packageManifests.length && + bunLockPackageVersions.every((version) => version === releaseVersion), +); +const editorManifest = JSON.parse(text("editors/vscode/package.json")); +check(`VS Code extension uses ${releaseVersion}`, editorManifest.version === releaseVersion); check( "standalone realtime package exists", existsSync(join(root, "packages/realtime/src/index.ts")) && @@ -172,7 +182,10 @@ check( has("packages/validation/src/index.ts", "TValues extends readonly [string, ...string[]]") && has("packages/validation/test/helpers.test.ts", "const schema: ObjectSchema"), ); -check("0.8 migration exists", has("packages/cli/src/update.ts", 'id: "0.8.0-01-package-kits"')); +check( + `current ${releaseVersion} migration exists`, + has("packages/cli/src/update.ts", `version: ${JSON.stringify(releaseVersion)}`), +); check( "package kit audit exists", existsSync(join(root, "scripts/audit-package-kits.mjs")) && diff --git a/update-package-versions.mjs b/update-package-versions.mjs index 25bf9325..80d7830c 100644 --- a/update-package-versions.mjs +++ b/update-package-versions.mjs @@ -1,7 +1,7 @@ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -const version = "0.8.2"; +const version = "0.8.3"; const dependencyGroups = [ "dependencies", @@ -61,3 +61,35 @@ for (const entry of readdirSync("packages", { writeFileSync(packageFile, `${JSON.stringify(packageJson, null, 2)}\n`); } + +const editorPackageFile = join("editors", "vscode", "package.json"); +if (existsSync(editorPackageFile)) { + const editorPackage = JSON.parse(readFileSync(editorPackageFile, "utf8")); + editorPackage.version = version; + writeFileSync(editorPackageFile, `${JSON.stringify(editorPackage, null, 2)}\n`); +} + +const editorLockFile = join("editors", "vscode", "package-lock.json"); +if (existsSync(editorLockFile)) { + const editorLock = JSON.parse(readFileSync(editorLockFile, "utf8")); + editorLock.version = version; + if (editorLock.packages?.[""]) editorLock.packages[""].version = version; + writeFileSync(editorLockFile, `${JSON.stringify(editorLock, null, 2)}\n`); +} + +const bunLockFile = "bun.lock"; +if (existsSync(bunLockFile)) { + let currentWorkspace = ""; + const lines = readFileSync(bunLockFile, "utf8").split(/(?<=\n)/); + const updated = lines.map((line) => { + const workspace = /^ {4}"([^"]+)": \{\r?\n?$/.exec(line)?.[1]; + if (workspace) currentWorkspace = workspace; + if (currentWorkspace.startsWith("packages/") && /^ {6}"version": "[^"]+",\r?\n?$/.test(line)) { + const newline = line.endsWith("\r\n") ? "\r\n" : line.endsWith("\n") ? "\n" : ""; + return ` "version": "${version}",${newline}`; + } + if (/^ {4}\},?\r?\n?$/.test(line)) currentWorkspace = ""; + return line; + }); + writeFileSync(bunLockFile, updated.join("")); +}