diff --git a/')) b/'))
deleted file mode 100644
index e69de29b..00000000
diff --git a/.gitignore b/.gitignore
index 2dedf281..bb140a3c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,5 @@ dist/
*.db-wal
.DS_Store
bun.lockb
+
+.publish/
diff --git a/.publish/ai/package.json b/.publish/ai/package.json
index fec6674b..a016b1b4 100644
--- a/.publish/ai/package.json
+++ b/.publish/ai/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ai",
- "version": "0.3.6",
+ "version": "0.4.0",
"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 d07e99bb..2ca0954c 100644
--- a/.publish/authz/package.json
+++ b/.publish/authz/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/authz",
- "version": "0.3.6",
+ "version": "0.4.0",
"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 59d8a582..af26cd6f 100644
--- a/.publish/cli/package.json
+++ b/.publish/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/cli",
- "version": "0.3.6",
+ "version": "0.4.0",
"type": "module",
"description": "@wrnexus/cli — part of the WrNexus framework.",
"license": "MIT",
@@ -28,18 +28,18 @@
"wrnexus": "./dist/index.js"
},
"dependencies": {
- "@wrnexus/core": "^0.3.6",
- "@wrnexus/router": "^0.3.6",
- "@wrnexus/csr": "^0.3.6",
- "@wrnexus/compiler": "^0.3.6",
- "@wrnexus/styles": "^0.3.6",
- "@wrnexus/dev-server": "^0.3.6",
- "@wrnexus/ui": "^0.3.6",
- "@wrnexus/validation": "^0.3.6",
- "@wrnexus/i18n": "^0.3.6",
- "@wrnexus/db": "^0.3.6",
- "@wrnexus/plugin": "^0.3.6",
- "@wrnexus/syntax": "^0.3.6"
+ "@wrnexus/core": "^0.4.0",
+ "@wrnexus/router": "^0.4.0",
+ "@wrnexus/csr": "^0.4.0",
+ "@wrnexus/compiler": "^0.4.0",
+ "@wrnexus/styles": "^0.4.0",
+ "@wrnexus/dev-server": "^0.4.0",
+ "@wrnexus/ui": "^0.4.0",
+ "@wrnexus/validation": "^0.4.0",
+ "@wrnexus/i18n": "^0.4.0",
+ "@wrnexus/db": "^0.4.0",
+ "@wrnexus/plugin": "^0.4.0",
+ "@wrnexus/syntax": "^0.4.0"
},
"files": [
"dist"
diff --git a/.publish/compiler/package.json b/.publish/compiler/package.json
index 79ffb6bb..fd90ea93 100644
--- a/.publish/compiler/package.json
+++ b/.publish/compiler/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/compiler",
- "version": "0.3.6",
+ "version": "0.4.0",
"type": "module",
"description": "@wrnexus/compiler — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
- "@wrnexus/syntax": "^0.3.6"
+ "@wrnexus/syntax": "^0.4.0"
},
"files": [
"dist"
diff --git a/.publish/core/package.json b/.publish/core/package.json
index f2b2a288..f08406c7 100644
--- a/.publish/core/package.json
+++ b/.publish/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/core",
- "version": "0.3.6",
+ "version": "0.4.0",
"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 84f982a3..b7d2f731 100644
--- a/.publish/csr/package.json
+++ b/.publish/csr/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/csr",
- "version": "0.3.6",
+ "version": "0.4.0",
"type": "module",
"description": "@wrnexus/csr — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
- "@wrnexus/core": "^0.3.6"
+ "@wrnexus/core": "^0.4.0"
},
"files": [
"dist"
diff --git a/.publish/db/package.json b/.publish/db/package.json
index c47ffd6f..68c35266 100644
--- a/.publish/db/package.json
+++ b/.publish/db/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/db",
- "version": "0.3.6",
+ "version": "0.4.0",
"type": "module",
"description": "@wrnexus/db — part of the WrNexus framework.",
"license": "MIT",
diff --git a/.publish/dev-server/package.json b/.publish/dev-server/package.json
index 6582a715..94e99a33 100644
--- a/.publish/dev-server/package.json
+++ b/.publish/dev-server/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
- "version": "0.3.6",
+ "version": "0.4.0",
"type": "module",
"description": "@wrnexus/dev-server — part of the WrNexus framework.",
"license": "MIT",
@@ -25,20 +25,20 @@
}
},
"dependencies": {
- "@wrnexus/core": "^0.3.6",
- "@wrnexus/dev-toolbar": "^0.3.6",
- "@wrnexus/router": "^0.3.6",
- "@wrnexus/ssr": "^0.3.6",
- "@wrnexus/csr": "^0.3.6",
- "@wrnexus/compiler": "^0.3.6",
- "@wrnexus/styles": "^0.3.6",
- "@wrnexus/ui": "^0.3.6",
- "@wrnexus/validation": "^0.3.6",
- "@wrnexus/i18n": "^0.3.6",
- "@wrnexus/db": "^0.3.6",
- "@wrnexus/pubsub": "^0.3.6",
- "@wrnexus/uploader": "^0.3.6",
- "@wrnexus/plugin": "^0.3.6"
+ "@wrnexus/core": "^0.4.0",
+ "@wrnexus/dev-toolbar": "^0.4.0",
+ "@wrnexus/router": "^0.4.0",
+ "@wrnexus/ssr": "^0.4.0",
+ "@wrnexus/csr": "^0.4.0",
+ "@wrnexus/compiler": "^0.4.0",
+ "@wrnexus/styles": "^0.4.0",
+ "@wrnexus/ui": "^0.4.0",
+ "@wrnexus/validation": "^0.4.0",
+ "@wrnexus/i18n": "^0.4.0",
+ "@wrnexus/db": "^0.4.0",
+ "@wrnexus/pubsub": "^0.4.0",
+ "@wrnexus/uploader": "^0.4.0",
+ "@wrnexus/plugin": "^0.4.0"
},
"files": [
"dist"
diff --git a/.publish/dev-toolbar/package.json b/.publish/dev-toolbar/package.json
index 26cc64f0..6d52fcbc 100644
--- a/.publish/dev-toolbar/package.json
+++ b/.publish/dev-toolbar/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-toolbar",
- "version": "0.3.6",
+ "version": "0.4.0",
"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 22e21e35..16ab1dc1 100644
--- a/.publish/encryption/package.json
+++ b/.publish/encryption/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/encryption",
- "version": "0.3.6",
+ "version": "0.4.0",
"type": "module",
"description": "@wrnexus/encryption — part of the WrNexus framework.",
"license": "MIT",
diff --git a/.publish/helpers/package.json b/.publish/helpers/package.json
index 98cc99f6..a2b29257 100644
--- a/.publish/helpers/package.json
+++ b/.publish/helpers/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/helpers",
- "version": "0.3.6",
+ "version": "0.4.0",
"type": "module",
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
- "@wrnexus/core": "^0.3.6"
+ "@wrnexus/core": "^0.4.0"
},
"files": [
"dist"
diff --git a/.publish/i18n/package.json b/.publish/i18n/package.json
index ac451f53..fb46d7ff 100644
--- a/.publish/i18n/package.json
+++ b/.publish/i18n/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/i18n",
- "version": "0.3.6",
+ "version": "0.4.0",
"type": "module",
"description": "@wrnexus/i18n — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
- "@wrnexus/core": "^0.3.6"
+ "@wrnexus/core": "^0.4.0"
},
"files": [
"dist"
diff --git a/.publish/jwt/package.json b/.publish/jwt/package.json
index edfba941..a84a0268 100644
--- a/.publish/jwt/package.json
+++ b/.publish/jwt/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/jwt",
- "version": "0.3.6",
+ "version": "0.4.0",
"type": "module",
"description": "@wrnexus/jwt — part of the WrNexus framework.",
"license": "MIT",
diff --git a/.publish/mobile/package.json b/.publish/mobile/package.json
index 406b8c2e..25cf6d9f 100644
--- a/.publish/mobile/package.json
+++ b/.publish/mobile/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/mobile",
- "version": "0.3.6",
+ "version": "0.4.0",
"type": "module",
"description": "@wrnexus/mobile — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
- "@wrnexus/native": "^0.3.6"
+ "@wrnexus/native": "^0.4.0"
},
"files": [
"dist"
diff --git a/.publish/native/package.json b/.publish/native/package.json
index 095e65cb..4b3ce4fe 100644
--- a/.publish/native/package.json
+++ b/.publish/native/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/native",
- "version": "0.3.6",
+ "version": "0.4.0",
"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 0e87a10f..23fa12d5 100644
--- a/.publish/oauth/package.json
+++ b/.publish/oauth/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/oauth",
- "version": "0.3.6",
+ "version": "0.4.0",
"type": "module",
"description": "@wrnexus/oauth — part of the WrNexus framework.",
"license": "MIT",
diff --git a/.publish/plugin/package.json b/.publish/plugin/package.json
index 66eae4c5..e2e278a6 100644
--- a/.publish/plugin/package.json
+++ b/.publish/plugin/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/plugin",
- "version": "0.3.6",
+ "version": "0.4.0",
"type": "module",
"description": "@wrnexus/plugin — part of the WrNexus framework.",
"license": "MIT",
@@ -18,10 +18,22 @@
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
+ },
+ "./types": {
+ "types": "./dist/types.d.ts",
+ "import": "./dist/types.js"
+ },
+ "./manifest": {
+ "types": "./dist/manifest.d.ts",
+ "import": "./dist/manifest.js"
+ },
+ "./discovery": {
+ "types": "./dist/discovery.d.ts",
+ "import": "./dist/discovery.js"
}
},
"dependencies": {
- "@wrnexus/syntax": "^0.3.6"
+ "@wrnexus/syntax": "^0.4.0"
},
"files": [
"dist"
diff --git a/.publish/pubsub/package.json b/.publish/pubsub/package.json
index 95a26c52..d1a64b77 100644
--- a/.publish/pubsub/package.json
+++ b/.publish/pubsub/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/pubsub",
- "version": "0.3.6",
+ "version": "0.4.0",
"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 9923c596..c58c89ca 100644
--- a/.publish/queue/package.json
+++ b/.publish/queue/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/queue",
- "version": "0.3.6",
+ "version": "0.4.0",
"type": "module",
"description": "@wrnexus/queue — part of the WrNexus framework.",
"license": "MIT",
diff --git a/.publish/reactive/package.json b/.publish/reactive/package.json
index 26c097d5..a02d588b 100644
--- a/.publish/reactive/package.json
+++ b/.publish/reactive/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/reactive",
- "version": "0.3.6",
+ "version": "0.4.0",
"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 6a50eaef..42f954d3 100644
--- a/.publish/router/package.json
+++ b/.publish/router/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/router",
- "version": "0.3.6",
+ "version": "0.4.0",
"type": "module",
"description": "@wrnexus/router — part of the WrNexus framework.",
"license": "MIT",
@@ -21,8 +21,8 @@
}
},
"dependencies": {
- "@wrnexus/compiler": "^0.3.6",
- "@wrnexus/core": "^0.3.6"
+ "@wrnexus/compiler": "^0.4.0",
+ "@wrnexus/core": "^0.4.0"
},
"files": [
"dist"
diff --git a/.publish/ssr/package.json b/.publish/ssr/package.json
index d4baba54..3f36289a 100644
--- a/.publish/ssr/package.json
+++ b/.publish/ssr/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ssr",
- "version": "0.3.6",
+ "version": "0.4.0",
"type": "module",
"description": "@wrnexus/ssr — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
- "@wrnexus/core": "^0.3.6"
+ "@wrnexus/core": "^0.4.0"
},
"files": [
"dist"
diff --git a/.publish/styles/package.json b/.publish/styles/package.json
index 07b71553..279f9293 100644
--- a/.publish/styles/package.json
+++ b/.publish/styles/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/styles",
- "version": "0.3.6",
+ "version": "0.4.0",
"type": "module",
"description": "@wrnexus/styles — part of the WrNexus framework.",
"license": "MIT",
@@ -21,9 +21,9 @@
}
},
"dependencies": {
- "@wrnexus/uploader": "^0.3.6",
- "@wrnexus/core": "^0.3.6",
- "@wrnexus/plugin": "^0.3.6"
+ "@wrnexus/uploader": "^0.4.0",
+ "@wrnexus/core": "^0.4.0",
+ "@wrnexus/plugin": "^0.4.0"
},
"files": [
"dist"
diff --git a/.publish/syntax/package.json b/.publish/syntax/package.json
index 2148cb70..75b8d195 100644
--- a/.publish/syntax/package.json
+++ b/.publish/syntax/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/syntax",
- "version": "0.3.6",
+ "version": "0.4.0",
"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 d67727c6..3ba16fdd 100644
--- a/.publish/test/package.json
+++ b/.publish/test/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/test",
- "version": "0.3.6",
+ "version": "0.4.0",
"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 fb2e8357..a10c65c7 100644
--- a/.publish/tracking/package.json
+++ b/.publish/tracking/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/tracking",
- "version": "0.3.6",
+ "version": "0.4.0",
"type": "module",
"description": "@wrnexus/tracking — part of the WrNexus framework.",
"license": "MIT",
diff --git a/.publish/ui/COMPONENTS.md b/.publish/ui/COMPONENTS.md
index c45638be..a3163320 100644
--- a/.publish/ui/COMPONENTS.md
+++ b/.publish/ui/COMPONENTS.md
@@ -36,9 +36,9 @@ Theme-aware, responsive copy markup component.
Theme-aware, responsive input number component.
- Mount: `data-component="InputNumber"`
-- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Input Number"`, `name: string = ""`, `value: string = ""`, `placeholder: string = ""`, `type: string = "number"`, `min: string = ""`, `max: string = ""`, `step: string = ""`, `disabled: boolean = false`, `required: boolean = false`, `class: string = ""`
+- Props: `size: string = "default"`, `color: string = "primary"`, `variant: string = "default"`, `class: string = ""`, `id: string = ""`, `name: string = "quantity"`, `value: number = 0`, `min: string = ""`, `max: string = ""`, `step: number = 1`, `precision: string = "auto"`, `label: string = ""`, `description: string = ""`, `helpText: string = ""`, `error: string = ""`, `invalid: boolean = false`, `prefix: string = ""`, `suffix: string = ""`, `placeholder: string = ""`, `autocomplete: string = "off"`, `inputMode: string = "decimal"`, `ariaLabel: string = ""`, `required: boolean = false`, `disabled: boolean = false`, `inputDisabled: boolean = false`, `buttonsDisabled: boolean = false`, `readonly: boolean = false`, `allowInput: boolean = true`, `keyboard: boolean = true`, `wheel: boolean = false`, `clamp: boolean = true`, `fullWidth: boolean = false`, `showButtons: boolean = true`, `showValidationMessage: boolean = true`, `decrementLabel: string = "Decrease value"`, `incrementLabel: string = "Increase value"`, `controlsLabel: string = "Quantity controls"`, `requiredMessage: string = "A value is required."`, `minMessage: string = "Value is below the minimum."`, `maxMessage: string = "Value is above the maximum."`
- Slots: None
-- Events: None
+- Events: `input`, `change`, `increment`, `decrement`
### PinInput
diff --git a/.publish/ui/README.md b/.publish/ui/README.md
index 017f1837..14e79db6 100644
--- a/.publish/ui/README.md
+++ b/.publish/ui/README.md
@@ -15,7 +15,7 @@ driven by `var(--wire-*)` theme tokens, so components restyle instantly when the
theme changes. The tiny JS surface (`src/index.ts`) exists only so the toolchain
(CLI build + dev server) can locate the component directory and stylesheet.
-The complete PDF-aligned catalog currently contains **891 components**. The
+The complete PDF-aligned catalog currently contains **85 components**. The
generated `COMPONENTS.md` and `component-reference.json` files document every
mount name, prop, inferred type, default/required status, slot, event, category,
and source file directly from the packaged `.wrn` source.
@@ -35,10 +35,11 @@ In practice you rarely install this directly: `@wrnexus/cli` and
## Components
-Components live as `.wrn` files under `packages/ui/components/`. The mount name
-is the **lowercase file basename** (e.g. `button.wrn` → `data-component="button"`).
-Each accepts a `class` prop (appended to its root element) and most render their
-body from either a named prop or the default slot.
+Components live as `.wrn` files under `packages/ui/components/`. The canonical mount
+name comes from the component declaration (for example, `component Button` mounts as
+`data-component="Button"`). Component lookup is case-insensitive, so existing lowercase
+mounts continue to work. Each component accepts a `class` prop (appended to its root
+element), and most render their body from either a named prop or the default slot.
### Layout
@@ -91,16 +92,17 @@ equivalent is exported as `@wrnexus/ui/component-reference.json`.
## API
-The JS module (`@wrnexus/ui`) exposes four helpers used by the build tooling to
+The JS module (`@wrnexus/ui`) exposes five helpers used by the build tooling to
locate the component assets. There is no component code to import — the components
are `.wrn` files rendered server-side.
-| Export | Signature | Returns |
-| ------------------ | ---------------- | ------------------------------------------------------------------------------------------ |
-| `uiComponentsDir` | `() => string` | Absolute path to the `.wrn` component directory (feed to `buildRouter`'s `componentDirs`). |
-| `uiCssPath` | `() => string` | Absolute path to `ui.css`. |
-| `uiCss` | `() => string` | The `ui.css` file contents (all `.wire-*` classes, themed via tokens). |
-| `uiComponentNames` | `() => string[]` | Sorted list of built-in component names (e.g. for `wrnexus eject` listing). |
+| Export | Signature | Returns |
+| ------------------ | -------------------------- | ------------------------------------------------------------------------------------------ |
+| `uiComponentsDir` | `() => string` | Absolute path to the `.wrn` component directory (feed to `buildRouter`'s `componentDirs`). |
+| `uiCssPath` | `() => string` | Absolute path to `ui.css`. |
+| `uiCss` | `() => string` | The `ui.css` file contents (all `.wire-*` classes, themed via tokens). |
+| `uiComponentNames` | `() => string[]` | Sorted list of declared built-in component names. |
+| `uiComponentPath` | `(name: string) => string` | Absolute source path for a declared component name or case-insensitive alias. |
### `./ui.css` asset export
diff --git a/.publish/ui/component-reference.json b/.publish/ui/component-reference.json
index 86bbad7c..6c8ed5ff 100644
--- a/.publish/ui/component-reference.json
+++ b/.publish/ui/component-reference.json
@@ -517,7 +517,7 @@
],
"slots": ["default"],
"events": [],
- "source": "components/Alert.wrn"
+ "source": "components/alert.wrn"
},
{
"name": "Avatar",
@@ -570,7 +570,7 @@
],
"slots": ["default"],
"events": [],
- "source": "components/Avatar.wrn"
+ "source": "components/avatar.wrn"
},
{
"name": "AvatarGroup",
@@ -676,7 +676,7 @@
],
"slots": ["default"],
"events": [],
- "source": "components/Badge.wrn"
+ "source": "components/badge.wrn"
},
{
"name": "Blockquote",
@@ -943,7 +943,7 @@
],
"slots": ["default"],
"events": [],
- "source": "components/Button.wrn"
+ "source": "components/button.wrn"
},
{
"name": "ButtonGroup",
@@ -1049,7 +1049,7 @@
],
"slots": ["default"],
"events": [],
- "source": "components/Card.wrn"
+ "source": "components/card.wrn"
},
{
"name": "Carousel",
@@ -1267,7 +1267,7 @@
],
"slots": [],
"events": [],
- "source": "components/Checkbox.wrn"
+ "source": "components/checkbox.wrn"
},
{
"name": "Clipboard",
@@ -1754,7 +1754,7 @@
],
"slots": [],
"events": ["search", "select", "change", "clear", "open", "close", "load", "error"],
- "source": "components/ComboBox.wrn"
+ "source": "components/Combobox.wrn"
},
{
"name": "Confetti",
@@ -1854,7 +1854,7 @@
],
"slots": ["default"],
"events": [],
- "source": "components/Container.wrn"
+ "source": "components/container.wrn"
},
{
"name": "ContextMenu",
@@ -2338,7 +2338,7 @@
],
"slots": [],
"events": [],
- "source": "components/Divider.wrn"
+ "source": "components/divider.wrn"
},
{
"name": "DragAndDrop",
@@ -2751,7 +2751,7 @@
],
"slots": ["default"],
"events": [],
- "source": "components/Grid.wrn"
+ "source": "components/grid.wrn"
},
{
"name": "Image",
@@ -2905,7 +2905,7 @@
],
"slots": [],
"events": [],
- "source": "components/Input.wrn"
+ "source": "components/input.wrn"
},
{
"name": "InputGroup",
@@ -3015,34 +3015,34 @@
"default": "\"primary\""
},
{
- "name": "label",
+ "name": "variant",
"type": "string",
"required": false,
- "default": "\"Input Number\""
+ "default": "\"default\""
+ },
+ {
+ "name": "class",
+ "type": "string",
+ "required": false,
+ "default": "\"\""
+ },
+ {
+ "name": "id",
+ "type": "string",
+ "required": false,
+ "default": "\"\""
},
{
"name": "name",
"type": "string",
"required": false,
- "default": "\"\""
+ "default": "\"quantity\""
},
{
"name": "value",
- "type": "string",
+ "type": "number",
"required": false,
- "default": "\"\""
- },
- {
- "name": "placeholder",
- "type": "string",
- "required": false,
- "default": "\"\""
- },
- {
- "name": "type",
- "type": "string",
- "required": false,
- "default": "\"number\""
+ "default": "0"
},
{
"name": "min",
@@ -3058,16 +3058,82 @@
},
{
"name": "step",
+ "type": "number",
+ "required": false,
+ "default": "1"
+ },
+ {
+ "name": "precision",
+ "type": "string",
+ "required": false,
+ "default": "\"auto\""
+ },
+ {
+ "name": "label",
"type": "string",
"required": false,
"default": "\"\""
},
{
- "name": "disabled",
+ "name": "description",
+ "type": "string",
+ "required": false,
+ "default": "\"\""
+ },
+ {
+ "name": "helpText",
+ "type": "string",
+ "required": false,
+ "default": "\"\""
+ },
+ {
+ "name": "error",
+ "type": "string",
+ "required": false,
+ "default": "\"\""
+ },
+ {
+ "name": "invalid",
"type": "boolean",
"required": false,
"default": "false"
},
+ {
+ "name": "prefix",
+ "type": "string",
+ "required": false,
+ "default": "\"\""
+ },
+ {
+ "name": "suffix",
+ "type": "string",
+ "required": false,
+ "default": "\"\""
+ },
+ {
+ "name": "placeholder",
+ "type": "string",
+ "required": false,
+ "default": "\"\""
+ },
+ {
+ "name": "autocomplete",
+ "type": "string",
+ "required": false,
+ "default": "\"off\""
+ },
+ {
+ "name": "inputMode",
+ "type": "string",
+ "required": false,
+ "default": "\"decimal\""
+ },
+ {
+ "name": "ariaLabel",
+ "type": "string",
+ "required": false,
+ "default": "\"\""
+ },
{
"name": "required",
"type": "boolean",
@@ -3075,14 +3141,110 @@
"default": "false"
},
{
- "name": "class",
+ "name": "disabled",
+ "type": "boolean",
+ "required": false,
+ "default": "false"
+ },
+ {
+ "name": "inputDisabled",
+ "type": "boolean",
+ "required": false,
+ "default": "false"
+ },
+ {
+ "name": "buttonsDisabled",
+ "type": "boolean",
+ "required": false,
+ "default": "false"
+ },
+ {
+ "name": "readonly",
+ "type": "boolean",
+ "required": false,
+ "default": "false"
+ },
+ {
+ "name": "allowInput",
+ "type": "boolean",
+ "required": false,
+ "default": "true"
+ },
+ {
+ "name": "keyboard",
+ "type": "boolean",
+ "required": false,
+ "default": "true"
+ },
+ {
+ "name": "wheel",
+ "type": "boolean",
+ "required": false,
+ "default": "false"
+ },
+ {
+ "name": "clamp",
+ "type": "boolean",
+ "required": false,
+ "default": "true"
+ },
+ {
+ "name": "fullWidth",
+ "type": "boolean",
+ "required": false,
+ "default": "false"
+ },
+ {
+ "name": "showButtons",
+ "type": "boolean",
+ "required": false,
+ "default": "true"
+ },
+ {
+ "name": "showValidationMessage",
+ "type": "boolean",
+ "required": false,
+ "default": "true"
+ },
+ {
+ "name": "decrementLabel",
"type": "string",
"required": false,
- "default": "\"\""
+ "default": "\"Decrease value\""
+ },
+ {
+ "name": "incrementLabel",
+ "type": "string",
+ "required": false,
+ "default": "\"Increase value\""
+ },
+ {
+ "name": "controlsLabel",
+ "type": "string",
+ "required": false,
+ "default": "\"Quantity controls\""
+ },
+ {
+ "name": "requiredMessage",
+ "type": "string",
+ "required": false,
+ "default": "\"A value is required.\""
+ },
+ {
+ "name": "minMessage",
+ "type": "string",
+ "required": false,
+ "default": "\"Value is below the minimum.\""
+ },
+ {
+ "name": "maxMessage",
+ "type": "string",
+ "required": false,
+ "default": "\"Value is above the maximum.\""
}
],
"slots": [],
- "events": [],
+ "events": ["input", "change", "increment", "decrement"],
"source": "components/InputNumber.wrn"
},
{
@@ -4039,7 +4201,7 @@
],
"slots": [],
"events": [],
- "source": "components/Progress.wrn"
+ "source": "components/progress.wrn"
},
{
"name": "Radio",
@@ -4098,7 +4260,7 @@
],
"slots": [],
"events": [],
- "source": "components/Radio.wrn"
+ "source": "components/radio.wrn"
},
{
"name": "RangeSlider",
@@ -4453,7 +4615,7 @@
],
"slots": [],
"events": [],
- "source": "components/Select.wrn"
+ "source": "components/select.wrn"
},
{
"name": "Sidebar",
@@ -4547,7 +4709,7 @@
],
"slots": [],
"events": [],
- "source": "components/Skeleton.wrn"
+ "source": "components/skeleton.wrn"
},
{
"name": "Spinner",
@@ -4588,7 +4750,7 @@
],
"slots": [],
"events": [],
- "source": "components/Spinner.wrn"
+ "source": "components/spinner.wrn"
},
{
"name": "Stepper",
@@ -4926,7 +5088,7 @@
],
"slots": [],
"events": [],
- "source": "components/Switch.wrn"
+ "source": "components/switch.wrn"
},
{
"name": "Table",
@@ -4979,7 +5141,7 @@
],
"slots": ["default"],
"events": [],
- "source": "components/Table.wrn"
+ "source": "components/table.wrn"
},
{
"name": "Tabs",
@@ -5103,7 +5265,7 @@
],
"slots": [],
"events": [],
- "source": "components/Textarea.wrn"
+ "source": "components/textarea.wrn"
},
{
"name": "Timeline",
@@ -5654,7 +5816,7 @@
],
"slots": ["default"],
"events": [],
- "source": "components/Tooltip.wrn"
+ "source": "components/tooltip.wrn"
},
{
"name": "TreeView",
diff --git a/.publish/ui/components/InputNumber.wrn b/.publish/ui/components/InputNumber.wrn
index 4b205de1..226922fd 100644
--- a/.publish/ui/components/InputNumber.wrn
+++ b/.publish/ui/components/InputNumber.wrn
@@ -1,20 +1,623 @@
component InputNumber {
- props {
- size = "default"
- color = "primary"
- label = "Input Number"
- name = ""
- value = ""
- placeholder = ""
- type = "number"
- min = ""
- max = ""
- step = ""
- disabled = false
- required = false
- class = ""
- }
- view {
-
- }
+ props {
+ size = "default"
+ color = "primary"
+ variant = "default"
+ class = ""
+
+ id = ""
+ name = "quantity"
+ value = 0
+ min = ""
+ max = ""
+ step = 1
+ precision = "auto"
+
+ label = ""
+ description = ""
+ helpText = ""
+ error = ""
+ invalid = false
+
+ prefix = ""
+ suffix = ""
+ placeholder = ""
+ autocomplete = "off"
+ inputMode = "decimal"
+ ariaLabel = ""
+
+ required = false
+ disabled = false
+ inputDisabled = false
+ buttonsDisabled = false
+ readonly = false
+ allowInput = true
+ keyboard = true
+ wheel = false
+ clamp = true
+ fullWidth = false
+ showButtons = true
+ showValidationMessage = true
+
+ decrementLabel = "Decrease value"
+ incrementLabel = "Increase value"
+ controlsLabel = "Quantity controls"
+ requiredMessage = "A value is required."
+ minMessage = "Value is below the minimum."
+ maxMessage = "Value is above the maximum."
+
+ @event input = function
+ @event change = function
+ @event increment = function
+ @event decrement = function
+ }
+
+ state currentValue = value
+ state committedValue = value
+
+ functions {
+ function inputId() {
+ if (id !== "") {
+ return id
+ }
+
+ if (name !== "") {
+ return name
+ }
+
+ return "input-number"
+ }
+
+ function descriptionId() {
+ return inputId() + "-description"
+ }
+
+ function messageId() {
+ return inputId() + "-message"
+ }
+
+ function componentColor() {
+ if (color === "secondary") {
+ return "var(--wire-color-secondary)"
+ }
+
+ if (color === "success") {
+ return "var(--wire-color-success)"
+ }
+
+ if (color === "warning") {
+ return "var(--wire-color-warning)"
+ }
+
+ if (color === "danger") {
+ return "var(--wire-color-danger)"
+ }
+
+ if (color === "info") {
+ return "var(--wire-color-info)"
+ }
+
+ return "var(--wire-color-primary)"
+ }
+
+ function hasMin() {
+ return min !== "" && min !== null && min !== undefined
+ }
+
+ function hasMax() {
+ return max !== "" && max !== null && max !== undefined
+ }
+
+ function isBlank() {
+ return currentValue === "" || currentValue === null || currentValue === undefined
+ }
+
+ function normalizedStep() {
+ return Number(step) > 0 ? Number(step) : 1
+ }
+
+ function inferredPrecision() {
+ if (precision !== "auto" && precision !== "") {
+ return Math.max(0, Number(precision) || 0)
+ }
+
+ if (String(normalizedStep()).includes(".")) {
+ return String(normalizedStep()).split(".")[1].length
+ }
+
+ return 0
+ }
+
+ function precisionFactor() {
+ return Math.pow(10, inferredPrecision())
+ }
+
+ function roundValue(nextValue) {
+ return Math.round(Number(nextValue) * precisionFactor()) / precisionFactor()
+ }
+
+ function clampValue(nextValue) {
+ nextValue = Number(nextValue)
+
+ if (hasMin() && nextValue < Number(min)) {
+ nextValue = Number(min)
+ }
+
+ if (hasMax() && nextValue > Number(max)) {
+ nextValue = Number(max)
+ }
+
+ return roundValue(nextValue)
+ }
+
+ function isBelowMin() {
+ return !isBlank() && hasMin() && Number(currentValue) < Number(min)
+ }
+
+ function isAboveMax() {
+ return !isBlank() && hasMax() && Number(currentValue) > Number(max)
+ }
+
+ function isInvalid() {
+ return (
+ invalid ||
+ error !== "" ||
+ (required && isBlank()) ||
+ isBelowMin() ||
+ isAboveMax()
+ )
+ }
+
+ function validationMessage() {
+ if (error !== "") {
+ return error
+ }
+
+ if (required && isBlank()) {
+ return requiredMessage
+ }
+
+ if (isBelowMin()) {
+ return minMessage
+ }
+
+ if (isAboveMax()) {
+ return maxMessage
+ }
+
+ return ""
+ }
+
+ function hasMessage() {
+ return (
+ helpText !== "" ||
+ (showValidationMessage && isInvalid() && validationMessage() !== "")
+ )
+ }
+
+ function describedBy() {
+ if (description !== "" && hasMessage()) {
+ return descriptionId() + " " + messageId()
+ }
+
+ if (description !== "") {
+ return descriptionId()
+ }
+
+ if (hasMessage()) {
+ return messageId()
+ }
+
+ return ""
+ }
+
+ function decrementDisabled() {
+ return (
+ disabled ||
+ readonly ||
+ buttonsDisabled ||
+ (hasMin() && !isBlank() && Number(currentValue) <= Number(min))
+ )
+ }
+
+ function incrementDisabled() {
+ return (
+ disabled ||
+ readonly ||
+ buttonsDisabled ||
+ (hasMax() && !isBlank() && Number(currentValue) >= Number(max))
+ )
+ }
+
+ function dispatchInputNumberEvent(
+ sourceEvent,
+ eventName,
+ action,
+ previousValue,
+ root,
+ customEvent
+ ) {
+ root = sourceEvent.currentTarget.closest("[data-wrn-input-number]")
+
+ if (!root && sourceEvent.target) {
+ root = sourceEvent.target.closest("[data-wrn-input-number]")
+ }
+
+ if (!root) {
+ return
+ }
+
+ customEvent = document.createEvent("CustomEvent")
+ customEvent.initCustomEvent(eventName, true, false, {
+ component: "InputNumber",
+ name: name,
+ value: currentValue,
+ previousValue: previousValue,
+ action: action,
+ min: hasMin() ? Number(min) : null,
+ max: hasMax() ? Number(max) : null,
+ step: normalizedStep(),
+ valid: !isInvalid()
+ })
+ root.dispatchEvent(customEvent)
+ }
+
+ function applyControlValue(nextValue, action, sourceEvent, previousValue) {
+ previousValue = currentValue
+ currentValue = clampValue(nextValue)
+ committedValue = currentValue
+
+ dispatchInputNumberEvent(
+ sourceEvent,
+ "input",
+ action,
+ previousValue
+ )
+ dispatchInputNumberEvent(
+ sourceEvent,
+ "change",
+ action,
+ previousValue
+ )
+ dispatchInputNumberEvent(
+ sourceEvent,
+ action,
+ action,
+ previousValue
+ )
+ }
+
+ function incrementValue(sourceEvent, nextValue) {
+ if (incrementDisabled()) {
+ return
+ }
+
+ if (isBlank()) {
+ nextValue = hasMin() ? Number(min) : normalizedStep()
+ } else {
+ nextValue =
+ Number(currentValue) +
+ normalizedStep() * (sourceEvent.shiftKey ? 10 : 1)
+ }
+
+ applyControlValue(nextValue, "increment", sourceEvent)
+ }
+
+ function decrementValue(sourceEvent, nextValue) {
+ if (decrementDisabled()) {
+ return
+ }
+
+ if (isBlank()) {
+ nextValue = hasMax() ? Number(max) : -normalizedStep()
+ } else {
+ nextValue =
+ Number(currentValue) -
+ normalizedStep() * (sourceEvent.shiftKey ? 10 : 1)
+ }
+
+ applyControlValue(nextValue, "decrement", sourceEvent)
+ }
+
+ function handleInput(sourceEvent, previousValue, nextValue) {
+ sourceEvent.stopPropagation()
+ previousValue = currentValue
+ nextValue = sourceEvent.target.value
+
+ if (nextValue === "") {
+ currentValue = ""
+ } else if (!Number.isNaN(Number(nextValue))) {
+ currentValue = roundValue(Number(nextValue))
+ }
+
+ dispatchInputNumberEvent(
+ sourceEvent,
+ "input",
+ "input",
+ previousValue
+ )
+ }
+
+ function handleChange(sourceEvent, previousValue) {
+ sourceEvent.stopPropagation()
+ previousValue = committedValue
+
+ if (!isBlank()) {
+ currentValue = clamp
+ ? clampValue(currentValue)
+ : roundValue(currentValue)
+ }
+
+ committedValue = currentValue
+
+ dispatchInputNumberEvent(
+ sourceEvent,
+ "change",
+ "change",
+ previousValue
+ )
+ }
+
+ function handleKeydown(sourceEvent) {
+ if (
+ !keyboard ||
+ disabled ||
+ readonly ||
+ inputDisabled ||
+ !allowInput
+ ) {
+ return
+ }
+
+ if (sourceEvent.key === "ArrowUp") {
+ sourceEvent.preventDefault()
+ incrementValue(sourceEvent)
+ } else if (sourceEvent.key === "ArrowDown") {
+ sourceEvent.preventDefault()
+ decrementValue(sourceEvent)
+ } else if (sourceEvent.key === "Home" && hasMin()) {
+ sourceEvent.preventDefault()
+ applyControlValue(Number(min), "decrement", sourceEvent)
+ } else if (sourceEvent.key === "End" && hasMax()) {
+ sourceEvent.preventDefault()
+ applyControlValue(Number(max), "increment", sourceEvent)
+ }
+ }
+
+ function handleWheel(sourceEvent) {
+ if (
+ !wheel ||
+ disabled ||
+ readonly ||
+ inputDisabled ||
+ !allowInput
+ ) {
+ return
+ }
+
+ sourceEvent.preventDefault()
+
+ if (sourceEvent.deltaY < 0) {
+ incrementValue(sourceEvent)
+ } else if (sourceEvent.deltaY > 0) {
+ decrementValue(sourceEvent)
+ }
+ }
+ }
+
+ view {
+
+ {#if label !== "" && variant !== "labeled" && variant !== "seat"}
+
+ {/if}
+
+ {#if description !== "" && variant !== "labeled" && variant !== "seat"}
+
+ {description}
+
+ {/if}
+
+
+ {#if variant === "horizontal" && showButtons}
+
+ {/if}
+
+
+ {#if variant === "labeled" || variant === "seat"}
+
+ {#if label !== ""}
+
+ {/if}
+
+ {#if description !== ""}
+
+ {description}
+
+ {/if}
+
+ {/if}
+
+
+ {#if prefix !== ""}
+
+ {prefix}
+
+ {/if}
+
+
+
+ {#if suffix !== ""}
+
+ {suffix}
+
+ {/if}
+
+
+
+ {#if variant === "horizontal" && showButtons}
+
+ {:else}
+ {#if showButtons}
+
+
+
+
+
+ {/if}
+ {/if}
+
+
+ {#if showValidationMessage && isInvalid() && validationMessage() !== ""}
+
+
+ {validationMessage()}
+
+ {/if}
+
+ {#if (!showValidationMessage || !isInvalid() || validationMessage() === "") && helpText !== ""}
+
+ {helpText}
+
+ {/if}
+
+ }
}
diff --git a/.publish/ui/package.json b/.publish/ui/package.json
index 2a29a2c7..c7b21836 100644
--- a/.publish/ui/package.json
+++ b/.publish/ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ui",
- "version": "0.3.6",
+ "version": "0.4.0",
"type": "module",
"description": "@wrnexus/ui — part of the WrNexus framework.",
"license": "MIT",
@@ -26,7 +26,7 @@
"./ui.css": "./ui.css"
},
"dependencies": {
- "@wrnexus/core": "^0.3.6"
+ "@wrnexus/core": "^0.4.0"
},
"files": [
"dist",
diff --git a/.publish/uploader/package.json b/.publish/uploader/package.json
index 87574325..3f3da297 100644
--- a/.publish/uploader/package.json
+++ b/.publish/uploader/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/uploader",
- "version": "0.3.6",
+ "version": "0.4.0",
"type": "module",
"description": "@wrnexus/uploader — part of the WrNexus framework.",
"license": "MIT",
@@ -21,7 +21,7 @@
}
},
"dependencies": {
- "@wrnexus/core": "^0.3.6"
+ "@wrnexus/core": "^0.4.0"
},
"files": [
"dist"
diff --git a/.publish/validation/package.json b/.publish/validation/package.json
index 066ce231..79d68fc0 100644
--- a/.publish/validation/package.json
+++ b/.publish/validation/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/validation",
- "version": "0.3.6",
+ "version": "0.4.0",
"type": "module",
"description": "@wrnexus/validation — part of the WrNexus framework.",
"license": "MIT",
diff --git a/AUDIO-PLAYBACK-FIX.md b/AUDIO-PLAYBACK-FIX.md
deleted file mode 100644
index d13f3d7f..00000000
--- a/AUDIO-PLAYBACK-FIX.md
+++ /dev/null
@@ -1,51 +0,0 @@
-# WRNexusJS CAPTCHA audio playback fix
-
-This patch fixes audio CAPTCHA playback in production and bundled server builds.
-
-## Root cause
-
-`AssetAudioRenderer` originally located WAV files only from `import.meta.url`. After
-WRNexusJS bundles the server into `dist/server.js`, that URL points to the app build
-folder instead of `packages/captcha`, so `/api/captcha/audio/...` fails while trying
-to read the WAV clips.
-
-## Changes
-
-- Resolves audio assets from the installed `@wrnexus/captcha/audio` package entry.
-- Supports `WRNEXUS_CAPTCHA_AUDIO_DIR` for custom deployments.
-- Searches workspace and `node_modules` layouts as safe fallbacks.
-- Keeps the browser `Audio` instance alive while it plays.
-- Stops old audio when a new challenge is loaded.
-- Returns a clear HTTP 503 and server log if audio rendering fails.
-- Adds a real WAV rendering test.
-
-## Apply
-
-Extract this ZIP into the WRNexusJS repository root and replace the included files.
-Then run:
-
-```bash
-bun run typecheck
-bun test packages/captcha
-bun run --cwd examples/captcha-showcase check
-```
-
-Restart the server. If running the production build, rebuild first:
-
-```bash
-bun run --cwd examples/captcha-showcase build
-bun examples/captcha-showcase/dist/server.js
-```
-
-## Verify
-
-1. Open the browser Network panel.
-2. Click **Listen**.
-3. The `/api/captcha/audio/?key=...` request must return HTTP 200.
-4. Its `content-type` must be `audio/wav`.
-
-For a nonstandard package layout, set an absolute path before starting the server:
-
-```powershell
-$env:WRNEXUS_CAPTCHA_AUDIO_DIR="E:\WireJS\packages\captcha\assets\audio"
-```
diff --git a/CAPTCHA-FIX8-README.md b/CAPTCHA-FIX8-README.md
deleted file mode 100644
index 00172b5e..00000000
--- a/CAPTCHA-FIX8-README.md
+++ /dev/null
@@ -1,40 +0,0 @@
-# WRNexusJS CAPTCHA Fixed 8
-
-Extract this ZIP directly into the WRNexusJS repository root and allow the included files to replace the current CAPTCHA files.
-
-## Added
-
-- 18 concrete generated CAPTCHA image styles
-- `imageStyle="random"`
-- `allowedStyles` and `excludedStyles`
-- `randomizeStyle`
-- Resolved style metadata and browser event details
-- Easy, normal, hard, controlled-pool, and full renderer showcase at `/styles`
-- Tailwind Iconify/Lucide showcase dependencies
-- Faster CRC32 PNG encoding
-- Safe floating-point line rendering for web, darts, distortion, wave, and scribble effects
-- Tests for every renderer and style-pool rule
-
-## Supported styles
-
-`classic`, `collision`, `snow`, `corrosion`, `spiderweb`, `cross-shadow`, `split`, `split2`, `cut`, `darts`, `distortion`, `stitch`, `striped`, `wave`, `grid-noise`, `scribble`, `pixel`, `broken-lines`, and `random`.
-
-## After extraction
-
-```bash
-bun install
-bun run scripts/generate-ui-component-reference.mjs
-bun run typecheck
-bun test packages/captcha
-bun test services/managed-captcha
-bun run --cwd examples/captcha-showcase check
-bun run --cwd examples/captcha-showcase dev
-```
-
-Open:
-
-```text
-http://localhost:3000/styles
-```
-
-Use a hard browser refresh after restarting the development server.
diff --git a/CAPTCHA-FIXED7-INSTRUCTIONS.md b/CAPTCHA-FIXED7-INSTRUCTIONS.md
deleted file mode 100644
index 37d58bac..00000000
--- a/CAPTCHA-FIXED7-INSTRUCTIONS.md
+++ /dev/null
@@ -1,51 +0,0 @@
-# WRNexusJS CAPTCHA Fixed 7
-
-Extract this patch directly into the WRNexusJS repository root and allow files to be replaced.
-
-## Added
-
-- `size="compact"`, `size="normal"`, and `size="big"`
-- Backward-compatible `small`/`sm`, `large`/`lg`, and `compact="true"` aliases
-- `showListen="true|false"` for the Listen button
-- Existing `showAudio="true|false"` remains the broader audio-alternative switch
-- Self-hosted `type="not-robot"` checkbox challenge
-- Showcase examples and tests for all new behavior
-- Managed API/OpenAPI support for `not-robot`
-
-## Usage
-
-```wrn
-
-```
-
-```wrn
-
-```
-
-The not-robot checkbox is server verified. It uses a one-time challenge, minimum completion time, honeypot, expiry, attempt limits, action binding, optional hostname/session/IP binding, and a single-use response token. It is a low-friction mode; high-risk traffic should escalate to a visual challenge or an external provider.
-
-## Regenerate and test
-
-```bash
-bun run scripts/generate-ui-component-reference.mjs
-bun run typecheck
-bun test packages/captcha
-bun run --cwd examples/captcha-showcase check
-bun run --cwd examples/captcha-showcase dev
-```
-
-Hard-refresh the browser after restarting the development server.
diff --git a/CHANGED-FILES.txt b/CHANGED-FILES.txt
deleted file mode 100644
index c42421cf..00000000
--- a/CHANGED-FILES.txt
+++ /dev/null
@@ -1,9 +0,0 @@
-tsconfig.json
-packages/styles/src/config.ts
-packages/dev-server/package.json
-packages/dev-server/src/hmr.ts
-packages/dev-server/src/watch.ts
-packages/dev-server/src/runtime.ts
-packages/dev-server/src/index.ts
-packages/dev-server/src/serve-entry.ts
-packages/dev-toolbar/src/client/runtime.ts
diff --git a/CHANGELOG-0.4.0.md b/CHANGELOG-0.4.0.md
new file mode 100644
index 00000000..ef1d81e1
--- /dev/null
+++ b/CHANGELOG-0.4.0.md
@@ -0,0 +1,37 @@
+# WRNexusJS 0.4.0
+
+## Package platform
+
+- Added automatic package plugin discovery from application dependencies and workspaces.
+- Added package-owned components, client runtimes, assets, styles, routes, middleware, database migrations, and DevToolbar panels.
+- Added duplicate/collision validation and strict discovery in development, build, doctor, and inspect flows.
+- Added production content-hashed client runtimes, content-addressed package asset storage with stable public URLs, and page-scoped runtime injection.
+- Added development bundling for TypeScript package runtimes and package-source HMR.
+- Added CSR package runtime mount/unmount lifecycle.
+
+## CLI and diagnostics
+
+- Added `wrnexus inspect packages|plugins|routes|assets|runtimes|styles|migrations|bundle`.
+- Added `wrnexus generate system `.
+- Expanded production build reports with all route types, plugins, runtimes, assets, component directories, and package migrations.
+- Added the 0.4 application migration and release verification scripts.
+- Fixed duplicate historical migration IDs.
+
+## CAPTCHA
+
+- Removed manual `captcha.js` copying and script inclusion.
+- Removed duplicated CAPTCHA components from `@wrnexus/ui` and the showcase application.
+- CAPTCHA is now discovered from `@wrnexus/captcha` and injected only on pages that render it.
+- Preserved all challenge modes, audio, protected-page flow, validated form, compact/normal/big modes, Listen controls, not-robot mode, disturbance controls, and image-style renderers.
+
+## Existing packages
+
+Every existing package received an exported advanced capability layer. See `docs/PACKAGE-UPGRADES-0.4.md` for the package-by-package inventory.
+
+## Fixes
+
+- Package runtime and asset path collisions now fail before serving.
+- Production style processing fails closed by default.
+- Page ETags now include structured script metadata.
+- Package `.wrn` changes invalidate Tailwind output during HMR.
+- Package migrations use the same ordered migration contract as application migrations.
diff --git a/MANIFEST-0.4.0.md b/MANIFEST-0.4.0.md
new file mode 100644
index 00000000..266e69cb
--- /dev/null
+++ b/MANIFEST-0.4.0.md
@@ -0,0 +1,109 @@
+# WRNexusJS 0.4.0 upgrade manifest
+
+This replacement upgrades the uploaded WRNexusJS repository to the 0.4.0 platform baseline and preserves existing APIs where practical.
+
+Compared with the uploaded baseline archive, the final tree contains 45 added files, 214 modified files, and 15 removed obsolete or generated files.
+
+## Main platform additions
+
+- Automatic installed/workspace package discovery through `package.json` `wrnexus.plugin`
+- Package-owned component directories
+- Page-scoped client runtimes
+- Virtual development assets with secure content types
+- Hashed production runtime assets
+- Package CSS entries and Tailwind scan sources
+- Package page/API/realtime routes and middleware
+- Package database migrations
+- Package DevToolbar panels
+- Dependency ordering, duplicate detection, cycle detection, and safe contribution validation
+- Runtime mount/unmount lifecycle during CSR navigation
+- Package-aware HMR and build reporting
+- `wrnexus inspect` commands and `wrnexus generate system`
+- 0.4 migration and compatibility tooling
+
+## CAPTCHA integration
+
+`@wrnexus/captcha` is now self-contained and automatically discovered. The old duplicate copies are removed from `@wrnexus/ui` and the showcase. The package contributes its own component, runtime, Tailwind scan source, and DevToolbar audit.
+
+Developers use:
+
+```bash
+bun add @wrnexus/captcha
+```
+
+```wrn
+
+```
+
+They do not copy `captcha.js`, add a script tag, or manually register `captchaPlugin()` for normal use. The runtime is injected only on pages that render a CAPTCHA.
+
+## Existing packages upgraded
+
+| Package | 0.4.0 addition or hardening |
+| ---------------------- | ------------------------------------------------------------------------------------ |
+| `@wrnexus/syntax` | syntax contract, feature/version metadata, ranges and diagnostics |
+| `@wrnexus/compiler` | compilation cache, dependency graph, cycle-safe cache implementation |
+| `@wrnexus/reactive` | watchers, resources, abort/supersession handling and cleanup scopes |
+| `@wrnexus/core` | service container, lifecycle, health, Problem Details, request IDs and idempotency |
+| `@wrnexus/router` | named routes, manifests, reverse URLs and app-over-package precedence |
+| `@wrnexus/ssr` | structured scripts, deduplication, CSP/SRI attributes and streaming output |
+| `@wrnexus/csr` | lazy package runtime loading and navigation mount/unmount lifecycle |
+| `@wrnexus/plugin` | discovery and package components/runtimes/assets/styles/routes/middleware/migrations |
+| `@wrnexus/dev-server` | virtual assets, TS runtime bundling, package HMR and migration integration |
+| `@wrnexus/cli` | package-aware builds, inspect commands, generator and 0.4 migration |
+| `@wrnexus/dev-toolbar` | package panels and platform snapshots |
+| `@wrnexus/styles` | package CSS/source support, package-only styles, token and contrast audits |
+| `@wrnexus/test` | request/context utilities, JSON/problem assertions, cookies and deferred helpers |
+| `@wrnexus/db` | migration ordering, cursor pagination, optimistic updates and tenant helpers |
+| `@wrnexus/queue` | durable stores, claims, priority, retry, dead-letter and processing safety |
+| `@wrnexus/pubsub` | resilient delivery and expiring defensive presence channels |
+| `@wrnexus/uploader` | safe object keys, inspection, policy enforcement and signed file tokens |
+| `@wrnexus/encryption` | versioned payloads and validated rotatable keyrings |
+| `@wrnexus/jwt` | key-ID signing, validated keyrings and safe decoding |
+| `@wrnexus/oauth` | state/PKCE storage, refresh, OIDC discovery and safe return URLs |
+| `@wrnexus/authz` | explainable decisions, owner policies, composition and filtering |
+| `@wrnexus/captcha` | automatic component/runtime, providers, audio, image styles, gates and guards |
+| `@wrnexus/validation` | async/cross-field refinements and OpenAPI conversion |
+| `@wrnexus/i18n` | fallbacks, coverage, formatters and plural messages |
+| `@wrnexus/ui` | component metadata and reference audits |
+| `@wrnexus/helpers` | retry, backoff, abortable sleep, timeouts and stable serialization |
+| `@wrnexus/tracking` | sampled/batched telemetry and reliable sink handling |
+| `@wrnexus/ai` | provider-neutral client, fallback providers and usage metadata |
+| `@wrnexus/mobile` | deep links, offline tasks and environment detection |
+| `@wrnexus/native` | capability manifests, permissions and target inspection |
+
+## Upgrade and release tooling
+
+- Root/package/service versions: 0.4.0
+- VS Code extension version: 0.4.0
+- Workspace lock versions updated
+- `update.ts` includes a 0.4.0 migration
+- Legacy CAPTCHA scripts are archived by migration rather than silently destroyed
+- Obsolete generated/release artifacts removed
+- New commands:
+
+```bash
+bun run verify:0.4
+bun run validate:0.4
+wrnexus doctor
+wrnexus inspect packages .
+wrnexus inspect plugins .
+wrnexus inspect routes .
+wrnexus inspect runtimes .
+wrnexus inspect assets .
+wrnexus generate system
+```
+
+## Documentation included
+
+- `README.md`
+- `CHANGELOG-0.4.0.md`
+- `docs/ARCHITECTURE-0.4.md`
+- `docs/PACKAGE-RUNTIMES-0.4.md`
+- `docs/PACKAGE-UPGRADES-0.4.md`
+- `docs/UPGRADE-0.4.md`
+- `docs/TEST-CHECKLIST-0.4.md`
+- `VALIDATION-0.4.0.md`
+- `MANIFEST-0.4.0.md`
+
+See `VALIDATION-0.4.0.md` for the exact checks completed and the required final Bun validation commands.
diff --git a/MANIFEST.md b/MANIFEST.md
deleted file mode 100644
index 5515daec..00000000
--- a/MANIFEST.md
+++ /dev/null
@@ -1,38 +0,0 @@
-# Bundle manifest
-
-## Package
-
-- Engine, challenge generators, optimized PNG renderer, audio renderer
-- Memory, SQLite-compatible, and Redis-compatible stores
-- Self-hosted, managed, Turnstile, reCAPTCHA, hCaptcha, and custom providers
-- HTTP handlers, guards, page gates, adaptive policy, validation integration
-- Plugin and DevToolbar audit panel
-- Tailwind-only `Captcha.wrn` with compact/normal/big sizing, listen visibility, not-robot mode, 25–75 disturbance control, and generated image renderer props
-- English WAV audio assets
-- Unit tests for all 18 PNG renderers, style resolution, security behavior, and documentation
-
-## Example
-
-- All self-hosted challenge types, including the not-robot checkbox
-- Audio and invisible examples
-- Shared `@wrnexus/validation` contact schema with client and API validation
-- Protected form API using `captchaGuard`
-- Working once-per-session protected-page grant and safe return redirect
-- Compact, normal, and big component examples
-- Listen-visible and listen-hidden examples
-- Easy, normal, and hard image-disturbance examples
-- Dedicated `/styles` gallery with 18 concrete renderers, random selection, allowed pools, and excluded pools
-- External-provider placeholders
-
-## Managed service
-
-- Project keys, allowed hostnames, quotas, key rotation
-- Public create/solve and secret-authenticated verification routes
-- Memory reference store, SQL migration, OpenAPI specification
-
-## Integration
-
-- Repeat-safe installer
-- TypeScript path aliases
-- Current component-discovery compatibility copy
-- Generated 18-style preview gallery and renderer guidance
diff --git a/README.md b/README.md
index 228ab710..b1590475 100644
--- a/README.md
+++ b/README.md
@@ -1,39 +1,21 @@
-# WRNexusJS CAPTCHA complete bundle
+# WRNexusJS 0.4.0 lint fix
-This bundle targets WRNexusJS `main-80` / package version `0.3.6` and contains:
+Extract this archive into the WRNexusJS repository root and replace the included files.
-- `packages/captcha`: publishable `@wrnexus/captcha` package
-- `packages/captcha/components/Captcha.wrn`: Tailwind-only UI component
-- `examples/captcha-showcase`: runnable challenge, disturbance, validated-form, and protected-page showcase
-- `services/managed-captcha`: managed API starter with projects, keys, hostname restrictions, quotas, and rotation
-- `scripts/install-captcha.mjs`: repeat-safe repository installer
-- `integration`: integration notes and generated path changes
-- `docs/IMAGE-STYLES.md`: renderer catalog with a generated preview gallery
+Fixes:
-## Install into a WRNexusJS checkout
+- Removes the `no-useless-assignment` failure in the CAPTCHA page-grant endpoint.
+- Restores the Button showcase's specialized examples so `buttonUses` is used.
+- Declares browser globals for the package-owned CAPTCHA runtime.
+- Replaces empty catch blocks with documented best-effort cleanup.
+- Fixes `prefer-const` in package discovery.
+- Removes unused plugin, style-audit, and UI-test bindings.
+- Uses the UI-reference verifier in the release workflow.
+- Declares Node globals correctly in installer and validation scripts.
+
+Run:
```bash
-bun scripts/install-captcha.mjs /path/to/WRNexusJS \
- --with-showcase \
- --with-managed-service \
- --check
+bun run lint
+bun run validate:0.4
```
-
-The installer copies the package, places `Captcha.wrn` in `packages/ui/components` for current component discovery, adds TypeScript aliases, optionally installs the example and managed service, and can run validation commands.
-
-Read `packages/captcha/README.md`, `packages/captcha/SECURITY.md`, and `services/managed-captcha/README.md` before production deployment.
-
-## Fixed 9 compact-size update
-
-- Compact width is now capped at `max-w-xs` instead of `max-w-sm`.
-- Compact padding, gaps, image height, input height, buttons, icons, and text are smaller.
-- The subtitle and provider-credit line are hidden in compact mode.
-- Normal and big rendering remain unchanged.
-
-## Fixed 8 integration updates
-
-- Added 18 generated CAPTCHA image renderers plus `imageStyle="random"`.
-- Added allowed and excluded renderer pools, forced randomization, and resolved style metadata.
-- Added the `/styles` showcase page with every renderer and easy/normal/hard random examples.
-- Optimized PNG line drawing and CRC generation for fast challenge creation.
-- Retains compact/normal/big sizing, Listen controls, not-robot mode, page-gate, 25–75 disturbance, validated form, audio, and runtime compatibility fixes.
diff --git a/REPLACE-FILES.md b/REPLACE-FILES.md
deleted file mode 100644
index a85fa269..00000000
--- a/REPLACE-FILES.md
+++ /dev/null
@@ -1,81 +0,0 @@
-# WRNexusJS DevToolbar Integration
-
-Extract this ZIP directly over the WRNexusJS repository root and allow these files to be replaced.
-
-## Replace files one by one
-
-1. `tsconfig.json`
- - Adds workspace path aliases for `@wrnexus/dev-toolbar` exports.
-
-2. `packages/styles/src/config.ts`
- - Adds `DevToolbarConfig` and `devToolbar` to `AppConfig`.
-
-3. `packages/dev-server/package.json`
- - Confirms the workspace dependency on `@wrnexus/dev-toolbar`.
-
-4. `packages/dev-server/src/hmr.ts`
- - Adds generic JSON broadcasting for toolbar events.
-
-5. `packages/dev-server/src/watch.ts`
- - Clears stale source issues and triggers rescans for CSS changes.
-
-6. `packages/dev-server/src/runtime.ts`
- - Serves toolbar routes.
- - Injects the toolbar only in development HTML.
- - Collects route, component, layout, and request errors.
- - Sends HMR/browser events used by the toolbar.
-
-7. `packages/dev-server/src/index.ts`
- - Resolves toolbar configuration.
- - Creates the server issue collector.
- - Connects collector updates to HMR.
- - Passes the toolbar into runtime and watcher dependencies.
-
-8. `packages/dev-server/src/serve-entry.ts`
- - Passes `config.devToolbar` from `wrnexus.config.ts` into the server.
-
-9. `packages/dev-toolbar/src/client/runtime.ts`
- - Loads server-side issues.
- - Receives live toolbar issue/scan messages.
- - Rescans after HMR and client navigation.
-
-## Configuration
-
-The toolbar is enabled automatically in development. Optional configuration:
-
-```ts
-export default {
- devToolbar: {
- enabled: true,
- position: "bottom-center",
- defaultOpen: false,
- scanOnNavigation: true,
- scanOnHmr: true,
- openEditor: true,
- editor: "code",
- },
-};
-```
-
-Disable it for an app:
-
-```ts
-export default {
- devToolbar: false,
-};
-```
-
-## After replacement
-
-Run from the repository root:
-
-```bash
-bun install
-bun run format
-bun run typecheck
-bun run lint
-bun test
-bun run check
-```
-
-The toolbar is development-only and is not injected when the server mode is `production`.
diff --git a/RUNTIME-COMPATIBILITY.md b/RUNTIME-COMPATIBILITY.md
deleted file mode 100644
index 37d95174..00000000
--- a/RUNTIME-COMPATIBILITY.md
+++ /dev/null
@@ -1,7 +0,0 @@
-# Reactive runtime compatibility correction
-
-The original Captcha component attempted to execute full native JavaScript inside WRNexusJS behavior blocks. WRNexusJS 0.3.6 behavior blocks use a deliberately limited eval-free interpreter and do not execute arbitrary JavaScript syntax.
-
-The corrected design keeps the `.wrn` component declarative and delegates network, audio, timers, provider SDKs, DOM updates, and form guards to a packaged same-origin browser runtime.
-
-This removes the `Unexpected token '?'` mount failure and prevents the later failures that would have occurred for `async`, `await`, `try`, callback functions, constructors, and member assignments.
diff --git a/VALIDATION-0.4.0.md b/VALIDATION-0.4.0.md
new file mode 100644
index 00000000..05b78ae2
--- /dev/null
+++ b/VALIDATION-0.4.0.md
@@ -0,0 +1,145 @@
+# WRNexusJS 0.4.0 validation report
+
+Validation date: 2026-07-25
+
+Baseline archive:
+
+- File: `WRNexusJS-main-current.zip`
+- SHA-256: `7860543f1b0d8bb227857bb01f70c6a3719d51215e24990cc0b3e45a18211c50`
+
+## Result
+
+All validation that can be performed in this build environment passed. The repository is structurally aligned to 0.4.0, TypeScript source is syntactically and semantically valid under the project configuration used for validation, automatic package discovery works, CAPTCHA is loaded automatically without a copied `captcha.js`, editor checks pass, generated component documentation succeeds, and targeted runtime smoke checks pass.
+
+Bun is not installed in this environment and external package installation is unavailable. Therefore the Bun unit-test suite, ESLint, Prettier, and real Bun production builds were not executed here. Run the final commands in the last section on the target Windows/Bun environment before publishing.
+
+## Passed checks
+
+### Repository structure
+
+Command:
+
+```bash
+node scripts/verify-0.4.mjs
+```
+
+Passed:
+
+- 30 `@wrnexus/*` packages aligned to version 0.4.0
+- Managed CAPTCHA service aligned to version 0.4.0
+- 73 unique updater migrations
+- Package/runtime/asset/migration discovery markers
+- CAPTCHA has no copied UI component or public JavaScript requirement
+- Bun workspace lock structure
+- VS Code extension package and lock versions
+- Required 0.4 architecture, migration, runtime, testing, and documentation files
+
+### TypeScript
+
+Passed:
+
+- 346 TypeScript files transpiled with zero syntax diagnostics
+- Strict semantic TypeScript check completed with zero diagnostics
+- Compiler cache runtime-cycle smoke check passed
+
+The semantic check used the repository `tsconfig.json` plus temporary environment-only type roots for Node/Bun declarations. The temporary config is not included in the delivered archives.
+
+### WRN source
+
+Passed:
+
+- 211 `.wrn` files parsed successfully
+- CAPTCHA component parses without a manual `
-
-
-
-
-
-
- {label}
-
-
-
- {description}
-
-
-
-
-
- Verified
-
-
-
-
-
-
-
- Loading challenge…
-
-
-
-
-
-
-
-
-
-
-
![CAPTCHA challenge]()
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Verification completed.
-
-
-
-
-
-
-
-
- {helpText}
-
-
-
-
-
-
-
- Protected by WRNexus CAPTCHA. Server-side verification is required.
-
-
- }
-}
diff --git a/examples/captcha-showcase/app/lib/captcha.ts b/examples/captcha-showcase/app/lib/captcha.ts
index 1dc8a6c4..4445134d 100644
--- a/examples/captcha-showcase/app/lib/captcha.ts
+++ b/examples/captcha-showcase/app/lib/captcha.ts
@@ -1,4 +1,8 @@
-import { createCaptchaEngine, createCaptchaHttpHandlers, MemoryCaptchaStore } from "@wrnexus/captcha/server";
+import {
+ createCaptchaEngine,
+ createCaptchaHttpHandlers,
+ MemoryCaptchaStore,
+} from "@wrnexus/captcha/server";
export const captchaEngine = createCaptchaEngine({
secret: process.env.CAPTCHA_SECRET ?? "development-only-change-this-captcha-secret-123456",
diff --git a/examples/captcha-showcase/app/styles/global.css b/examples/captcha-showcase/app/styles/global.css
index 4e4269cf..82bb37a0 100644
--- a/examples/captcha-showcase/app/styles/global.css
+++ b/examples/captcha-showcase/app/styles/global.css
@@ -1,7 +1,13 @@
@import "tailwindcss";
@plugin "@iconify/tailwind4";
@source "../**/*.wrn";
-@source "../../../packages/captcha/components/*.wrn";
-html { color-scheme: light dark; }
-body { margin: 0; background: var(--wire-color-bg); color: var(--wire-color-text); font-family: "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif; }
+html {
+ color-scheme: light dark;
+}
+body {
+ margin: 0;
+ background: var(--wire-color-bg);
+ color: var(--wire-color-text);
+ font-family: "Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif;
+}
diff --git a/examples/captcha-showcase/public/assets/wrnexus/captcha.js b/examples/captcha-showcase/public/assets/wrnexus/captcha.js
deleted file mode 100644
index dc4b2c9c..00000000
--- a/examples/captcha-showcase/public/assets/wrnexus/captcha.js
+++ /dev/null
@@ -1,878 +0,0 @@
-(function () {
- "use strict";
-
- var RUNTIME_KEY = "__wrnexusCaptchaRuntime";
- var existingRuntime = window[RUNTIME_KEY];
-
- if (existingRuntime && typeof existingRuntime.scan === "function") {
- existingRuntime.scan(document);
- return;
- }
-
- var states = new WeakMap();
- var scriptPromises = new Map();
- var instanceCounter = 0;
-
- function bool(value, fallback) {
- if (value === undefined || value === null || value === "") return fallback;
- return value === true || value === "true" || value === "1";
- }
-
- function numberInRange(value, fallback, minimum, maximum) {
- var parsed = Number(value);
- if (!Number.isFinite(parsed)) return fallback;
- return Math.max(minimum, Math.min(maximum, Math.round(parsed)));
- }
-
- function normalizeSize(value, compact) {
- if (compact) return "compact";
- var normalized = String(value || "normal").trim().toLowerCase();
- if (normalized === "compact" || normalized === "small" || normalized === "sm") return "compact";
- if (normalized === "big" || normalized === "large" || normalized === "lg") return "big";
- return "normal";
- }
-
- function commaList(value) {
- return String(value || "")
- .split(",")
- .map(function (item) { return item.trim().toLowerCase(); })
- .filter(Boolean);
- }
-
- function text(root, selector, value) {
- var element = root.querySelector(selector);
- if (element) element.textContent = value == null ? "" : String(value);
- }
-
- function show(element, visible) {
- if (!element) return;
- element.hidden = !visible;
- }
-
- function setBusy(root, busy) {
- root.setAttribute("aria-busy", busy ? "true" : "false");
- }
-
- function config(root) {
- var data = root.dataset;
- var responseField = data.captchaResponseField || data.captchaName || "wrn-captcha-response";
- var compact = bool(data.captchaCompact, false);
- var size = normalizeSize(data.captchaSize, compact);
-
- return {
- provider: data.captchaProvider || "self-hosted",
- siteKey: data.captchaSiteKey || "",
- type: data.captchaType || "alphanumeric",
- action: data.captchaAction || "form-submit",
- presentation: data.captchaPresentation || "visual",
- difficulty: data.captchaDifficulty || "normal",
- disturbance: numberInRange(data.captchaDisturbance, 50, 25, 75),
- imageStyle: data.captchaImageStyle || "random",
- allowedStyles: commaList(data.captchaAllowedStyles),
- excludedStyles: commaList(data.captchaExcludedStyles),
- randomizeStyle: bool(data.captchaRandomizeStyle, false),
- locale: data.captchaLocale || "en",
- size: size,
- endpoint: data.captchaEndpoint || "/__wrnexus/captcha/challenge",
- verifyEndpoint: data.captchaVerifyEndpoint || "/__wrnexus/captcha/verify",
- responseField: responseField,
- autoLoad: bool(data.captchaAutoLoad, true),
- autoVerify: bool(data.captchaAutoVerify, false),
- showVerify: bool(data.captchaShowVerify, true),
- showRefresh: bool(data.captchaShowRefresh, true),
- showAudio: bool(data.captchaShowAudio, true),
- showListen: bool(data.captchaShowListen, true),
- showStatus: bool(data.captchaShowStatus, true),
- disabled: bool(data.captchaDisabled, false),
- required: bool(data.captchaRequired, true),
- compact: compact,
- requiredMessage: data.captchaRequiredMessage || "Please complete the security check.",
- incorrectMessage: data.captchaIncorrectMessage || "That answer was not correct. Try again.",
- expiredMessage: data.captchaExpiredMessage || "This challenge expired. Load a new one.",
- networkMessage: data.captchaNetworkMessage || "The verification service is unavailable. Try again.",
- };
- }
-
- function eventDetail(state, extra) {
- var challenge = state.challenge;
- return {
- component: "Captcha",
- provider: state.config.provider,
- type: challenge && challenge.type ? challenge.type : state.config.type,
- action: state.config.action,
- disturbance: state.config.disturbance,
- imageStyle: challenge && challenge.metadata && challenge.metadata.imageStyle
- ? challenge.metadata.imageStyle
- : state.config.imageStyle,
- requestedImageStyle: challenge && challenge.metadata && challenge.metadata.requestedImageStyle
- ? challenge.metadata.requestedImageStyle
- : state.config.imageStyle,
- imageStylePool: challenge && challenge.metadata && challenge.metadata.imageStylePool
- ? challenge.metadata.imageStylePool
- : state.config.allowedStyles,
- size: state.config.size,
- status: state.status,
- challengeId: challenge && challenge.id ? challenge.id : "",
- responseToken: state.responseToken,
- expiresAt: challenge && challenge.expiresAt ? challenge.expiresAt : null,
- extra: extra || null,
- };
- }
-
- function emit(state, name, extra) {
- state.root.dispatchEvent(
- new CustomEvent(name, {
- bubbles: true,
- detail: eventDetail(state, extra),
- }),
- );
- }
-
- function setResponseToken(state, token) {
- state.responseToken = token || "";
- var input = state.root.querySelector("[data-captcha-response]");
- if (input) {
- input.name = state.config.responseField;
- input.value = state.responseToken;
- }
- }
-
- function isNotRobot(state) {
- return Boolean(
- (state.challenge && state.challenge.type === "not-robot") ||
- (!state.challenge && state.config.type === "not-robot"),
- );
- }
-
- function updateNotRobotState(state) {
- var panel = state.root.querySelector("[data-captcha-not-robot]");
- var button = state.root.querySelector("[data-captcha-not-robot-button]");
- var empty = state.root.querySelector("[data-captcha-not-robot-empty]");
- var spinner = state.root.querySelector("[data-captcha-not-robot-spinner]");
- var check = state.root.querySelector("[data-captcha-not-robot-check]");
- var control = state.root.querySelector("[data-captcha-not-robot-control]");
- var label = state.root.querySelector("[data-captcha-not-robot-label]");
- var active = isNotRobot(state);
- var pending = state.notRobotPending || state.status === "verifying";
- var verified = state.status === "verified";
-
- show(panel, active && state.status !== "loading" && state.status !== "idle");
- if (!active) return;
-
- if (button) {
- button.disabled = state.config.disabled || pending || verified || state.status === "expired";
- button.setAttribute("aria-pressed", verified ? "true" : "false");
- }
- show(empty, !pending && !verified);
- show(spinner, pending && !verified);
- show(check, verified);
-
- if (control) control.dataset.verified = verified ? "true" : "false";
- if (label) {
- label.textContent = verified ? "Verified" : pending ? "Checking…" : "I'm not a robot";
- }
- }
-
- function setStatus(state, status, message) {
- state.status = status;
- state.root.dataset.captchaStatus = status;
- setBusy(state.root, status === "loading" || status === "verifying");
-
- var loading = state.root.querySelector("[data-captcha-loading]");
- var challenge = state.root.querySelector("[data-captcha-challenge]");
- var success = state.root.querySelector("[data-captcha-success]");
- var error = state.root.querySelector("[data-captcha-error]");
- var badge = state.root.querySelector("[data-captcha-verified-badge]");
- var verifyButton = state.root.querySelector("[data-captcha-verify]");
- var verifyIcon = state.root.querySelector("[data-captcha-verify-icon]");
- var verifySpinner = state.root.querySelector("[data-captcha-verify-spinner]");
- var verifyLabel = state.root.querySelector("[data-captcha-verify-label]");
- var answer = state.root.querySelector("[data-captcha-answer]");
-
- show(loading, status === "loading");
- show(challenge, status !== "loading" && status !== "idle" && state.config.provider !== "turnstile" && state.config.provider !== "recaptcha" && state.config.provider !== "hcaptcha");
- show(badge, status === "verified");
-
- if (state.config.showStatus) {
- show(success, status === "verified");
- show(error, Boolean(message) && status !== "verified");
- } else {
- show(success, false);
- show(error, false);
- }
-
- if (message && status === "verified") text(state.root, "[data-captcha-success-message]", message);
- if (message && status !== "verified") text(state.root, "[data-captcha-error-message]", message);
-
- if (answer) {
- answer.disabled = state.config.disabled || status === "verified" || status === "expired" || status === "loading" || status === "verifying";
- answer.setAttribute("aria-invalid", status === "incorrect" ? "true" : "false");
- }
-
- if (verifyButton) {
- verifyButton.disabled = state.config.disabled || status === "loading" || status === "verifying" || status === "verified" || status === "expired";
- }
-
- show(verifyIcon, status !== "verifying");
- show(verifySpinner, status === "verifying");
- if (verifyLabel) verifyLabel.textContent = status === "verifying" ? "Verifying…" : "Verify";
-
- updateNotRobotState(state);
- updateControls(state);
- }
-
- function clearTimer(state) {
- if (state.timer) {
- window.clearInterval(state.timer);
- state.timer = null;
- }
- }
-
- function updateCountdown(state) {
- var countdown = state.root.querySelector("[data-captcha-countdown]");
- if (!countdown || !state.challenge || !state.challenge.expiresAt) {
- show(countdown, false);
- return;
- }
-
- var remaining = Math.max(0, Math.ceil((Number(state.challenge.expiresAt) - Date.now()) / 1000));
- countdown.textContent = remaining + "s";
- show(countdown, remaining > 0 && state.status !== "verified");
-
- if (remaining <= 0 && state.status !== "verified" && state.status !== "expired") {
- clearTimer(state);
- setResponseToken(state, "");
- setStatus(state, "expired", state.config.expiredMessage);
- emit(state, "expired");
- }
- }
-
- function startTimer(state) {
- clearTimer(state);
- if (!state.challenge || !state.challenge.expiresAt) return;
- updateCountdown(state);
- state.timer = window.setInterval(function () {
- updateCountdown(state);
- }, 1000);
- }
-
- function resetUi(state) {
- if (state.audioPlayer) {
- state.audioPlayer.pause();
- state.audioPlayer.removeAttribute("src");
- state.audioPlayer.load();
- state.audioPlayer = null;
- }
- if (state.notRobotTimer) {
- window.clearTimeout(state.notRobotTimer);
- state.notRobotTimer = null;
- }
-
- state.notRobotPending = false;
- state.challengeLoadedAt = 0;
- state.answer = "";
- state.selections = [];
- state.challenge = null;
- setResponseToken(state, "");
-
- var answer = state.root.querySelector("[data-captcha-answer]");
- var items = state.root.querySelector("[data-captcha-items]");
- var imageWrap = state.root.querySelector("[data-captcha-image-wrap]");
- var providerMount = state.root.querySelector("[data-captcha-provider-mount]");
- var honeypot = state.root.querySelector("[data-captcha-honeypot]");
- var notRobot = state.root.querySelector("[data-captcha-not-robot]");
-
- if (answer) answer.value = "";
- if (items) items.replaceChildren();
- show(items, false);
- show(imageWrap, false);
- show(notRobot, false);
- if (honeypot) {
- honeypot.value = "";
- honeypot.name = "";
- }
- if (providerMount && state.config.provider !== "turnstile" && state.config.provider !== "recaptcha" && state.config.provider !== "hcaptcha") {
- providerMount.replaceChildren();
- show(providerMount, false);
- }
- }
-
- function updateControls(state) {
- var challenge = state.challenge;
- var audio = state.root.querySelector("[data-captcha-audio]");
- var audioAlternative = state.root.querySelector("[data-captcha-audio-alternative]");
- var refresh = state.root.querySelector("[data-captcha-refresh]");
- var verify = state.root.querySelector("[data-captcha-verify]");
- var footer = state.root.querySelector("[data-captcha-footer]");
- var isExternal = state.config.provider === "turnstile" || state.config.provider === "recaptcha" || state.config.provider === "hcaptcha";
- var notRobot = isNotRobot(state);
- var locked = state.config.disabled || state.status === "loading" || state.status === "verifying" || state.notRobotPending;
-
- show(footer, !notRobot);
- show(audio, !notRobot && state.config.showAudio && state.config.showListen && Boolean(challenge && challenge.audioUrl) && state.status !== "verified");
- show(audioAlternative, !notRobot && state.config.showAudio && Boolean(challenge && challenge.type === "image") && state.status !== "verified");
- show(refresh, !notRobot && state.config.showRefresh && state.status !== "verified");
- show(verify, !notRobot && state.config.showVerify && !isExternal);
-
- if (audio) audio.disabled = locked;
- if (audioAlternative) audioAlternative.disabled = locked;
- if (refresh) refresh.disabled = locked;
- if (verify) verify.disabled = locked || state.status === "verified" || state.status === "expired";
- }
-
- function renderItems(state, challenge) {
- var container = state.root.querySelector("[data-captcha-items]");
- var template = state.root.querySelector("[data-captcha-item-template]");
- if (!container || !template || !Array.isArray(challenge.items) || challenge.items.length === 0) {
- show(container, false);
- return;
- }
-
- container.replaceChildren();
- container.setAttribute("aria-label", challenge.prompt || "Select matching images");
-
- challenge.items.forEach(function (item, index) {
- var fragment = template.content.cloneNode(true);
- var button = fragment.querySelector("button");
- var image = fragment.querySelector("[data-captcha-item-image]");
- if (!button || !image) return;
-
- button.dataset.captchaItemId = item.id;
- button.setAttribute("aria-label", item.alt || "Challenge tile " + (index + 1));
- image.src = item.image;
- image.alt = "";
- button.addEventListener("click", function () {
- toggleItem(state, item.id, button);
- });
- container.appendChild(fragment);
- });
-
- show(container, true);
- }
-
- function toggleItem(state, itemId, button) {
- if (state.config.disabled || state.status !== "ready") return;
-
- var index = state.selections.indexOf(itemId);
- var selected = index >= 0;
-
- if (selected) {
- state.selections.splice(index, 1);
- } else {
- var maximum = Number(state.challenge && state.challenge.maxSelections ? state.challenge.maxSelections : 0);
- if (maximum > 0 && state.selections.length >= maximum) {
- var removed = state.selections.shift();
- var previousButton = state.root.querySelector('[data-captcha-item-id="' + CSS.escape(removed) + '"]');
- if (previousButton) {
- previousButton.setAttribute("aria-pressed", "false");
- show(previousButton.querySelector("[data-captcha-item-check]"), false);
- }
- }
- state.selections.push(itemId);
- }
-
- var nowSelected = state.selections.indexOf(itemId) >= 0;
- button.setAttribute("aria-pressed", nowSelected ? "true" : "false");
- show(button.querySelector("[data-captcha-item-check]"), nowSelected);
- emit(state, "input", { selections: state.selections.slice() });
-
- var minimum = Number(state.challenge && state.challenge.minSelections ? state.challenge.minSelections : 0);
- if (state.config.autoVerify && minimum > 0 && state.selections.length >= minimum) verify(state);
- }
-
- function renderChallenge(state, challenge) {
- state.challenge = challenge;
- state.challengeLoadedAt = Date.now();
- state.notRobotPending = false;
- state.answer = "";
- state.selections = [];
-
- var notRobot = challenge.type === "not-robot";
- state.root.dataset.captchaType = challenge.type || state.config.type;
- state.root.dataset.captchaResolvedImageStyle = challenge.metadata && challenge.metadata.imageStyle
- ? String(challenge.metadata.imageStyle)
- : state.config.imageStyle;
- text(state.root, "[data-captcha-prompt]", challenge.prompt || "Complete the security challenge.");
-
- var promptRow = state.root.querySelector("[data-captcha-prompt-row]");
- var notRobotPanel = state.root.querySelector("[data-captcha-not-robot]");
- show(promptRow, !notRobot);
- show(notRobotPanel, notRobot);
-
- var imageWrap = state.root.querySelector("[data-captcha-image-wrap]");
- var image = state.root.querySelector("[data-captcha-image]");
- if (!notRobot && challenge.image && image) {
- image.src = challenge.image;
- show(imageWrap, true);
- } else {
- if (image) image.removeAttribute("src");
- show(imageWrap, false);
- }
-
- if (notRobot) {
- var items = state.root.querySelector("[data-captcha-items]");
- if (items) items.replaceChildren();
- show(items, false);
- } else {
- renderItems(state, challenge);
- }
-
- var answerWrap = state.root.querySelector("[data-captcha-answer-wrap]");
- var answer = state.root.querySelector("[data-captcha-answer]");
- var needsAnswer = !notRobot && !challenge.items && challenge.inputMode !== "none";
- show(answerWrap, needsAnswer);
- if (answer) {
- answer.value = "";
- answer.inputMode = challenge.inputMode === "numeric" ? "numeric" : "text";
- answer.setAttribute("aria-invalid", "false");
- }
-
- var honeypot = state.root.querySelector("[data-captcha-honeypot]");
- if (honeypot) honeypot.name = challenge.honeypotField || "";
-
- setStatus(state, "ready", "");
- startTimer(state);
- emit(state, "challenge", challenge);
- emit(state, "ready");
-
- if (state.config.autoVerify && !notRobot && challenge.inputMode === "none" && !challenge.items) {
- window.setTimeout(function () {
- verify(state);
- }, 0);
- }
- }
-
- async function createChallenge(state, requestedPresentation) {
- if (state.config.disabled) return;
-
- if (state.config.provider === "turnstile" || state.config.provider === "recaptcha" || state.config.provider === "hcaptcha") {
- await mountExternal(state);
- return;
- }
-
- clearTimer(state);
- resetUi(state);
- setStatus(state, "loading", "");
-
- try {
- var response = await fetch(state.config.endpoint, {
- method: "POST",
- credentials: "same-origin",
- headers: {
- "content-type": "application/json",
- accept: "application/json",
- },
- body: JSON.stringify({
- siteKey: state.config.siteKey,
- action: state.config.action,
- type: state.config.type,
- presentation: requestedPresentation || state.config.presentation,
- difficulty: state.config.difficulty,
- disturbance: state.config.disturbance,
- imageStyle: state.config.imageStyle,
- allowedStyles: state.config.allowedStyles,
- excludedStyles: state.config.excludedStyles,
- randomizeStyle: state.config.randomizeStyle,
- locale: state.config.locale,
- responseField: state.config.responseField,
- }),
- });
-
- var result = await response.json().catch(function () {
- return null;
- });
-
- if (!response.ok || !result || !result.id) {
- throw new Error(result && result.message ? result.message : "Challenge request failed");
- }
-
- renderChallenge(state, result);
- } catch (error) {
- setStatus(state, "network-error", state.config.networkMessage);
- emit(state, "error", { error: error instanceof Error ? error.message : String(error) });
- }
- }
-
- async function verify(state) {
- state.notRobotPending = false;
- if (state.config.disabled || state.status === "loading" || state.status === "verifying" || state.status === "expired" || state.status === "verified") {
- updateNotRobotState(state);
- updateControls(state);
- return;
- }
-
- if (!state.challenge) {
- await createChallenge(state);
- return;
- }
-
- var answerInput = state.root.querySelector("[data-captcha-answer]");
- var honeypotInput = state.root.querySelector("[data-captcha-honeypot]");
- state.answer = answerInput ? answerInput.value : "";
-
- setStatus(state, "verifying", "");
- emit(state, "verify");
-
- try {
- var response = await fetch(state.challenge.verifyUrl || state.config.verifyEndpoint, {
- method: "POST",
- credentials: "same-origin",
- headers: {
- "content-type": "application/json",
- accept: "application/json",
- },
- body: JSON.stringify({
- challengeId: state.challenge.id,
- action: state.config.action,
- answer: state.answer,
- selections: state.selections,
- honeypot: honeypotInput ? honeypotInput.value : "",
- timingToken: state.challenge.timingToken || "",
- }),
- });
-
- var result = await response.json().catch(function () {
- return null;
- });
-
- if (!response.ok || !result || !result.success) {
- setResponseToken(state, "");
- var expired = Boolean(result && result.code === "expired");
- setStatus(
- state,
- expired ? "expired" : "incorrect",
- expired
- ? state.config.expiredMessage
- : result && result.message
- ? result.message
- : state.config.incorrectMessage,
- );
- emit(state, "failure", result || null);
- return;
- }
-
- clearTimer(state);
- setResponseToken(state, result.responseToken || "");
- setStatus(state, "verified", "Verification completed.");
- emit(state, "success", result);
- } catch (error) {
- setStatus(state, "network-error", state.config.networkMessage);
- emit(state, "error", { error: error instanceof Error ? error.message : String(error) });
- }
- }
-
- function verifyNotRobot(state) {
- if (state.config.disabled || state.notRobotPending || state.status !== "ready") return;
- if (!state.challenge || state.challenge.type !== "not-robot") {
- createChallenge(state);
- return;
- }
-
- var configuredMinimum = Number(
- state.challenge.metadata && state.challenge.metadata.minCompletionMs !== undefined
- ? state.challenge.metadata.minCompletionMs
- : 800,
- );
- var minimum = Number.isFinite(configuredMinimum) ? Math.max(0, configuredMinimum) : 800;
- var elapsed = Math.max(0, Date.now() - state.challengeLoadedAt);
- var remaining = Math.max(0, minimum - elapsed);
-
- state.notRobotPending = true;
- updateNotRobotState(state);
- updateControls(state);
- emit(state, "input", { checked: true });
-
- var complete = function () {
- state.notRobotTimer = null;
- state.notRobotPending = false;
- verify(state);
- };
-
- if (remaining > 0) state.notRobotTimer = window.setTimeout(complete, remaining);
- else complete();
- }
-
- function providerDefinition(provider) {
- if (provider === "turnstile") {
- return {
- url: "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit",
- globalName: "turnstile",
- };
- }
- if (provider === "recaptcha") {
- return {
- url: "https://www.google.com/recaptcha/api.js?render=explicit",
- globalName: "grecaptcha",
- };
- }
- if (provider === "hcaptcha") {
- return {
- url: "https://js.hcaptcha.com/1/api.js?render=explicit",
- globalName: "hcaptcha",
- };
- }
- return null;
- }
-
- function loadProviderScript(definition) {
- if (window[definition.globalName]) return Promise.resolve(window[definition.globalName]);
- if (scriptPromises.has(definition.url)) return scriptPromises.get(definition.url);
-
- var promise = new Promise(function (resolve, reject) {
- var script = document.querySelector('script[src="' + definition.url + '"]');
- if (!script) {
- script = document.createElement("script");
- script.src = definition.url;
- script.async = true;
- script.defer = true;
- document.head.appendChild(script);
- }
-
- script.addEventListener(
- "load",
- function () {
- if (window[definition.globalName]) resolve(window[definition.globalName]);
- else reject(new Error("CAPTCHA provider did not initialize"));
- },
- { once: true },
- );
- script.addEventListener("error", function () { reject(new Error("CAPTCHA provider script failed")); }, { once: true });
- });
-
- scriptPromises.set(definition.url, promise);
- return promise;
- }
-
- async function mountExternal(state) {
- var definition = providerDefinition(state.config.provider);
- if (!definition) return;
-
- if (!state.config.siteKey) {
- setStatus(state, "provider-error", "A public site key is required.");
- return;
- }
-
- var mount = state.root.querySelector("[data-captcha-provider-mount]");
- if (!mount) return;
-
- show(mount, true);
- setStatus(state, "loading", "");
-
- try {
- var api = await loadProviderScript(definition);
-
- if (state.externalWidgetId !== null && typeof api.reset === "function") {
- setResponseToken(state, "");
- api.reset(state.externalWidgetId);
- setStatus(state, "ready", "");
- emit(state, "ready");
- return;
- }
-
- mount.replaceChildren();
- state.externalApi = api;
- state.externalWidgetId = api.render(mount, {
- sitekey: state.config.siteKey,
- theme: "auto",
- size: state.config.size === "compact" ? "compact" : state.config.provider === "turnstile" ? "flexible" : "normal",
- action: state.config.action,
- callback: function (token) {
- setResponseToken(state, token);
- setStatus(state, "verified", "Verification completed.");
- emit(state, "success");
- },
- "expired-callback": function () {
- setResponseToken(state, "");
- setStatus(state, "expired", state.config.expiredMessage);
- emit(state, "expired");
- },
- "error-callback": function (providerError) {
- setResponseToken(state, "");
- setStatus(state, "provider-error", state.config.networkMessage);
- emit(state, "error", { error: providerError || "provider-error" });
- },
- });
-
- setStatus(state, "ready", "");
- emit(state, "ready");
- } catch (error) {
- setStatus(state, "provider-error", state.config.networkMessage);
- emit(state, "error", { error: error instanceof Error ? error.message : String(error) });
- }
- }
-
- function playAudio(state) {
- if (!state.challenge || !state.challenge.audioUrl || state.config.disabled) return;
-
- if (state.audioPlayer) {
- state.audioPlayer.pause();
- state.audioPlayer.currentTime = 0;
- }
-
- var audio = new Audio();
- state.audioPlayer = audio;
- audio.preload = "auto";
- audio.volume = 1;
- audio.src = new URL(state.challenge.audioUrl, window.location.href).href;
-
- audio.addEventListener("ended", function () {
- if (state.audioPlayer === audio) state.audioPlayer = null;
- emit(state, "audioEnd");
- emit(state, "audio-end");
- }, { once: true });
-
- audio.addEventListener("error", function () {
- if (state.audioPlayer === audio) state.audioPlayer = null;
- var mediaError = audio.error;
- setStatus(state, "network-error", "The audio challenge could not be played. Load a new challenge and try again.");
- emit(state, "error", {
- code: "audio-playback-failed",
- audioUrl: audio.src,
- mediaErrorCode: mediaError ? mediaError.code : null,
- });
- }, { once: true });
-
- audio.load();
- audio.play().then(function () {
- emit(state, "audioStart");
- emit(state, "audio-start");
- }).catch(function (error) {
- if (state.audioPlayer === audio) state.audioPlayer = null;
- setStatus(state, "network-error", "The audio challenge could not be played. Check the audio endpoint and try again.");
- emit(state, "error", {
- code: "audio-playback-rejected",
- audioUrl: audio.src,
- error: error instanceof Error ? error.message : String(error),
- });
- });
- }
-
- function onFormSubmit(state, event) {
- if (!state.config.required || state.responseToken) return;
- event.preventDefault();
- event.stopImmediatePropagation();
- setStatus(state, "incorrect", state.config.requiredMessage);
- emit(state, "failure", { code: "missing-input" });
- var target = state.root.querySelector("[data-captcha-answer]") || state.root.querySelector("[data-captcha-not-robot-button]") || state.root.querySelector("[data-captcha-verify]");
- if (target && typeof target.focus === "function") target.focus();
- }
-
- function initialize(root) {
- if (!(root instanceof HTMLElement) || states.has(root)) return;
-
- if (!root.id) {
- instanceCounter += 1;
- root.id = "wrn-captcha-" + instanceCounter;
- }
-
- var state = {
- root: root,
- config: config(root),
- challenge: null,
- answer: "",
- selections: [],
- responseToken: "",
- status: "idle",
- timer: null,
- notRobotTimer: null,
- notRobotPending: false,
- challengeLoadedAt: 0,
- externalApi: null,
- externalWidgetId: null,
- audioPlayer: null,
- form: root.closest("form"),
- };
- states.set(root, state);
- root.dataset.captchaSize = state.config.size;
-
- var response = root.querySelector("[data-captcha-response]");
- if (response) response.name = state.config.responseField;
-
- var answer = root.querySelector("[data-captcha-answer]");
- var verifyButton = root.querySelector("[data-captcha-verify]");
- var refreshButton = root.querySelector("[data-captcha-refresh]");
- var audioButton = root.querySelector("[data-captcha-audio]");
- var audioAlternative = root.querySelector("[data-captcha-audio-alternative]");
- var notRobotButton = root.querySelector("[data-captcha-not-robot-button]");
-
- if (answer) {
- answer.addEventListener("input", function () {
- state.answer = answer.value;
- emit(state, "input", { answerLength: state.answer.length });
- });
- answer.addEventListener("keydown", function (event) {
- if (event.key === "Enter") {
- event.preventDefault();
- verify(state);
- }
- });
- }
-
- if (verifyButton) verifyButton.addEventListener("click", function () { verify(state); });
- if (notRobotButton) notRobotButton.addEventListener("click", function () { verifyNotRobot(state); });
- if (refreshButton) refreshButton.addEventListener("click", function () {
- emit(state, "refresh");
- createChallenge(state, state.config.presentation);
- });
- if (audioButton) audioButton.addEventListener("click", function () { playAudio(state); });
- if (audioAlternative) audioAlternative.addEventListener("click", function () {
- emit(state, "refresh", { presentation: "audio" });
- createChallenge(state, "audio");
- });
-
- root.addEventListener("captcha-reset", function () {
- emit(state, "refresh");
- createChallenge(state, state.config.presentation);
- });
-
- if (state.form) {
- state.form.addEventListener("submit", function (event) {
- onFormSubmit(state, event);
- }, true);
- state.form.addEventListener("wire:success", function () {
- createChallenge(state, state.config.presentation);
- });
- }
-
- updateNotRobotState(state);
- updateControls(state);
- if (state.config.disabled) setStatus(state, "disabled", "");
- else if (state.config.autoLoad) createChallenge(state, state.config.presentation);
- }
-
- function scan(scope) {
- var host = scope || document;
- if (host instanceof Element && host.matches("[data-wrn-captcha]")) initialize(host);
- host.querySelectorAll("[data-wrn-captcha]").forEach(initialize);
- }
-
- var runtime = {
- scan: scan,
- reset: function (element) {
- var root = typeof element === "string" ? document.querySelector(element) : element;
- var state = root ? states.get(root) : null;
- if (state) createChallenge(state, state.config.presentation);
- },
- verify: function (element) {
- var root = typeof element === "string" ? document.querySelector(element) : element;
- var state = root ? states.get(root) : null;
- return state ? verify(state) : Promise.resolve();
- },
- };
-
- window[RUNTIME_KEY] = runtime;
-
- if (document.readyState === "loading") {
- document.addEventListener("DOMContentLoaded", function () { scan(document); }, { once: true });
- } else {
- scan(document);
- }
-
- new MutationObserver(function (records) {
- records.forEach(function (record) {
- record.addedNodes.forEach(function (node) {
- if (node.nodeType === 1) scan(node);
- });
- });
- }).observe(document.documentElement, { childList: true, subtree: true });
-})();
diff --git a/examples/captcha-showcase/test/showcase.test.ts b/examples/captcha-showcase/test/showcase.test.ts
index 0cbb1f0d..f27eed0f 100644
--- a/examples/captcha-showcase/test/showcase.test.ts
+++ b/examples/captcha-showcase/test/showcase.test.ts
@@ -3,6 +3,14 @@ import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
const showcaseRoot = join(import.meta.dir, "..");
+const repositoryRoot = join(showcaseRoot, "..", "..");
+const packageComponentPath = join(
+ repositoryRoot,
+ "packages",
+ "captcha",
+ "components",
+ "Captcha.wrn",
+);
function path(relativePath: string): string {
return join(showcaseRoot, relativePath);
@@ -19,7 +27,6 @@ describe("CAPTCHA showcase structure", () => {
"app/pages/captcha.wrn",
"app/pages/protected.wrn",
"app/pages/styles.wrn",
- "app/components/Captcha.wrn",
"app/api/contact.ts",
"app/api/page-grant.ts",
"app/schemas/contact.ts",
@@ -41,7 +48,8 @@ describe("CAPTCHA showcase structure", () => {
expect(packageJson).toContain('"@iconify-json/lucide"');
expect(packageJson).toContain('"@iconify/tailwind4"');
expect(styles).toContain('@plugin "@iconify/tailwind4";');
- expect(styles).toContain('@source "../../../packages/captcha/components/*.wrn";');
+ expect(styles).not.toContain("packages/captcha/components");
+ expect(read("wrnexus.config.ts")).not.toContain("includePackageSources: false");
});
test("documents every bundled challenge mode", () => {
@@ -72,7 +80,6 @@ describe("CAPTCHA showcase structure", () => {
expect(source).toContain('showListen="false"');
});
-
test("documents every generated image renderer and random pool controls", () => {
const source = read("app/pages/styles.wrn");
const styles = [
@@ -170,15 +177,23 @@ describe("CAPTCHA showcase integration", () => {
expect(grantRoute).toContain("Response.redirect");
});
- test("registers the CAPTCHA plugin and DevToolbar audit", () => {
- const source = read("wrnexus.config.ts");
+ test("auto-discovers the CAPTCHA package without manual plugin or script wiring", () => {
+ const config = read("wrnexus.config.ts");
+ const packageJson = read("package.json");
+ const component = readFileSync(packageComponentPath, "utf8");
- expect(source).toContain("captchaPlugin");
- expect(source).toContain("enableDevToolbar: true");
+ expect(packageJson).toContain('"@wrnexus/captcha": "workspace:*"');
+ expect(existsSync(path("app/components/Captcha.wrn"))).toBe(false);
+ expect(existsSync(packageComponentPath)).toBe(true);
+ expect(config).not.toContain("captchaPlugin");
+ expect(config).not.toContain("plugins:");
+ expect(component).toContain('data-wrnexus-runtime="captcha"');
+ expect(component).not.toContain("
-
diff --git a/packages/captcha/package.json b/packages/captcha/package.json
index 787a93d7..1bc5352d 100644
--- a/packages/captcha/package.json
+++ b/packages/captcha/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/captcha",
- "version": "0.3.6",
+ "version": "0.4.0",
"description": "First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI.",
"type": "module",
"sideEffects": false,
@@ -49,5 +49,12 @@
"@types/bun": "latest",
"typescript": "^5.9.2",
"@wrnexus/syntax": "workspace:*"
+ },
+ "wrnexus": {
+ "plugin": {
+ "plugin": "./src/plugin.ts",
+ "export": "default",
+ "factory": true
+ }
}
}
diff --git a/packages/captcha/src/audio/renderer.ts b/packages/captcha/src/audio/renderer.ts
index 8ea17b75..499a945b 100644
--- a/packages/captcha/src/audio/renderer.ts
+++ b/packages/captcha/src/audio/renderer.ts
@@ -126,10 +126,7 @@ export function resolveCaptchaAudioAssetsDir(explicitDir?: string): string {
try {
const packageEntry = createRequire(import.meta.url).resolve("@wrnexus/captcha/audio");
- addCandidate(
- candidates,
- join(dirname(dirname(dirname(packageEntry))), "assets", "audio"),
- );
+ addCandidate(candidates, join(dirname(dirname(dirname(packageEntry))), "assets", "audio"));
} catch {
// The source-relative and cwd fallbacks below still support direct source use.
}
@@ -140,7 +137,10 @@ export function resolveCaptchaAudioAssetsDir(explicitDir?: string): string {
let current = process.cwd();
for (let depth = 0; depth < 8; depth += 1) {
addCandidate(candidates, join(current, "packages", "captcha", "assets", "audio"));
- addCandidate(candidates, join(current, "node_modules", "@wrnexus", "captcha", "assets", "audio"));
+ addCandidate(
+ candidates,
+ join(current, "node_modules", "@wrnexus", "captcha", "assets", "audio"),
+ );
const parent = dirname(current);
if (parent === current) break;
@@ -190,9 +190,7 @@ export class AssetAudioRenderer implements CaptchaAudioRenderer {
}
if (!sequence.length) throw new Error("Cannot render an empty CAPTCHA audio sequence");
- const clips = await Promise.all(
- sequence.map((token) => this.load(language, safeToken(token))),
- );
+ const clips = await Promise.all(sequence.map((token) => this.load(language, safeToken(token))));
const first = clips[0]!;
for (const clip of clips) {
@@ -223,17 +221,13 @@ export class AssetAudioRenderer implements CaptchaAudioRenderer {
const cached = this.cache.get(key);
if (cached) return cached;
- const bytes = new Uint8Array(
- await readFile(join(this.assetsDir, language, `${token}.wav`)),
- );
+ const bytes = new Uint8Array(await readFile(join(this.assetsDir, language, `${token}.wav`)));
const parsed = parseWav(bytes);
this.cache.set(key, parsed);
return parsed;
}
}
-export function createAssetAudioRenderer(
- options?: AssetAudioRendererOptions,
-): AssetAudioRenderer {
+export function createAssetAudioRenderer(options?: AssetAudioRendererOptions): AssetAudioRenderer {
return new AssetAudioRenderer(options);
}
diff --git a/packages/captcha/src/challenges/calculation.ts b/packages/captcha/src/challenges/calculation.ts
index 704c83de..6d7e31f4 100644
--- a/packages/captcha/src/challenges/calculation.ts
+++ b/packages/captcha/src/challenges/calculation.ts
@@ -51,7 +51,13 @@ export class CalculationCaptchaGenerator implements CaptchaChallengeGenerator {
answerKind: "text",
image: renderTextChallenge(expression, context),
inputMode: "numeric",
- audioSequence: ["what", "is", ...numberTokens(left), ...(OPERATOR_WORDS[operator] ?? []), ...numberTokens(right)],
+ audioSequence: [
+ "what",
+ "is",
+ ...numberTokens(left),
+ ...(OPERATOR_WORDS[operator] ?? []),
+ ...numberTokens(right),
+ ],
metadata: { operator, imageStyle: context.imageStyle },
};
}
diff --git a/packages/captcha/src/challenges/image.ts b/packages/captcha/src/challenges/image.ts
index 6994c944..df950229 100644
--- a/packages/captcha/src/challenges/image.ts
+++ b/packages/captcha/src/challenges/image.ts
@@ -4,7 +4,15 @@ import type {
CaptchaImageItem,
GeneratedCaptchaChallenge,
} from "../types.ts";
-import { createImage, drawLine, fillCircle, fillPolygon, fillRect, pngDataUri, setPixel } from "./png.ts";
+import {
+ createImage,
+ drawLine,
+ fillCircle,
+ fillPolygon,
+ fillRect,
+ pngDataUri,
+ setPixel,
+} from "./png.ts";
const SHAPES = ["circle", "square", "triangle", "diamond", "star"] as const;
type Shape = (typeof SHAPES)[number];
@@ -39,9 +47,26 @@ function shapeImage(shape: Shape, context: CaptchaGeneratorContext): string {
if (shape === "circle") fillCircle(image, cx, cy, size, color);
else if (shape === "square") fillRect(image, cx - size, cy - size, size * 2, size * 2, color);
else if (shape === "triangle") {
- fillPolygon(image, [[cx, cy - size], [cx - size, cy + size], [cx + size, cy + size]], color);
+ fillPolygon(
+ image,
+ [
+ [cx, cy - size],
+ [cx - size, cy + size],
+ [cx + size, cy + size],
+ ],
+ color,
+ );
} else if (shape === "diamond") {
- fillPolygon(image, [[cx, cy - size], [cx - size, cy], [cx, cy + size], [cx + size, cy]], color);
+ fillPolygon(
+ image,
+ [
+ [cx, cy - size],
+ [cx - size, cy],
+ [cx, cy + size],
+ [cx + size, cy],
+ ],
+ color,
+ );
} else {
fillPolygon(image, starPoints(cx, cy, size, size * 0.45), color);
}
@@ -49,12 +74,12 @@ function shapeImage(shape: Shape, context: CaptchaGeneratorContext): string {
const ratio = disturbanceRatio(context);
const dots = Math.round(35 + ratio * 150);
for (let index = 0; index < dots; index++) {
- setPixel(
- image,
- context.randomInt(0, 95),
- context.randomInt(0, 71),
- [context.randomInt(105, 225), context.randomInt(105, 225), context.randomInt(105, 225), Math.round(55 + ratio * 65)],
- );
+ setPixel(image, context.randomInt(0, 95), context.randomInt(0, 71), [
+ context.randomInt(105, 225),
+ context.randomInt(105, 225),
+ context.randomInt(105, 225),
+ Math.round(55 + ratio * 65),
+ ]);
}
const lines = Math.round(1 + ratio * 4);
@@ -65,7 +90,12 @@ function shapeImage(shape: Shape, context: CaptchaGeneratorContext): string {
context.randomInt(0, 71),
context.randomInt(0, 95),
context.randomInt(0, 71),
- [context.randomInt(100, 210), context.randomInt(100, 210), context.randomInt(100, 210), Math.round(45 + ratio * 55)],
+ [
+ context.randomInt(100, 210),
+ context.randomInt(100, 210),
+ context.randomInt(100, 210),
+ Math.round(45 + ratio * 55),
+ ],
ratio > 0.75 ? 2 : 1,
);
}
@@ -105,7 +135,11 @@ export class ImageCaptchaGenerator implements CaptchaChallengeGenerator {
}
shuffle(entries, context);
- const answer = entries.filter((entry) => entry.shape === target).map((entry) => entry.id).sort().join(",");
+ const answer = entries
+ .filter((entry) => entry.shape === target)
+ .map((entry) => entry.id)
+ .sort()
+ .join(",");
return {
type: "image",
presentation: "visual",
diff --git a/packages/captcha/src/challenges/index.ts b/packages/captcha/src/challenges/index.ts
index dde41200..f33ca3fb 100644
--- a/packages/captcha/src/challenges/index.ts
+++ b/packages/captcha/src/challenges/index.ts
@@ -1,8 +1,16 @@
import type { CaptchaChallengeGenerator } from "../types.ts";
-import { alphaCaptchaGenerator, alphanumericCaptchaGenerator, numberCaptchaGenerator } from "./text.ts";
+import {
+ alphaCaptchaGenerator,
+ alphanumericCaptchaGenerator,
+ numberCaptchaGenerator,
+} from "./text.ts";
import { calculationCaptchaGenerator } from "./calculation.ts";
import { imageCaptchaGenerator } from "./image.ts";
-import { honeypotCaptchaGenerator, notRobotCaptchaGenerator, timingCaptchaGenerator } from "./invisible.ts";
+import {
+ honeypotCaptchaGenerator,
+ notRobotCaptchaGenerator,
+ timingCaptchaGenerator,
+} from "./invisible.ts";
export * from "./text.ts";
export * from "./calculation.ts";
@@ -13,7 +21,8 @@ export * from "./styles.ts";
export function defineCaptchaGenerator
(generator: T): T {
if (!generator.type) throw new TypeError("CAPTCHA generator requires a stable type");
- if (typeof generator.generate !== "function") throw new TypeError("CAPTCHA generator requires generate()");
+ if (typeof generator.generate !== "function")
+ throw new TypeError("CAPTCHA generator requires generate()");
return generator;
}
diff --git a/packages/captcha/src/challenges/invisible.ts b/packages/captcha/src/challenges/invisible.ts
index 1594e501..5e0ab1d3 100644
--- a/packages/captcha/src/challenges/invisible.ts
+++ b/packages/captcha/src/challenges/invisible.ts
@@ -19,7 +19,8 @@ export class InvisibleCaptchaGenerator implements CaptchaChallengeGenerator {
return {
type: this.type,
presentation: "invisible",
- prompt: this.type === "not-robot" ? "Confirm that you are not a robot" : "Automated abuse check",
+ prompt:
+ this.type === "not-robot" ? "Confirm that you are not a robot" : "Automated abuse check",
answer: JSON.stringify({ honeypot: "", timingToken }),
answerKind: "invisible",
inputMode: "none",
diff --git a/packages/captcha/src/challenges/png.ts b/packages/captcha/src/challenges/png.ts
index e6a5438a..177039ba 100644
--- a/packages/captcha/src/challenges/png.ts
+++ b/packages/captcha/src/challenges/png.ts
@@ -8,7 +8,11 @@ export interface RgbaImage {
export type Rgba = readonly [number, number, number, number?];
-export function createImage(width: number, height: number, background: Rgba = [255, 255, 255, 255]): RgbaImage {
+export function createImage(
+ width: number,
+ height: number,
+ background: Rgba = [255, 255, 255, 255],
+): RgbaImage {
const data = new Uint8Array(width * height * 4);
const alpha = background[3] ?? 255;
for (let index = 0; index < data.length; index += 4) {
@@ -33,13 +37,28 @@ export function setPixel(image: RgbaImage, x: number, y: number, color: Rgba): v
image.data[index + 3] = 255;
}
-export function fillRect(image: RgbaImage, x: number, y: number, width: number, height: number, color: Rgba): void {
+export function fillRect(
+ image: RgbaImage,
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+ color: Rgba,
+): void {
for (let py = Math.floor(y); py < Math.ceil(y + height); py++) {
for (let px = Math.floor(x); px < Math.ceil(x + width); px++) setPixel(image, px, py, color);
}
}
-export function drawLine(image: RgbaImage, x0: number, y0: number, x1: number, y1: number, color: Rgba, thickness = 1): void {
+export function drawLine(
+ image: RgbaImage,
+ x0: number,
+ y0: number,
+ x1: number,
+ y1: number,
+ color: Rgba,
+ thickness = 1,
+): void {
let x = Math.round(x0);
let y = Math.round(y0);
const targetX = Math.round(x1);
@@ -50,7 +69,14 @@ export function drawLine(image: RgbaImage, x0: number, y0: number, x1: number, y
const sy = y < targetY ? 1 : -1;
let error = dx + dy;
while (true) {
- fillRect(image, x - Math.floor(thickness / 2), y - Math.floor(thickness / 2), thickness, thickness, color);
+ fillRect(
+ image,
+ x - Math.floor(thickness / 2),
+ y - Math.floor(thickness / 2),
+ thickness,
+ thickness,
+ color,
+ );
if (x === targetX && y === targetY) break;
const twice = 2 * error;
if (twice >= dy) {
@@ -64,7 +90,13 @@ export function drawLine(image: RgbaImage, x0: number, y0: number, x1: number, y
}
}
-export function fillCircle(image: RgbaImage, centerX: number, centerY: number, radius: number, color: Rgba): void {
+export function fillCircle(
+ image: RgbaImage,
+ centerX: number,
+ centerY: number,
+ radius: number,
+ color: Rgba,
+): void {
const r2 = radius * radius;
for (let y = Math.floor(centerY - radius); y <= Math.ceil(centerY + radius); y++) {
for (let x = Math.floor(centerX - radius); x <= Math.ceil(centerX + radius); x++) {
@@ -75,7 +107,11 @@ export function fillCircle(image: RgbaImage, centerX: number, centerY: number, r
}
}
-function pointInPolygon(x: number, y: number, points: readonly (readonly [number, number])[]): boolean {
+function pointInPolygon(
+ x: number,
+ y: number,
+ points: readonly (readonly [number, number])[],
+): boolean {
let inside = false;
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
const xi = points[i]![0];
@@ -88,13 +124,18 @@ function pointInPolygon(x: number, y: number, points: readonly (readonly [number
return inside;
}
-export function fillPolygon(image: RgbaImage, points: readonly (readonly [number, number])[], color: Rgba): void {
+export function fillPolygon(
+ image: RgbaImage,
+ points: readonly (readonly [number, number])[],
+ color: Rgba,
+): void {
const minX = Math.floor(Math.min(...points.map((point) => point[0])));
const maxX = Math.ceil(Math.max(...points.map((point) => point[0])));
const minY = Math.floor(Math.min(...points.map((point) => point[1])));
const maxY = Math.ceil(Math.max(...points.map((point) => point[1])));
for (let y = minY; y <= maxY; y++) {
- for (let x = minX; x <= maxX; x++) if (pointInPolygon(x + 0.5, y + 0.5, points)) setPixel(image, x, y, color);
+ for (let x = minX; x <= maxX; x++)
+ if (pointInPolygon(x + 0.5, y + 0.5, points)) setPixel(image, x, y, color);
}
}
@@ -134,7 +175,15 @@ export function drawText(
let cursor = options.x;
for (let index = 0; index < text.length; index++) {
const jitter = options.jitter?.(index) ?? { x: 0, y: 0, shear: 0 };
- drawGlyph(image, text[index]!, cursor + jitter.x, options.y + jitter.y, options.scale, options.color, jitter.shear);
+ drawGlyph(
+ image,
+ text[index]!,
+ cursor + jitter.x,
+ options.y + jitter.y,
+ options.scale,
+ options.color,
+ jitter.shear,
+ );
cursor += options.scale * 5 + spacing;
}
}
@@ -170,7 +219,12 @@ function adler32(bytes: Uint8Array): number {
}
function u32(value: number): Uint8Array {
- return Uint8Array.of((value >>> 24) & 255, (value >>> 16) & 255, (value >>> 8) & 255, value & 255);
+ return Uint8Array.of(
+ (value >>> 24) & 255,
+ (value >>> 16) & 255,
+ (value >>> 8) & 255,
+ value & 255,
+ );
}
function concat(parts: readonly Uint8Array[]): Uint8Array {
@@ -195,9 +249,15 @@ function deflateStored(data: Uint8Array): Uint8Array {
const size = Math.min(65535, data.length - offset);
const final = offset + size >= data.length;
const length = size;
- const inverse = (~length) & 0xffff;
+ const inverse = ~length & 0xffff;
blocks.push(
- Uint8Array.of(final ? 1 : 0, length & 255, (length >>> 8) & 255, inverse & 255, (inverse >>> 8) & 255),
+ Uint8Array.of(
+ final ? 1 : 0,
+ length & 255,
+ (length >>> 8) & 255,
+ inverse & 255,
+ (inverse >>> 8) & 255,
+ ),
data.slice(offset, offset + size),
);
}
diff --git a/packages/captcha/src/challenges/styles.ts b/packages/captcha/src/challenges/styles.ts
index a9b07f61..c058068d 100644
--- a/packages/captcha/src/challenges/styles.ts
+++ b/packages/captcha/src/challenges/styles.ts
@@ -1,7 +1,4 @@
-import type {
- CaptchaConcreteImageStyle,
- CaptchaImageStyle,
-} from "../types.ts";
+import type { CaptchaConcreteImageStyle, CaptchaImageStyle } from "../types.ts";
export const CAPTCHA_CONCRETE_IMAGE_STYLES = [
"classic",
@@ -52,9 +49,7 @@ export function normalizeCaptchaImageStyle(
if (value === undefined || value === null || value === "") return fallback;
const normalized = String(value).trim().toLowerCase();
if (!STYLE_SET.has(normalized)) {
- throw new RangeError(
- `imageStyle must be one of: ${CAPTCHA_IMAGE_STYLES.join(", ")}`,
- );
+ throw new RangeError(`imageStyle must be one of: ${CAPTCHA_IMAGE_STYLES.join(", ")}`);
}
return normalized as CaptchaImageStyle;
}
@@ -67,9 +62,7 @@ export function normalizeCaptchaImageStyleList(
for (const entry of styleValues(value)) {
if (entry === "random") continue;
if (!CONCRETE_STYLE_SET.has(entry)) {
- throw new RangeError(
- `${name} contains an unknown image style: ${entry}`,
- );
+ throw new RangeError(`${name} contains an unknown image style: ${entry}`);
}
const style = entry as CaptchaConcreteImageStyle;
if (!styles.includes(style)) styles.push(style);
@@ -95,17 +88,12 @@ export function resolveCaptchaImageStyle(
options: ResolveCaptchaImageStyleOptions,
): ResolvedCaptchaImageStyle {
const requested = normalizeCaptchaImageStyle(options.imageStyle, "random");
- const allowed = normalizeCaptchaImageStyleList(
- options.allowedStyles,
- "allowedStyles",
- );
+ const allowed = normalizeCaptchaImageStyleList(options.allowedStyles, "allowedStyles");
const excluded = new Set(
normalizeCaptchaImageStyleList(options.excludedStyles, "excludedStyles"),
);
- const source = allowed.length
- ? allowed
- : [...CAPTCHA_CONCRETE_IMAGE_STYLES];
+ const source = allowed.length ? allowed : [...CAPTCHA_CONCRETE_IMAGE_STYLES];
const pool = source.filter((style) => !excluded.has(style));
if (!pool.length) {
@@ -116,9 +104,7 @@ export function resolveCaptchaImageStyle(
if (!options.randomizeStyle && requested !== "random") {
if (!pool.includes(requested)) {
- throw new RangeError(
- `imageStyle ${requested} is not available in the configured style pool`,
- );
+ throw new RangeError(`imageStyle ${requested} is not available in the configured style pool`);
}
return { requested, resolved: requested, pool };
}
diff --git a/packages/captcha/src/challenges/text.ts b/packages/captcha/src/challenges/text.ts
index f7bb8f4f..ceb1f687 100644
--- a/packages/captcha/src/challenges/text.ts
+++ b/packages/captcha/src/challenges/text.ts
@@ -11,7 +11,9 @@ const ALPHA = "ABCDEFGHJKMNPQRSTUVWXYZ";
const ALPHANUMERIC = `${ALPHA}${NUMBERS}`;
function defaultLength(context: CaptchaGeneratorContext): number {
- return context.length ?? (context.difficulty === "easy" ? 4 : context.difficulty === "hard" ? 7 : 6);
+ return (
+ context.length ?? (context.difficulty === "easy" ? 4 : context.difficulty === "hard" ? 7 : 6)
+ );
}
function charset(type: CaptchaChallengeType): string {
@@ -20,11 +22,15 @@ function charset(type: CaptchaChallengeType): string {
return ALPHANUMERIC;
}
-function generateText(type: "number" | "alpha" | "alphanumeric", context: CaptchaGeneratorContext): GeneratedCaptchaChallenge {
+function generateText(
+ type: "number" | "alpha" | "alphanumeric",
+ context: CaptchaGeneratorContext,
+): GeneratedCaptchaChallenge {
const source = charset(type);
const length = Math.max(3, Math.min(10, defaultLength(context)));
let answer = "";
- for (let index = 0; index < length; index++) answer += source[context.randomInt(0, source.length - 1)];
+ for (let index = 0; index < length; index++)
+ answer += source[context.randomInt(0, source.length - 1)];
return {
type,
presentation: "visual",
diff --git a/packages/captcha/src/challenges/visual.ts b/packages/captcha/src/challenges/visual.ts
index 518d548e..61f83c8e 100644
--- a/packages/captcha/src/challenges/visual.ts
+++ b/packages/captcha/src/challenges/visual.ts
@@ -1,7 +1,4 @@
-import type {
- CaptchaConcreteImageStyle,
- CaptchaGeneratorContext,
-} from "../types.ts";
+import type { CaptchaConcreteImageStyle, CaptchaGeneratorContext } from "../types.ts";
import {
createImage,
drawGlyph,
@@ -56,7 +53,11 @@ function randomColor(
];
}
-function rawPixel(image: RgbaImage, x: number, y: number): readonly [number, number, number, number] {
+function rawPixel(
+ image: RgbaImage,
+ x: number,
+ y: number,
+): readonly [number, number, number, number] {
const px = clamp(Math.round(x), 0, image.width - 1);
const py = clamp(Math.round(y), 0, image.height - 1);
const index = (py * image.width + px) * 4;
@@ -98,11 +99,12 @@ function layoutFor(text: string, style: CaptchaConcreteImageStyle): TextLayout {
const scale = text.length > 9 ? 3 : text.length > 7 ? 4 : 5;
const glyphWidth = scale * 5;
const normalSpacing = scale + 3;
- const spacing = style === "collision"
- ? Math.max(-Math.floor(scale * 0.45), -2)
- : style === "cross-shadow"
- ? scale
- : normalSpacing;
+ const spacing =
+ style === "collision"
+ ? Math.max(-Math.floor(scale * 0.45), -2)
+ : style === "cross-shadow"
+ ? scale
+ : normalSpacing;
const textWidth = text.length * glyphWidth + Math.max(0, text.length - 1) * spacing;
const textHeight = scale * 7;
return {
@@ -218,9 +220,8 @@ function drawCharacters(
const y = layout.startY + context.randomInt(-jitterY, jitterY);
const glyphShear = (context.randomFloat() - 0.5) * shear;
const accent = ACCENTS[context.randomInt(0, ACCENTS.length - 1)]!;
- const color = style === "collision" || style === "cross-shadow" || style === "pixel"
- ? accent
- : INK;
+ const color =
+ style === "collision" || style === "cross-shadow" || style === "pixel" ? accent : INK;
if (style === "cross-shadow") {
drawGlyph(image, character, x - 3, y + 3, layout.scale, [37, 99, 235, 95], glyphShear);
@@ -228,7 +229,15 @@ function drawCharacters(
} else if (style === "collision") {
drawGlyph(image, character, x - 2, y + 2, layout.scale, [15, 23, 42, 75], glyphShear);
if (index > 0 && context.randomFloat() < 0.65) {
- drawGlyph(image, character, x + context.randomInt(-4, 1), y, layout.scale, [2, 6, 23, 60], -glyphShear);
+ drawGlyph(
+ image,
+ character,
+ x + context.randomInt(-4, 1),
+ y,
+ layout.scale,
+ [2, 6, 23, 60],
+ -glyphShear,
+ );
}
} else if (context.randomFloat() < 0.28 + ratio * 0.35) {
drawGlyph(image, character, x + 1, y + 1, layout.scale, [15, 23, 42, 70], glyphShear);
@@ -283,13 +292,25 @@ function shiftColumns(
function applyClassic(image: RgbaImage, context: CaptchaGeneratorContext): void {
const ratio = disturbanceRatio(context);
- addCrossingLines(image, context, Math.round(3 + ratio * 5), Math.round(55 + ratio * 55), ratio > 0.7 ? 2 : 1);
+ addCrossingLines(
+ image,
+ context,
+ Math.round(3 + ratio * 5),
+ Math.round(55 + ratio * 55),
+ ratio > 0.7 ? 2 : 1,
+ );
addDots(image, context, Math.round(140 + ratio * 470), Math.round(45 + ratio * 50));
}
function applyCollision(image: RgbaImage, context: CaptchaGeneratorContext): void {
const ratio = disturbanceRatio(context);
- addCrossingLines(image, context, Math.round(5 + ratio * 7), Math.round(60 + ratio * 60), ratio > 0.55 ? 2 : 1);
+ addCrossingLines(
+ image,
+ context,
+ Math.round(5 + ratio * 7),
+ Math.round(60 + ratio * 60),
+ ratio > 0.55 ? 2 : 1,
+ );
addDots(image, context, Math.round(120 + ratio * 320), 70);
const bars = Math.round(2 + ratio * 5);
for (let index = 0; index < bars; index++) {
@@ -306,7 +327,15 @@ function applyCollision(image: RgbaImage, context: CaptchaGeneratorContext): voi
function applySnow(image: RgbaImage, context: CaptchaGeneratorContext): void {
const ratio = disturbanceRatio(context);
- addDots(image, context, Math.round(420 + ratio * 950), Math.round(85 + ratio * 75), 120, 245, ratio > 0.5 ? 2 : 1);
+ addDots(
+ image,
+ context,
+ Math.round(420 + ratio * 950),
+ Math.round(85 + ratio * 75),
+ 120,
+ 245,
+ ratio > 0.5 ? 2 : 1,
+ );
for (let index = 0; index < Math.round(35 + ratio * 90); index++) {
const x = context.randomInt(0, image.width - 1);
const y = context.randomInt(0, image.height - 1);
@@ -327,7 +356,14 @@ function applyCorrosion(
const x = context.randomInt(layout.startX - 5, layout.startX + layout.textWidth + 5);
const y = context.randomInt(layout.startY - 5, layout.startY + layout.textHeight + 5);
const size = context.randomInt(1, ratio > 0.65 ? 4 : 3);
- fillRect(image, x, y, size, size, context.randomFloat() < 0.6 ? LIGHT_BACKGROUND : [203, 213, 225, 210]);
+ fillRect(
+ image,
+ x,
+ y,
+ size,
+ size,
+ context.randomFloat() < 0.6 ? LIGHT_BACKGROUND : [203, 213, 225, 210],
+ );
}
addDots(image, context, Math.round(170 + ratio * 430), 85, 90, 190, 2);
addCrossingLines(image, context, Math.round(2 + ratio * 4), 60, 1);
@@ -341,12 +377,32 @@ function applySpiderweb(image: RgbaImage, context: CaptchaGeneratorContext): voi
}));
for (let index = 0; index < nodes.length; index++) {
const current = nodes[index]!;
- const next = nodes[(index + context.randomInt(1, Math.max(1, nodes.length - 1))) % nodes.length]!;
- drawLine(image, current.x, current.y, next.x, next.y, [71, 85, 105, Math.round(65 + ratio * 65)], 1);
- if (index % 2 === 0) drawCircleOutline(image, current.x, current.y, context.randomInt(2, 5), [100, 116, 139, 70], 1);
+ const next =
+ nodes[(index + context.randomInt(1, Math.max(1, nodes.length - 1))) % nodes.length]!;
+ drawLine(
+ image,
+ current.x,
+ current.y,
+ next.x,
+ next.y,
+ [71, 85, 105, Math.round(65 + ratio * 65)],
+ 1,
+ );
+ if (index % 2 === 0)
+ drawCircleOutline(
+ image,
+ current.x,
+ current.y,
+ context.randomInt(2, 5),
+ [100, 116, 139, 70],
+ 1,
+ );
}
const anchorX = context.randomInt(Math.floor(image.width * 0.25), Math.floor(image.width * 0.75));
- const anchorY = context.randomInt(Math.floor(image.height * 0.25), Math.floor(image.height * 0.75));
+ const anchorY = context.randomInt(
+ Math.floor(image.height * 0.25),
+ Math.floor(image.height * 0.75),
+ );
for (let index = 0; index < Math.round(7 + ratio * 7); index++) {
const angle = (index / Math.round(7 + ratio * 7)) * Math.PI * 2;
drawLine(
@@ -365,8 +421,24 @@ function applySpiderweb(image: RgbaImage, context: CaptchaGeneratorContext): voi
function applyCrossShadow(image: RgbaImage, context: CaptchaGeneratorContext): void {
const ratio = disturbanceRatio(context);
const centerY = Math.floor(image.height / 2);
- drawLine(image, 0, centerY - 6, image.width - 1, centerY + 6, [37, 99, 235, 75], ratio > 0.55 ? 2 : 1);
- drawLine(image, 0, centerY + 7, image.width - 1, centerY - 8, [220, 38, 38, 65], ratio > 0.55 ? 2 : 1);
+ drawLine(
+ image,
+ 0,
+ centerY - 6,
+ image.width - 1,
+ centerY + 6,
+ [37, 99, 235, 75],
+ ratio > 0.55 ? 2 : 1,
+ );
+ drawLine(
+ image,
+ 0,
+ centerY + 7,
+ image.width - 1,
+ centerY - 8,
+ [220, 38, 38, 65],
+ ratio > 0.55 ? 2 : 1,
+ );
addCrossingLines(image, context, Math.round(2 + ratio * 5), 55, 1);
addDots(image, context, Math.round(120 + ratio * 300), 60);
}
@@ -399,11 +471,7 @@ function applySplit2(image: RgbaImage, context: CaptchaGeneratorContext): void {
addCrossingLines(image, context, Math.round(2 + ratio * 4), 55, 1);
}
-function applyCut(
- image: RgbaImage,
- context: CaptchaGeneratorContext,
- layout: TextLayout,
-): void {
+function applyCut(image: RgbaImage, context: CaptchaGeneratorContext, layout: TextLayout): void {
const ratio = disturbanceRatio(context);
const cuts = Math.round(4 + ratio * 8);
for (let index = 0; index < cuts; index++) {
@@ -459,12 +527,32 @@ function applyStitch(image: RgbaImage, context: CaptchaGeneratorContext): void {
const rows = Math.round(4 + ratio * 5);
for (let index = 0; index < rows; index++) {
const y = Math.round(((index + 1) / (rows + 1)) * image.height);
- drawDashedLine(image, 0, y, image.width - 1, y + context.randomInt(-3, 3), [71, 85, 105, 75], 4, 4, 1);
+ drawDashedLine(
+ image,
+ 0,
+ y,
+ image.width - 1,
+ y + context.randomInt(-3, 3),
+ [71, 85, 105, 75],
+ 4,
+ 4,
+ 1,
+ );
}
const columns = Math.round(2 + ratio * 4);
for (let index = 0; index < columns; index++) {
const x = context.randomInt(10, image.width - 10);
- drawDashedLine(image, x, 0, x + context.randomInt(-5, 5), image.height - 1, [100, 116, 139, 65], 3, 5, 1);
+ drawDashedLine(
+ image,
+ x,
+ 0,
+ x + context.randomInt(-5, 5),
+ image.height - 1,
+ [100, 116, 139, 65],
+ 3,
+ 5,
+ 1,
+ );
}
for (let index = 0; index < Math.round(18 + ratio * 35); index++) {
const x = context.randomInt(0, image.width - 1);
@@ -479,7 +567,15 @@ function applyStriped(image: RgbaImage, context: CaptchaGeneratorContext): void
const spacing = Math.round(10 - ratio * 4);
const offset = context.randomInt(-image.height, image.width);
for (let x = offset; x < image.width + image.height; x += spacing) {
- drawLine(image, x, 0, x - image.height, image.height - 1, [71, 85, 105, Math.round(45 + ratio * 45)], ratio > 0.7 ? 2 : 1);
+ drawLine(
+ image,
+ x,
+ 0,
+ x - image.height,
+ image.height - 1,
+ [71, 85, 105, Math.round(45 + ratio * 45)],
+ ratio > 0.7 ? 2 : 1,
+ );
}
for (let y = context.randomInt(3, 9); y < image.height; y += context.randomInt(8, 14)) {
drawLine(image, 0, y, image.width - 1, y, [148, 163, 184, 45], 1);
@@ -595,16 +691,20 @@ function applyBrokenLines(
for (let index = 0; index < Math.round(12 + ratio * 28); index++) {
const x = context.randomInt(0, image.width - 12);
const y = context.randomInt(0, image.height - 1);
- drawLine(image, x, y, x + context.randomInt(4, 18), y + context.randomInt(-2, 2), randomColor(context, 45, 175, 75), 1);
+ drawLine(
+ image,
+ x,
+ y,
+ x + context.randomInt(4, 18),
+ y + context.randomInt(-2, 2),
+ randomColor(context, 45, 175, 75),
+ 1,
+ );
}
addDots(image, context, Math.round(70 + ratio * 180), 60);
}
-function applyStyle(
- image: RgbaImage,
- context: CaptchaGeneratorContext,
- layout: TextLayout,
-): void {
+function applyStyle(image: RgbaImage, context: CaptchaGeneratorContext, layout: TextLayout): void {
switch (context.imageStyle) {
case "collision":
applyCollision(image, context);
@@ -667,10 +767,7 @@ function backgroundFor(style: CaptchaConcreteImageStyle): Rgba {
return LIGHT_BACKGROUND;
}
-export function renderTextChallenge(
- text: string,
- context: CaptchaGeneratorContext,
-): string {
+export function renderTextChallenge(text: string, context: CaptchaGeneratorContext): string {
const layout = layoutFor(text, context.imageStyle);
const image = createImage(300, 104, backgroundFor(context.imageStyle));
diff --git a/packages/captcha/src/crypto.ts b/packages/captcha/src/crypto.ts
index 37e6a24a..fc693c32 100644
--- a/packages/captcha/src/crypto.ts
+++ b/packages/captcha/src/crypto.ts
@@ -1,7 +1,8 @@
const encoder = new TextEncoder();
export function defaultRandomBytes(length: number): Uint8Array {
- if (!Number.isInteger(length) || length < 1) throw new RangeError("random byte length must be positive");
+ if (!Number.isInteger(length) || length < 1)
+ throw new RangeError("random byte length must be positive");
const bytes = new Uint8Array(length);
crypto.getRandomValues(bytes);
return bytes;
diff --git a/packages/captcha/src/engine.ts b/packages/captcha/src/engine.ts
index 417c3aaa..ae295168 100644
--- a/packages/captcha/src/engine.ts
+++ b/packages/captcha/src/engine.ts
@@ -1,8 +1,19 @@
import { defaultCaptchaGenerators } from "./challenges/index.ts";
import { resolveCaptchaImageStyle } from "./challenges/styles.ts";
import { AssetAudioRenderer } from "./audio/renderer.ts";
-import { bindingHash, constantTimeEqual, defaultRandomBytes, hmacSha256, randomId, sha256 } from "./crypto.ts";
-import { normalizeSelections, normalizeTextAnswer, normalizedSubmittedAnswer } from "./normalize.ts";
+import {
+ bindingHash,
+ constantTimeEqual,
+ defaultRandomBytes,
+ hmacSha256,
+ randomId,
+ sha256,
+} from "./crypto.ts";
+import {
+ normalizeSelections,
+ normalizeTextAnswer,
+ normalizedSubmittedAnswer,
+} from "./normalize.ts";
import { MemoryCaptchaStore } from "./stores/memory.ts";
import type {
CaptchaBinding,
@@ -40,13 +51,16 @@ function failure(
function assertAction(action: string): string {
const value = action.trim();
if (!value || value.length > 128 || !/^[a-z0-9][a-z0-9:._/-]*$/i.test(value)) {
- throw new TypeError("CAPTCHA action must be a non-empty stable identifier up to 128 characters");
+ throw new TypeError(
+ "CAPTCHA action must be a non-empty stable identifier up to 128 characters",
+ );
}
return value;
}
function assertPositiveInteger(value: number, name: string): number {
- if (!Number.isInteger(value) || value < 1) throw new RangeError(`${name} must be a positive integer`);
+ if (!Number.isInteger(value) || value < 1)
+ throw new RangeError(`${name} must be a positive integer`);
return value;
}
@@ -54,7 +68,8 @@ function normalizeDisturbance(value: number | undefined, difficulty: CaptchaDiff
const fallback = difficulty === "easy" ? 25 : difficulty === "hard" ? 75 : 50;
if (value === undefined) return fallback;
const numeric = Number(value);
- if (!Number.isFinite(numeric)) throw new RangeError("disturbance must be a finite number between 25 and 75");
+ if (!Number.isFinite(numeric))
+ throw new RangeError("disturbance must be a finite number between 25 and 75");
const normalized = Math.round(numeric);
if (normalized < 25 || normalized > 75) {
throw new RangeError("disturbance must be between 25 and 75");
@@ -62,8 +77,13 @@ function normalizeDisturbance(value: number | undefined, difficulty: CaptchaDiff
return normalized;
}
-function randomInteger(randomBytes: (length: number) => Uint8Array, min: number, max: number): number {
- if (!Number.isInteger(min) || !Number.isInteger(max) || max < min) throw new RangeError("invalid random range");
+function randomInteger(
+ randomBytes: (length: number) => Uint8Array,
+ min: number,
+ max: number,
+): number {
+ if (!Number.isInteger(min) || !Number.isInteger(max) || max < min)
+ throw new RangeError("invalid random range");
const span = max - min + 1;
if (span === 1) return min;
const limit = Math.floor(0x1_0000_0000 / span) * span;
@@ -74,10 +94,13 @@ function randomInteger(randomBytes: (length: number) => Uint8Array, min: number,
}
}
-async function matchesHash(expected: string | undefined, raw: string | undefined): Promise {
+async function matchesHash(
+ expected: string | undefined,
+ raw: string | undefined,
+): Promise {
if (!expected) return true;
if (!raw) return false;
- return constantTimeEqual(expected, await bindingHash(raw) ?? "");
+ return constantTimeEqual(expected, (await bindingHash(raw)) ?? "");
}
export class DefaultCaptchaEngine implements CaptchaEngine {
@@ -106,9 +129,18 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
this.store = options.store ?? new MemoryCaptchaStore();
this.audioRenderer = options.audioRenderer ?? new AssetAudioRenderer();
this.basePath = `/${(options.basePath ?? "/__wrnexus/captcha").replace(/^\/+|\/+$/g, "")}`;
- this.challengeTtlMs = assertPositiveInteger(options.challengeTtlMs ?? DEFAULT_CHALLENGE_TTL_MS, "challengeTtlMs");
- this.responseTokenTtlMs = assertPositiveInteger(options.responseTokenTtlMs ?? DEFAULT_TOKEN_TTL_MS, "responseTokenTtlMs");
- this.maxAttempts = assertPositiveInteger(options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS, "maxAttempts");
+ this.challengeTtlMs = assertPositiveInteger(
+ options.challengeTtlMs ?? DEFAULT_CHALLENGE_TTL_MS,
+ "challengeTtlMs",
+ );
+ this.responseTokenTtlMs = assertPositiveInteger(
+ options.responseTokenTtlMs ?? DEFAULT_TOKEN_TTL_MS,
+ "responseTokenTtlMs",
+ );
+ this.maxAttempts = assertPositiveInteger(
+ options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
+ "maxAttempts",
+ );
this.minCompletionMs = Math.max(0, options.minCompletionMs ?? DEFAULT_MIN_COMPLETION_MS);
this.responseField = options.responseField ?? DEFAULT_RESPONSE_FIELD;
this.defaultType = options.defaultType ?? "alphanumeric";
@@ -116,19 +148,22 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
this.bindIp = options.bindIp ?? false;
this.now = options.now ?? Date.now;
this.randomBytes = options.randomBytes ?? defaultRandomBytes;
- for (const generator of options.generators ?? defaultCaptchaGenerators()) this.generators.set(generator.type, generator);
- if (!this.generators.has(this.defaultType)) throw new Error(`No CAPTCHA generator registered for ${this.defaultType}`);
+ for (const generator of options.generators ?? defaultCaptchaGenerators())
+ this.generators.set(generator.type, generator);
+ if (!this.generators.has(this.defaultType))
+ throw new Error(`No CAPTCHA generator registered for ${this.defaultType}`);
}
async create(options: CreateCaptchaOptions): Promise {
const action = assertAction(options.action);
const requestedType = options.type ?? this.defaultType;
- const requestedPresentation = options.presentation ?? (
- requestedType === "honeypot" || requestedType === "timing" || requestedType === "not-robot"
+ const requestedPresentation =
+ options.presentation ??
+ (requestedType === "honeypot" || requestedType === "timing" || requestedType === "not-robot"
? "invisible"
- : "visual"
- );
- const actualType = requestedPresentation === "audio" && requestedType === "image" ? "number" : requestedType;
+ : "visual");
+ const actualType =
+ requestedPresentation === "audio" && requestedType === "image" ? "number" : requestedType;
const generator = this.generators.get(actualType);
if (!generator) throw new Error(`No CAPTCHA generator registered for ${actualType}`);
const now = this.now();
@@ -161,19 +196,25 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
const id = randomId(this.randomBytes, 24);
const answerSalt = randomId(this.randomBytes, 16);
const caseSensitive = options.caseSensitive ?? false;
- const normalizedAnswer = generated.answerKind === "selections"
- ? normalizeSelections(generated.answer.split(","))
- : generated.answerKind === "text"
- ? normalizeTextAnswer(generated.answer, caseSensitive)
- : generated.answer;
+ const normalizedAnswer =
+ generated.answerKind === "selections"
+ ? normalizeSelections(generated.answer.split(","))
+ : generated.answerKind === "text"
+ ? normalizeTextAnswer(generated.answer, caseSensitive)
+ : generated.answer;
const answerDigest = await hmacSha256(this.secret, `${id}:${answerSalt}:${normalizedAnswer}`);
- const expiresAt = now + assertPositiveInteger(options.expiresInMs ?? this.challengeTtlMs, "expiresInMs");
- const maxAttempts = assertPositiveInteger(options.maxAttempts ?? this.maxAttempts, "maxAttempts");
+ const expiresAt =
+ now + assertPositiveInteger(options.expiresInMs ?? this.challengeTtlMs, "expiresInMs");
+ const maxAttempts = assertPositiveInteger(
+ options.maxAttempts ?? this.maxAttempts,
+ "maxAttempts",
+ );
const audioKey = generated.audioSequence?.length ? randomId(this.randomBytes, 18) : undefined;
const responseField = options.responseField ?? this.responseField;
- const presentation = requestedPresentation === "audio" && generated.audioSequence?.length
- ? "audio"
- : generated.presentation;
+ const presentation =
+ requestedPresentation === "audio" && generated.audioSequence?.length
+ ? "audio"
+ : generated.presentation;
const publicChallenge: CaptchaChallenge = {
id,
@@ -190,7 +231,9 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
items: presentation === "audio" ? undefined : generated.items,
minSelections: generated.minSelections,
maxSelections: generated.maxSelections,
- audioUrl: audioKey ? `${this.basePath}/audio/${encodeURIComponent(id)}?key=${encodeURIComponent(audioKey)}` : undefined,
+ audioUrl: audioKey
+ ? `${this.basePath}/audio/${encodeURIComponent(id)}?key=${encodeURIComponent(audioKey)}`
+ : undefined,
refreshUrl: `${this.basePath}/challenge`,
verifyUrl: `${this.basePath}/verify`,
honeypotField: String(generated.metadata?.honeypotField ?? "") || undefined,
@@ -202,9 +245,13 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
requestedImageStyle: context.requestedImageStyle,
imageStylePool: [...context.imageStylePool],
locale: context.locale,
- ...(generated.answerKind === "invisible" ? { minCompletionMs: context.minCompletionMs } : {}),
+ ...(generated.answerKind === "invisible"
+ ? { minCompletionMs: context.minCompletionMs }
+ : {}),
...(generated.metadata?.interaction ? { interaction: generated.metadata.interaction } : {}),
- ...(requestedType === "image" && actualType !== requestedType ? { alternativeFor: requestedType } : {}),
+ ...(requestedType === "image" && actualType !== requestedType
+ ? { alternativeFor: requestedType }
+ : {}),
...options.metadata,
},
};
@@ -248,14 +295,23 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
if (!input.challengeId) return failure(action, "missing-input", "Missing CAPTCHA challenge id");
const now = this.now();
const record = await this.store.getChallenge(input.challengeId);
- if (!record) return failure(action, "invalid-input", "Unknown CAPTCHA challenge", input.challengeId);
- if (record.expiresAt <= now) return failure(action, "expired", "The CAPTCHA challenge expired", record.id);
- if (record.consumedAt) return failure(action, "already-used", "The CAPTCHA challenge was already used", record.id);
+ if (!record)
+ return failure(action, "invalid-input", "Unknown CAPTCHA challenge", input.challengeId);
+ if (record.expiresAt <= now)
+ return failure(action, "expired", "The CAPTCHA challenge expired", record.id);
+ if (record.consumedAt)
+ return failure(action, "already-used", "The CAPTCHA challenge was already used", record.id);
const bindingFailure = await this.checkChallengeBinding(record, input, action);
if (bindingFailure) return bindingFailure;
const attempted = await this.store.incrementAttempts(record.id, now);
- if (!attempted) return failure(action, "already-used", "The CAPTCHA challenge is no longer available", record.id);
+ if (!attempted)
+ return failure(
+ action,
+ "already-used",
+ "The CAPTCHA challenge is no longer available",
+ record.id,
+ );
if (attempted.attempts > attempted.maxAttempts) {
await this.store.consumeChallenge(attempted.id, now);
return failure(action, "attempts-exhausted", "Too many CAPTCHA attempts", record.id);
@@ -269,7 +325,10 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
}
const submitted = normalizedSubmittedAnswer(attempted, input);
- const digest = await hmacSha256(this.secret, `${attempted.id}:${attempted.answerSalt}:${submitted}`);
+ const digest = await hmacSha256(
+ this.secret,
+ `${attempted.id}:${attempted.answerSalt}:${submitted}`,
+ );
if (!constantTimeEqual(attempted.answerDigest, digest)) {
const exhausted = attempted.attempts >= attempted.maxAttempts;
if (exhausted) await this.store.consumeChallenge(attempted.id, now);
@@ -282,7 +341,13 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
}
const consumed = await this.store.consumeChallenge(attempted.id, now);
- if (!consumed) return failure(action, "already-used", "The CAPTCHA challenge was already used", attempted.id);
+ if (!consumed)
+ return failure(
+ action,
+ "already-used",
+ "The CAPTCHA challenge was already used",
+ attempted.id,
+ );
const plainToken = randomId(this.randomBytes, 32);
const tokenHash = await sha256(plainToken);
const expiresAt = now + this.responseTokenTtlMs;
@@ -319,13 +384,33 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
const tokenHash = await sha256(token);
const existing = await this.store.getToken(tokenHash);
if (!existing) return failure(action, "invalid-input", "Unknown CAPTCHA response token");
- if (existing.expiresAt <= now) return failure(action, "expired", "The CAPTCHA response token expired", existing.challengeId);
- if (existing.consumedAt) return failure(action, "already-used", "The CAPTCHA response token was already used", existing.challengeId);
- if (existing.action !== action) return failure(action, "action-mismatch", "The CAPTCHA action does not match", existing.challengeId);
+ if (existing.expiresAt <= now)
+ return failure(action, "expired", "The CAPTCHA response token expired", existing.challengeId);
+ if (existing.consumedAt)
+ return failure(
+ action,
+ "already-used",
+ "The CAPTCHA response token was already used",
+ existing.challengeId,
+ );
+ if (existing.action !== action)
+ return failure(
+ action,
+ "action-mismatch",
+ "The CAPTCHA action does not match",
+ existing.challengeId,
+ );
const bindingFailure = await this.checkTokenBinding(existing, input, action);
if (bindingFailure) return bindingFailure;
- const record = input.consume === false ? existing : await this.store.consumeToken(tokenHash, now);
- if (!record) return failure(action, "already-used", "The CAPTCHA response token was already used", existing.challengeId);
+ const record =
+ input.consume === false ? existing : await this.store.consumeToken(tokenHash, now);
+ if (!record)
+ return failure(
+ action,
+ "already-used",
+ "The CAPTCHA response token was already used",
+ existing.challengeId,
+ );
return {
success: true,
provider: "self-hosted",
@@ -338,13 +423,17 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
};
}
- async renderAudio(challengeId: string, key: string): Promise<{ bytes: Uint8Array; contentType: string } | undefined> {
+ async renderAudio(
+ challengeId: string,
+ key: string,
+ ): Promise<{ bytes: Uint8Array; contentType: string } | undefined> {
const record = await this.store.getChallenge(challengeId);
if (!record || record.expiresAt <= this.now() || record.consumedAt) return undefined;
const expectedKey = String(record.metadata.audioKey ?? "");
if (!expectedKey || !constantTimeEqual(expectedKey, key)) return undefined;
const sequence = record.metadata.audioSequence;
- if (!Array.isArray(sequence) || !sequence.every((value) => typeof value === "string")) return undefined;
+ if (!Array.isArray(sequence) || !sequence.every((value) => typeof value === "string"))
+ return undefined;
const bytes = await this.audioRenderer.render(sequence, String(record.metadata.locale ?? "en"));
return { bytes, contentType: this.audioRenderer.contentType ?? "audio/wav" };
}
@@ -358,7 +447,8 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
input: CaptchaBinding,
action: string,
): Promise {
- if (record.action !== action) return failure(action, "action-mismatch", "The CAPTCHA action does not match", record.id);
+ if (record.action !== action)
+ return failure(action, "action-mismatch", "The CAPTCHA action does not match", record.id);
if (!(await matchesHash(record.hostnameHash, input.hostname))) {
return failure(action, "hostname-mismatch", "The CAPTCHA hostname does not match", record.id);
}
@@ -366,7 +456,12 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
return failure(action, "session-mismatch", "The CAPTCHA session does not match", record.id);
}
if (!(await matchesHash(record.ipHash, input.ip))) {
- return failure(action, "ip-mismatch", "The CAPTCHA network binding does not match", record.id);
+ return failure(
+ action,
+ "ip-mismatch",
+ "The CAPTCHA network binding does not match",
+ record.id,
+ );
}
return undefined;
}
@@ -377,13 +472,28 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
action: string,
): Promise {
if (!(await matchesHash(record.hostnameHash, input.hostname))) {
- return failure(action, "hostname-mismatch", "The CAPTCHA hostname does not match", record.challengeId);
+ return failure(
+ action,
+ "hostname-mismatch",
+ "The CAPTCHA hostname does not match",
+ record.challengeId,
+ );
}
if (!(await matchesHash(record.sessionHash, input.sessionId))) {
- return failure(action, "session-mismatch", "The CAPTCHA session does not match", record.challengeId);
+ return failure(
+ action,
+ "session-mismatch",
+ "The CAPTCHA session does not match",
+ record.challengeId,
+ );
}
if (!(await matchesHash(record.ipHash, input.ip))) {
- return failure(action, "ip-mismatch", "The CAPTCHA network binding does not match", record.challengeId);
+ return failure(
+ action,
+ "ip-mismatch",
+ "The CAPTCHA network binding does not match",
+ record.challengeId,
+ );
}
return undefined;
}
diff --git a/packages/captcha/src/http.ts b/packages/captcha/src/http.ts
index e50a1cea..86781554 100644
--- a/packages/captcha/src/http.ts
+++ b/packages/captcha/src/http.ts
@@ -34,7 +34,8 @@ function json(body: unknown, status = 200, headers: HeadersInit = {}): Response
async function readPayload(request: Request): Promise> {
const contentType = request.headers.get("content-type") ?? "";
- if (contentType.includes("application/json")) return (await request.json()) as Record;
+ if (contentType.includes("application/json"))
+ return (await request.json()) as Record;
if (contentType.includes("form")) {
const form = await request.formData();
const payload: Record = {};
@@ -88,15 +89,22 @@ export function createCaptchaHttpHandlers(
counters.set(key, counter);
}
counter.count += 1;
- return { allowed: counter.count <= maximum, retryAfter: Math.max(1, Math.ceil((counter.resetAt - now) / 1000)) };
+ return {
+ allowed: counter.count <= maximum,
+ retryAfter: Math.max(1, Math.ceil((counter.resetAt - now) / 1000)),
+ };
};
const create = async (request: Request, ctx?: Context): Promise => {
if (!originAllowed(request)) return json({ success: false, code: "origin-rejected" }, 403);
- if (request.method !== "POST") return json({ success: false, code: "method-not-allowed" }, 405, { allow: "POST" });
+ if (request.method !== "POST")
+ return json({ success: false, code: "method-not-allowed" }, 405, { allow: "POST" });
const ip = clientIp(request, ctx, options.trustProxy ?? false);
const limit = withinLimit(`create:${ip}`, options.createLimit ?? 30);
- if (!limit.allowed) return json({ success: false, code: "rate-limited" }, 429, { "retry-after": String(limit.retryAfter) });
+ if (!limit.allowed)
+ return json({ success: false, code: "rate-limited" }, 429, {
+ "retry-after": String(limit.retryAfter),
+ });
try {
const payload = await readPayload(request);
const challenge = await engine.create({
@@ -118,10 +126,14 @@ export function createCaptchaHttpHandlers(
const verify = async (request: Request, ctx?: Context): Promise => {
if (!originAllowed(request)) return json({ success: false, code: "origin-rejected" }, 403);
- if (request.method !== "POST") return json({ success: false, code: "method-not-allowed" }, 405, { allow: "POST" });
+ if (request.method !== "POST")
+ return json({ success: false, code: "method-not-allowed" }, 405, { allow: "POST" });
const ip = clientIp(request, ctx, options.trustProxy ?? false);
const limit = withinLimit(`verify:${ip}`, options.verifyLimit ?? 60);
- if (!limit.allowed) return json({ success: false, code: "rate-limited" }, 429, { "retry-after": String(limit.retryAfter) });
+ if (!limit.allowed)
+ return json({ success: false, code: "rate-limited" }, 429, {
+ "retry-after": String(limit.retryAfter),
+ });
try {
const payload = await readPayload(request);
const result = await engine.verify({
@@ -143,7 +155,8 @@ export function createCaptchaHttpHandlers(
const audio = async (request: Request): Promise => {
if (!originAllowed(request)) return new Response("Forbidden", { status: 403 });
- if (request.method !== "GET" && request.method !== "HEAD") return new Response("Method Not Allowed", { status: 405 });
+ if (request.method !== "GET" && request.method !== "HEAD")
+ return new Response("Method Not Allowed", { status: 405 });
const url = new URL(request.url);
const prefix = `${engine.basePath}/audio/`;
const id = decodeURIComponent(url.pathname.slice(prefix.length));
diff --git a/packages/captcha/src/middleware.ts b/packages/captcha/src/middleware.ts
index 3064d897..153e8de2 100644
--- a/packages/captcha/src/middleware.ts
+++ b/packages/captcha/src/middleware.ts
@@ -76,7 +76,8 @@ export function captchaGuard(options: CaptchaGuardOptions) {
return async (ctx: Context, next: () => Promise | Response): Promise => {
const result = await verifyRequest(ctx, options);
ctx.locals.captcha = result;
- if (!result.success) return options.onFailure ? options.onFailure(ctx, result) : defaultFailure(options, result);
+ if (!result.success)
+ return options.onFailure ? options.onFailure(ctx, result) : defaultFailure(options, result);
return next();
};
}
@@ -93,7 +94,7 @@ export function captchaPageGate(options: CaptchaPageGateOptions) {
const grants = ctx.session.get(sessionKey) ?? [];
if (validCaptchaGrant(grants, action, now, routeGroup)) return next();
- const signals = await options.signals?.(ctx) ?? {};
+ const signals = (await options.signals?.(ctx)) ?? {};
const decision = shouldRequireCaptcha(action, policy, signals);
ctx.locals.captchaRisk = decision;
if (!decision.challenge) return next();
diff --git a/packages/captcha/src/normalize.ts b/packages/captcha/src/normalize.ts
index 00bfd879..041a9a0b 100644
--- a/packages/captcha/src/normalize.ts
+++ b/packages/captcha/src/normalize.ts
@@ -13,7 +13,10 @@ export function normalizeSelections(values: unknown): string {
return [...new Set(values.map((value) => String(value).trim()).filter(Boolean))].sort().join(",");
}
-export function normalizedSubmittedAnswer(record: CaptchaChallengeRecord, input: VerifyCaptchaInput): string {
+export function normalizedSubmittedAnswer(
+ record: CaptchaChallengeRecord,
+ input: VerifyCaptchaInput,
+): string {
if (record.answerKind === "selections") return normalizeSelections(input.selections);
if (record.answerKind === "invisible") {
return JSON.stringify({
diff --git a/packages/captcha/src/plugin.ts b/packages/captcha/src/plugin.ts
index 9255fb57..b16b6589 100644
--- a/packages/captcha/src/plugin.ts
+++ b/packages/captcha/src/plugin.ts
@@ -1,4 +1,3 @@
-import { copyFile, mkdir } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { definePlugin } from "@wrnexus/plugin";
@@ -22,17 +21,6 @@ export interface CaptchaAuditIssue {
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const captchaClientRuntime = join(packageRoot, "assets", "client", "captcha.js");
-async function copyCaptchaClientRuntime(destinationDir: string): Promise {
- await mkdir(destinationDir, { recursive: true });
- await copyFile(captchaClientRuntime, join(destinationDir, "captcha.js"));
-}
-
-async function installCaptchaClientRuntime(root: string, includeBuildOutput: boolean): Promise {
- await copyCaptchaClientRuntime(join(root, "public", "assets", "wrnexus"));
- if (includeBuildOutput) {
- await copyCaptchaClientRuntime(join(root, "dist", "public", "assets", "wrnexus"));
- }
-}
export function captchaComponentsDir(): string {
return join(packageRoot, "components");
}
@@ -40,24 +28,54 @@ export function captchaComponentsDir(): string {
function auditCaptchaSource(code: string, file: string, external: boolean): CaptchaAuditIssue[] {
if (!code.includes("
- issues.push({ id: `${id}:${file}`, severity, title, message, file });
+ const push = (
+ id: string,
+ severity: CaptchaAuditIssue["severity"],
+ title: string,
+ message: string,
+ ) => issues.push({ id: `${id}:${file}`, severity, title, message, file });
if (/secret(Key)?\s*=|providerSecret\s*=|captchaSecret\s*=/i.test(code)) {
- push("client-secret", "error", "CAPTCHA secret exposed", "Never pass a provider secret or verification secret to a .wrn component.");
+ push(
+ "client-secret",
+ "error",
+ "CAPTCHA secret exposed",
+ "Never pass a provider secret or verification secret to a .wrn component.",
+ );
}
if (!/action\s*=/.test(code)) {
- push("missing-action", "warning", "CAPTCHA action is missing", "Bind each challenge to a stable action such as signup, login, or contact-submit.");
+ push(
+ "missing-action",
+ "warning",
+ "CAPTCHA action is missing",
+ "Bind each challenge to a stable action such as signup, login, or contact-submit.",
+ );
}
if (/required\s*=\s*["']?false/i.test(code)) {
- push("optional-captcha", "warning", "CAPTCHA is optional", "Protected forms should require a CAPTCHA response and verify it on the server.");
+ push(
+ "optional-captcha",
+ "warning",
+ "CAPTCHA is optional",
+ "Protected forms should require a CAPTCHA response and verify it on the server.",
+ );
}
if (!/showAudio\s*=|presentation\s*=\s*["']audio/i.test(code)) {
- push("audio-alternative", "suggestion", "Confirm an accessible alternative", "Visual challenges should offer an audio or non-visual alternative.");
+ push(
+ "audio-alternative",
+ "suggestion",
+ "Confirm an accessible alternative",
+ "Visual challenges should offer an audio or non-visual alternative.",
+ );
}
- const imageStyle = code.match(/imageStyle\s*=\s*["']([^"']+)["']/i)?.[1]?.trim().toLowerCase();
- if (imageStyle && !CAPTCHA_IMAGE_STYLES.includes(imageStyle as (typeof CAPTCHA_IMAGE_STYLES)[number])) {
+ const imageStyle = code
+ .match(/imageStyle\s*=\s*["']([^"']+)["']/i)?.[1]
+ ?.trim()
+ .toLowerCase();
+ if (
+ imageStyle &&
+ !CAPTCHA_IMAGE_STYLES.includes(imageStyle as (typeof CAPTCHA_IMAGE_STYLES)[number])
+ ) {
push(
"unknown-image-style",
"error",
@@ -67,7 +85,11 @@ function auditCaptchaSource(code: string, file: string, external: boolean): Capt
}
const disturbance = Number(code.match(/disturbance\s*=\s*["']?(\d+)/i)?.[1] ?? "");
- if (Number.isFinite(disturbance) && disturbance >= 65 && /showAudio\s*=\s*["']?false/i.test(code)) {
+ if (
+ Number.isFinite(disturbance) &&
+ disturbance >= 65 &&
+ /showAudio\s*=\s*["']?false/i.test(code)
+ ) {
push(
"hard-without-audio",
"warning",
@@ -75,10 +97,24 @@ function auditCaptchaSource(code: string, file: string, external: boolean): Capt
"High disturbance should include audio or another non-visual challenge path.",
);
}
- if (external && /provider\s*=\s*["'](?:turnstile|recaptcha|hcaptcha)/i.test(code) && !/siteKey\s*=/.test(code)) {
- push("missing-site-key", "error", "Provider site key is missing", "External CAPTCHA providers require a public site key in the browser.");
+ if (
+ external &&
+ /provider\s*=\s*["'](?:turnstile|recaptcha|hcaptcha)/i.test(code) &&
+ !/siteKey\s*=/.test(code)
+ ) {
+ push(
+ "missing-site-key",
+ "error",
+ "Provider site key is missing",
+ "External CAPTCHA providers require a public site key in the browser.",
+ );
}
- push("server-verification", "suggestion", "Server verification required", "Confirm the receiving API uses captchaGuard(), parseWithCaptcha(), or provider.verify().");
+ push(
+ "server-verification",
+ "suggestion",
+ "Server verification required",
+ "Confirm the receiving API uses captchaGuard(), parseWithCaptcha(), or provider.verify().",
+ );
return issues;
}
@@ -86,37 +122,62 @@ export function captchaPlugin(options: CaptchaPluginOptions = {}) {
const metadataKey = "@wrnexus/captcha:audit";
return definePlugin({
name: "@wrnexus/captcha",
- version: "0.3.6",
+ version: "0.4.0",
enforce: "post",
- async configure(config, context) {
+ componentDirs:
+ options.exposeComponentDirectory === false
+ ? []
+ : [options.componentDir ?? captchaComponentsDir()],
+ clientRuntimes: [
+ {
+ id: "captcha",
+ entry: captchaClientRuntime,
+ type: "script",
+ load: "defer",
+ singleton: true,
+ bundle: false,
+ },
+ ],
+ styleSources: [
+ {
+ id: "captcha-components",
+ source: options.componentDir ?? captchaComponentsDir(),
+ order: "normal",
+ },
+ ],
+ configure(config, context) {
const current = (config.captcha ?? {}) as Record;
config.captcha = {
componentDir: options.componentDir ?? captchaComponentsDir(),
...current,
};
- context.metadata.set("@wrnexus/captcha:component-dir", options.componentDir ?? captchaComponentsDir());
- await installCaptchaClientRuntime(context.root, context.command === "build");
+ context.metadata.set(
+ "@wrnexus/captcha:component-dir",
+ options.componentDir ?? captchaComponentsDir(),
+ );
},
transformCode(code, context) {
if (context.mode !== "development") return;
const previous = (context.metadata.get(metadataKey) as CaptchaAuditIssue[] | undefined) ?? [];
const withoutFile = previous.filter((issue) => issue.file !== context.file);
- context.metadata.set(
- metadataKey,
- [...withoutFile, ...auditCaptchaSource(code, context.file, options.auditExternalProviders ?? true)],
- );
+ context.metadata.set(metadataKey, [
+ ...withoutFile,
+ ...auditCaptchaSource(code, context.file, options.auditExternalProviders ?? true),
+ ]);
},
devToolbarPanels(context) {
if (options.enableDevToolbar === false) return [];
const issues = (context.metadata.get(metadataKey) as CaptchaAuditIssue[] | undefined) ?? [];
- return [{
- id: "wrnexus-captcha",
- title: "CAPTCHA",
- icon: "shield-check",
- badge: issues.length,
- description: "CAPTCHA security, accessibility, and integration checks",
- issues,
- }];
+ return [
+ {
+ id: "wrnexus-captcha",
+ title: "CAPTCHA",
+ icon: "shield-check",
+ badge: issues.length,
+ description: "CAPTCHA security, accessibility, and integration checks",
+ issues,
+ },
+ ];
},
});
}
diff --git a/packages/captcha/src/policy.ts b/packages/captcha/src/policy.ts
index 1f02efa5..66d98d4b 100644
--- a/packages/captcha/src/policy.ts
+++ b/packages/captcha/src/policy.ts
@@ -1,8 +1,4 @@
-import type {
- CaptchaPolicyOptions,
- CaptchaRiskResult,
- CaptchaRiskSignals,
-} from "./types.ts";
+import type { CaptchaPolicyOptions, CaptchaRiskResult, CaptchaRiskSignals } from "./types.ts";
export function evaluateCaptchaRisk(
signals: CaptchaRiskSignals,
@@ -14,8 +10,10 @@ export function evaluateCaptchaRisk(
score = Math.min(100, score + points);
reasons.push(reason);
};
- if ((signals.failedAttempts ?? 0) > 0) add(Math.min(35, (signals.failedAttempts ?? 0) * 10), "failed-attempts");
- if ((signals.requestsInWindow ?? 0) > 20) add(Math.min(35, ((signals.requestsInWindow ?? 0) - 20) * 2), "request-rate");
+ if ((signals.failedAttempts ?? 0) > 0)
+ add(Math.min(35, (signals.failedAttempts ?? 0) * 10), "failed-attempts");
+ if ((signals.requestsInWindow ?? 0) > 20)
+ add(Math.min(35, ((signals.requestsInWindow ?? 0) - 20) * 2), "request-rate");
if (signals.completionMs !== undefined && signals.completionMs < 700) add(25, "too-fast");
if (signals.missingBrowserSignals) add(20, "missing-browser-signals");
if (signals.suspiciousHeaders) add(20, "suspicious-headers");
diff --git a/packages/captcha/src/providers/custom.ts b/packages/captcha/src/providers/custom.ts
index ae70df6e..835dee8e 100644
--- a/packages/captcha/src/providers/custom.ts
+++ b/packages/captcha/src/providers/custom.ts
@@ -2,7 +2,9 @@ import type { CaptchaProvider } from "../types.ts";
export function defineCaptchaProvider(provider: T): T {
if (!provider.name) throw new TypeError("Custom CAPTCHA provider requires a stable name");
- if (!provider.client?.responseField) throw new TypeError("Custom CAPTCHA provider requires client.responseField");
- if (typeof provider.verify !== "function") throw new TypeError("Custom CAPTCHA provider requires verify()");
+ if (!provider.client?.responseField)
+ throw new TypeError("Custom CAPTCHA provider requires client.responseField");
+ if (typeof provider.verify !== "function")
+ throw new TypeError("Custom CAPTCHA provider requires verify()");
return provider;
}
diff --git a/packages/captcha/src/providers/managed.ts b/packages/captcha/src/providers/managed.ts
index d025d172..9f994354 100644
--- a/packages/captcha/src/providers/managed.ts
+++ b/packages/captcha/src/providers/managed.ts
@@ -32,10 +32,14 @@ export class ManagedCaptchaProvider implements CaptchaProvider {
}
async createChallenge(options: CreateCaptchaOptions): Promise {
- return this.request("/v1/challenges", {
- siteKey: this.options.siteKey,
- ...options,
- }, false);
+ return this.request(
+ "/v1/challenges",
+ {
+ siteKey: this.options.siteKey,
+ ...options,
+ },
+ false,
+ );
}
async verify(input: VerifyCaptchaInput): Promise {
@@ -64,6 +68,8 @@ export class ManagedCaptchaProvider implements CaptchaProvider {
}
}
-export function managedCaptchaProvider(options: ManagedCaptchaProviderOptions): ManagedCaptchaProvider {
+export function managedCaptchaProvider(
+ options: ManagedCaptchaProviderOptions,
+): ManagedCaptchaProvider {
return new ManagedCaptchaProvider(options);
}
diff --git a/packages/captcha/src/providers/self-hosted.ts b/packages/captcha/src/providers/self-hosted.ts
index 51911722..d77efaf5 100644
--- a/packages/captcha/src/providers/self-hosted.ts
+++ b/packages/captcha/src/providers/self-hosted.ts
@@ -22,7 +22,10 @@ export class SelfHostedCaptchaProvider implements CaptchaProvider {
verify(input: VerifyCaptchaInput) {
return input.responseToken || input.providerToken
- ? this.engine.verifyResponseToken({ ...input, responseToken: input.responseToken ?? input.providerToken })
+ ? this.engine.verifyResponseToken({
+ ...input,
+ responseToken: input.responseToken ?? input.providerToken,
+ })
: this.engine.verify(input);
}
}
diff --git a/packages/captcha/src/providers/siteverify.ts b/packages/captcha/src/providers/siteverify.ts
index 993d013b..18181011 100644
--- a/packages/captcha/src/providers/siteverify.ts
+++ b/packages/captcha/src/providers/siteverify.ts
@@ -61,8 +61,10 @@ export class SiteverifyCaptchaProvider implements CaptchaProvider {
async verify(input: VerifyCaptchaInput): Promise {
const token = input.providerToken ?? input.responseToken;
const action = input.action;
- if (!token) return providerFailure(this.name, action, "missing-input", "Missing provider response token");
- if (token.length > 4096) return providerFailure(this.name, action, "invalid-input", "Provider token is too long");
+ if (!token)
+ return providerFailure(this.name, action, "missing-input", "Missing provider response token");
+ if (token.length > 4096)
+ return providerFailure(this.name, action, "invalid-input", "Provider token is too long");
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.options.timeoutMs ?? 10_000);
const body = new URLSearchParams({ secret: this.options.secretKey, response: token });
@@ -79,7 +81,12 @@ export class SiteverifyCaptchaProvider implements CaptchaProvider {
signal: controller.signal,
});
if (!response.ok) {
- return providerFailure(this.name, action, "provider-error", `${this.name} verification returned HTTP ${response.status}`);
+ return providerFailure(
+ this.name,
+ action,
+ "provider-error",
+ `${this.name} verification returned HTTP ${response.status}`,
+ );
}
const data = (await response.json()) as SiteverifyPayload;
if (!data.success) {
@@ -98,26 +105,45 @@ export class SiteverifyCaptchaProvider implements CaptchaProvider {
}
const expectedAction = this.options.expectedAction ?? action;
if (data.action && expectedAction && data.action !== expectedAction) {
- return providerFailure(this.name, action, "action-mismatch", "Provider action does not match", {
- receivedAction: data.action,
- });
+ return providerFailure(
+ this.name,
+ action,
+ "action-mismatch",
+ "Provider action does not match",
+ {
+ receivedAction: data.action,
+ },
+ );
}
if (
this.options.expectedHostnames?.length &&
(!data.hostname || !this.options.expectedHostnames.includes(data.hostname))
) {
- return providerFailure(this.name, action, "hostname-mismatch", "Provider hostname does not match", {
- hostname: data.hostname,
- });
+ return providerFailure(
+ this.name,
+ action,
+ "hostname-mismatch",
+ "Provider hostname does not match",
+ {
+ hostname: data.hostname,
+ },
+ );
}
if (this.options.minScore !== undefined && typeof data.score === "number") {
- const rejected = this.preset.scoreDirection === "higher-is-risk"
- ? data.score >= this.options.minScore
- : data.score < this.options.minScore;
+ const rejected =
+ this.preset.scoreDirection === "higher-is-risk"
+ ? data.score >= this.options.minScore
+ : data.score < this.options.minScore;
if (rejected) {
- return providerFailure(this.name, action, "risk-rejected", "Provider risk score did not pass", {
- score: data.score,
- });
+ return providerFailure(
+ this.name,
+ action,
+ "risk-rejected",
+ "Provider risk score did not pass",
+ {
+ score: data.score,
+ },
+ );
}
}
return {
@@ -135,7 +161,9 @@ export class SiteverifyCaptchaProvider implements CaptchaProvider {
return providerFailure(
this.name,
action,
- error instanceof DOMException && error.name === "AbortError" ? "network-error" : "provider-error",
+ error instanceof DOMException && error.name === "AbortError"
+ ? "network-error"
+ : "provider-error",
error instanceof DOMException && error.name === "AbortError"
? `${this.name} verification timed out`
: `${this.name} verification failed`,
diff --git a/packages/captcha/src/stores/memory.ts b/packages/captcha/src/stores/memory.ts
index b2207f26..366a7bad 100644
--- a/packages/captcha/src/stores/memory.ts
+++ b/packages/captcha/src/stores/memory.ts
@@ -1,8 +1,4 @@
-import type {
- CaptchaChallengeRecord,
- CaptchaResponseTokenRecord,
- CaptchaStore,
-} from "../types.ts";
+import type { CaptchaChallengeRecord, CaptchaResponseTokenRecord, CaptchaStore } from "../types.ts";
function cloneChallenge(record: CaptchaChallengeRecord): CaptchaChallengeRecord {
return structuredClone(record);
@@ -72,7 +68,10 @@ export class MemoryCaptchaStore implements CaptchaStore {
return record ? cloneToken(record) : undefined;
}
- async consumeToken(tokenHash: string, now: number): Promise {
+ async consumeToken(
+ tokenHash: string,
+ now: number,
+ ): Promise {
const record = this.tokens.get(tokenHash);
if (!record || record.expiresAt <= now || record.consumedAt) return undefined;
record.consumedAt = now;
@@ -96,7 +95,11 @@ export class MemoryCaptchaStore implements CaptchaStore {
}
}
- private evict(map: Map, max: number, now: number): void {
+ private evict(
+ map: Map,
+ max: number,
+ now: number,
+ ): void {
for (const [key, record] of map) if (record.expiresAt <= now) map.delete(key);
while (map.size >= max) map.delete(map.keys().next().value!);
}
diff --git a/packages/captcha/src/stores/redis.ts b/packages/captcha/src/stores/redis.ts
index 2ebd6092..10bd49a7 100644
--- a/packages/captcha/src/stores/redis.ts
+++ b/packages/captcha/src/stores/redis.ts
@@ -1,8 +1,4 @@
-import type {
- CaptchaChallengeRecord,
- CaptchaResponseTokenRecord,
- CaptchaStore,
-} from "../types.ts";
+import type { CaptchaChallengeRecord, CaptchaResponseTokenRecord, CaptchaStore } from "../types.ts";
export interface RedisCaptchaClient {
get(key: string): Promise | string | null;
@@ -94,7 +90,10 @@ export class RedisCaptchaStore implements CaptchaStore {
return this.read(this.tokenKey(tokenHash));
}
- async consumeToken(tokenHash: string, now: number): Promise {
+ async consumeToken(
+ tokenHash: string,
+ now: number,
+ ): Promise {
const key = this.tokenKey(tokenHash);
if (this.redis.eval) {
const raw = await this.redis.eval(CONSUME_TOKEN, {
@@ -107,7 +106,9 @@ export class RedisCaptchaStore implements CaptchaStore {
const current = await this.read(key);
if (!current || current.expiresAt <= now || current.consumedAt) return undefined;
current.consumedAt = now;
- await this.redis.set(key, JSON.stringify(current), { px: Math.max(1, current.expiresAt - now) });
+ await this.redis.set(key, JSON.stringify(current), {
+ px: Math.max(1, current.expiresAt - now),
+ });
return current;
});
}
@@ -138,7 +139,9 @@ export class RedisCaptchaStore implements CaptchaStore {
if (!current || current.expiresAt <= now || current.consumedAt) return undefined;
if (operation === "attempt") current.attempts += 1;
else current.consumedAt = now;
- await this.redis.set(key, JSON.stringify(current), { px: Math.max(1, current.expiresAt - now) });
+ await this.redis.set(key, JSON.stringify(current), {
+ px: Math.max(1, current.expiresAt - now),
+ });
return current;
});
}
diff --git a/packages/captcha/src/stores/sqlite.ts b/packages/captcha/src/stores/sqlite.ts
index 5920a0d0..e71d0f9c 100644
--- a/packages/captcha/src/stores/sqlite.ts
+++ b/packages/captcha/src/stores/sqlite.ts
@@ -1,8 +1,4 @@
-import type {
- CaptchaChallengeRecord,
- CaptchaResponseTokenRecord,
- CaptchaStore,
-} from "../types.ts";
+import type { CaptchaChallengeRecord, CaptchaResponseTokenRecord, CaptchaStore } from "../types.ts";
export interface SqliteStatementLike {
run(...params: unknown[]): unknown;
@@ -21,16 +17,21 @@ export interface SqliteCaptchaStoreOptions {
}
function safeIdentifier(value: string): string {
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) throw new TypeError(`Unsafe SQL identifier: ${value}`);
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value))
+ throw new TypeError(`Unsafe SQL identifier: ${value}`);
return value;
}
-function parseChallenge(row: Record | undefined): CaptchaChallengeRecord | undefined {
+function parseChallenge(
+ row: Record | undefined,
+): CaptchaChallengeRecord | undefined {
if (!row) return undefined;
return JSON.parse(String(row.payload)) as CaptchaChallengeRecord;
}
-function parseToken(row: Record | undefined): CaptchaResponseTokenRecord | undefined {
+function parseToken(
+ row: Record | undefined,
+): CaptchaResponseTokenRecord | undefined {
if (!row) return undefined;
return JSON.parse(String(row.payload)) as CaptchaResponseTokenRecord;
}
@@ -77,7 +78,13 @@ export class SqliteCaptchaStore implements CaptchaStore {
(id, payload, expires_at, consumed_at, attempts)
VALUES (?, ?, ?, ?, ?)`,
)
- .run(record.id, JSON.stringify(record), record.expiresAt, record.consumedAt ?? null, record.attempts);
+ .run(
+ record.id,
+ JSON.stringify(record),
+ record.expiresAt,
+ record.consumedAt ?? null,
+ record.attempts,
+ );
}
async getChallenge(id: string): Promise {
@@ -96,7 +103,9 @@ export class SqliteCaptchaStore implements CaptchaStore {
SET payload = ?, attempts = ?
WHERE id = ? AND expires_at > ? AND consumed_at IS NULL AND attempts = ?`,
)
- .run(JSON.stringify(current), current.attempts, id, now, current.attempts - 1) as { changes?: number };
+ .run(JSON.stringify(current), current.attempts, id, now, current.attempts - 1) as {
+ changes?: number;
+ };
return result?.changes === 0 ? undefined : current;
}
@@ -134,7 +143,10 @@ export class SqliteCaptchaStore implements CaptchaStore {
);
}
- async consumeToken(tokenHash: string, now: number): Promise {
+ async consumeToken(
+ tokenHash: string,
+ now: number,
+ ): Promise {
const current = await this.getToken(tokenHash);
if (!current || current.expiresAt <= now || current.consumedAt) return undefined;
current.consumedAt = now;
diff --git a/packages/captcha/src/types.ts b/packages/captcha/src/types.ts
index 16a7e5fa..edec7f28 100644
--- a/packages/captcha/src/types.ts
+++ b/packages/captcha/src/types.ts
@@ -1,12 +1,7 @@
import type { Context, Middleware } from "@wrnexus/core";
export type CaptchaProviderName =
- | "self-hosted"
- | "wrnexus-managed"
- | "turnstile"
- | "recaptcha"
- | "hcaptcha"
- | (string & {});
+ "self-hosted" | "wrnexus-managed" | "turnstile" | "recaptcha" | "hcaptcha" | (string & {});
export type CaptchaChallengeType =
| "number"
@@ -231,7 +226,9 @@ export interface CaptchaGeneratorContext {
export interface CaptchaChallengeGenerator {
readonly type: CaptchaChallengeType;
- generate(context: CaptchaGeneratorContext): GeneratedCaptchaChallenge | Promise;
+ generate(
+ context: CaptchaGeneratorContext,
+ ): GeneratedCaptchaChallenge | Promise;
}
export interface CaptchaAudioRenderer {
@@ -263,7 +260,10 @@ export interface CaptchaEngine {
create(options: CreateCaptchaOptions): Promise;
verify(input: VerifyCaptchaInput): Promise;
verifyResponseToken(input: VerifyCaptchaInput): Promise;
- renderAudio(challengeId: string, key: string): Promise<{ bytes: Uint8Array; contentType: string } | undefined>;
+ renderAudio(
+ challengeId: string,
+ key: string,
+ ): Promise<{ bytes: Uint8Array; contentType: string } | undefined>;
gc(): Promise;
}
diff --git a/packages/captcha/src/validation.ts b/packages/captcha/src/validation.ts
index d65730a5..bf9a9623 100644
--- a/packages/captcha/src/validation.ts
+++ b/packages/captcha/src/validation.ts
@@ -1,10 +1,6 @@
import type { Context } from "@wrnexus/core";
import type { ObjectSchema, ParseResult } from "@wrnexus/validation";
-import type {
- CaptchaEngine,
- CaptchaProvider,
- CaptchaVerificationResult,
-} from "./types.ts";
+import type { CaptchaEngine, CaptchaProvider, CaptchaVerificationResult } from "./types.ts";
import { selfHostedProvider } from "./providers/self-hosted.ts";
export interface ParseWithCaptchaOptions {
@@ -28,7 +24,8 @@ export async function parseWithCaptcha>(
options: ParseWithCaptchaOptions,
): Promise> {
const parsed = schema.parse(input) as ParseResult;
- const provider = options.provider ?? (options.engine ? selfHostedProvider(options.engine) : undefined);
+ const provider =
+ options.provider ?? (options.engine ? selfHostedProvider(options.engine) : undefined);
if (!provider) throw new TypeError("parseWithCaptcha requires provider or engine");
const field = options.responseField ?? provider.client.responseField;
const token = input[field] ?? input.captchaToken ?? input.responseToken;
diff --git a/packages/captcha/test/audio.test.ts b/packages/captcha/test/audio.test.ts
index 473e89b5..a8432b39 100644
--- a/packages/captcha/test/audio.test.ts
+++ b/packages/captcha/test/audio.test.ts
@@ -1,9 +1,6 @@
import { describe, expect, test } from "bun:test";
import { join } from "node:path";
-import {
- createAssetAudioRenderer,
- resolveCaptchaAudioAssetsDir,
-} from "../src/audio/renderer.ts";
+import { createAssetAudioRenderer, resolveCaptchaAudioAssetsDir } from "../src/audio/renderer.ts";
const packageRoot = join(import.meta.dir, "..");
const expectedAssets = join(packageRoot, "assets", "audio");
diff --git a/packages/captcha/test/client-runtime.test.ts b/packages/captcha/test/client-runtime.test.ts
index 0f49c2d5..eca76929 100644
--- a/packages/captcha/test/client-runtime.test.ts
+++ b/packages/captcha/test/client-runtime.test.ts
@@ -24,14 +24,15 @@ test("Captcha browser runtime is valid JavaScript and exposes the expected lifec
expect(source).toContain("stopImmediatePropagation");
expect(source).toContain('addEventListener("submit"');
expect(source).toContain("MutationObserver");
- expect(source).toContain('new CustomEvent(name');
+ expect(source).toContain("new CustomEvent(name");
});
test("Captcha component delegates native browser work to the packaged runtime", async () => {
const source = await readFile(join(packageRoot, "components/Captcha.wrn"), "utf8");
- expect(source).toContain('src="/assets/wrnexus/captcha.js"');
- expect(source).not.toContain('src="/__wrnexus/captcha.js"');
+ expect(source).toContain('data-wrnexus-runtime="captcha"');
+ expect(source).not.toContain("
+ }
+ }`,
+ );
+ writeFileSync(
+ join(root, "public", "assets", "wrnexus", "captcha.js"),
+ "window.legacyCaptcha = true;\n",
+ );
+
+ try {
+ updateApp(root, "0.4.0", false);
+ const page = readFileSync(join(root, "app", "pages", "index.wrn"), "utf8");
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
+ expect(page).toContain(" diagnostic.severity === "error");
+ if (errors.length > 0) {
+ throw new ParseError(
+ errors.map((diagnostic) => diagnostic.message).join("\n"),
+ errors[0]!.code,
+ );
+ }
+
+ const ast = parse(source);
+ return {
+ code: `// compiled from .wrn\n${generate(ast)}`,
+ ast,
+ diagnostics: richDiagnostics.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`),
+ richDiagnostics,
+ };
+}
+
+export interface CompilationCacheEntry extends CompileResult {
+ key: string;
+ file: string;
+ sourceHash: string;
+ createdAt: number;
+}
+
+export interface CompilationCacheOptions {
+ maxEntries?: number;
+ now?: () => number;
+}
+
+export interface CompilationCache {
+ compile(source: string, file?: string, salt?: string): CompilationCacheEntry;
+ get(key: string): CompilationCacheEntry | undefined;
+ invalidate(file?: string): number;
+ clear(): void;
+ size(): number;
+ stats(): { hits: number; misses: number; entries: number };
+}
+
+export function compilationKey(source: string, file = "", salt = ""): string {
+ return createHash("sha256")
+ .update(file)
+ .update("\0")
+ .update(salt)
+ .update("\0")
+ .update(source)
+ .digest("hex");
+}
+
+export function createCompilationCache(options: CompilationCacheOptions = {}): CompilationCache {
+ const maxEntries = options.maxEntries ?? 500;
+ if (!Number.isInteger(maxEntries) || maxEntries < 1)
+ throw new RangeError("maxEntries must be positive");
+ const now = options.now ?? Date.now;
+ const entries = new Map();
+ let hits = 0;
+ let misses = 0;
+
+ function touch(key: string, value: CompilationCacheEntry): void {
+ entries.delete(key);
+ entries.set(key, value);
+ while (entries.size > maxEntries) entries.delete(entries.keys().next().value!);
+ }
+
+ return {
+ compile(source, file = "", salt = "") {
+ const key = compilationKey(source, file, salt);
+ const existing = entries.get(key);
+ if (existing) {
+ hits++;
+ touch(key, existing);
+ return existing;
+ }
+ misses++;
+ const result = compileSource(source, file);
+ const entry: CompilationCacheEntry = {
+ ...result,
+ key,
+ file,
+ sourceHash: createHash("sha256").update(source).digest("hex"),
+ createdAt: now(),
+ };
+ touch(key, entry);
+ return entry;
+ },
+ get(key) {
+ const entry = entries.get(key);
+ if (entry) touch(key, entry);
+ return entry;
+ },
+ invalidate(file) {
+ let removed = 0;
+ for (const [key, entry] of entries) {
+ if (!file || entry.file === file) {
+ entries.delete(key);
+ removed++;
+ }
+ }
+ return removed;
+ },
+ clear() {
+ entries.clear();
+ },
+ size: () => entries.size,
+ stats: () => ({ hits, misses, entries: entries.size }),
+ };
+}
+
+export class DependencyGraph {
+ readonly #dependencies = new Map>();
+ readonly #dependents = new Map>();
+
+ set(file: string, dependencies: Iterable): void {
+ this.remove(file);
+ const values = new Set(dependencies);
+ this.#dependencies.set(file, values);
+ for (const dependency of values) {
+ const set = this.#dependents.get(dependency) ?? new Set();
+ set.add(file);
+ this.#dependents.set(dependency, set);
+ }
+ }
+
+ remove(file: string): void {
+ for (const dependency of this.#dependencies.get(file) ?? []) {
+ const set = this.#dependents.get(dependency);
+ set?.delete(file);
+ if (set?.size === 0) this.#dependents.delete(dependency);
+ }
+ this.#dependencies.delete(file);
+ }
+
+ dependencies(file: string): string[] {
+ return [...(this.#dependencies.get(file) ?? [])].sort();
+ }
+ dependents(file: string): string[] {
+ return [...(this.#dependents.get(file) ?? [])].sort();
+ }
+
+ affected(file: string): string[] {
+ const found = new Set();
+ const queue = [file];
+ while (queue.length) {
+ const current = queue.shift()!;
+ for (const dependent of this.#dependents.get(current) ?? []) {
+ if (found.has(dependent)) continue;
+ found.add(dependent);
+ queue.push(dependent);
+ }
+ }
+ return [...found].sort();
+ }
+}
diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts
index 6bda3f1e..6bf1800b 100644
--- a/packages/compiler/src/index.ts
+++ b/packages/compiler/src/index.ts
@@ -101,3 +101,5 @@ export function compile(source: string, filePath = ""): CompileResu
richDiagnostics,
};
}
+export { compilationKey, createCompilationCache, DependencyGraph } from "./cache.ts";
+export type { CompilationCache, CompilationCacheEntry, CompilationCacheOptions } from "./cache.ts";
diff --git a/packages/compiler/test/compiler.test.ts b/packages/compiler/test/compiler.test.ts
index ffdaebde..550580b8 100644
--- a/packages/compiler/test/compiler.test.ts
+++ b/packages/compiler/test/compiler.test.ts
@@ -1199,7 +1199,7 @@ test("server-rendered each locals work in reactive handlers", async () => {
expect(articles[0]!.querySelector("span")?.classList.contains("open")).toBe(false);
expect(articles[1]!.querySelector("span")?.classList.contains("open")).toBe(true);
-});
+}, 20_000);
test("WRN 0.3 metadata, computed values, loaders, and actions compile additively", () => {
const source = `page Dashboard {
diff --git a/packages/core/package.json b/packages/core/package.json
index 37b776f1..da6bd317 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/core",
- "version": "0.3.6",
+ "version": "0.4.0",
"type": "module",
"main": "src/index.ts",
"exports": {
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 515eaf8a..be8517d3 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -134,3 +134,24 @@ export type { FeatureFlags, FeatureRule, FeatureValue } from "./features.ts";
export { checkPerformanceBudgets } from "./performance.ts";
export type { BudgetViolation, PerformanceBudgets, PerformanceMeasurement } from "./performance.ts";
+export {
+ problem,
+ serviceToken,
+ ServiceContainer,
+ ApplicationLifecycle,
+ HealthRegistry,
+ requestId,
+ memoryIdempotencyStore,
+ withIdempotency,
+} from "./platform.ts";
+export type {
+ ProblemDetails,
+ ProblemDetailsInput,
+ ServiceToken,
+ LifecyclePhase,
+ LifecycleHandler,
+ HealthCheck,
+ HealthCheckResult,
+ IdempotencyRecord,
+ IdempotencyStore,
+} from "./platform.ts";
diff --git a/packages/core/src/platform.ts b/packages/core/src/platform.ts
new file mode 100644
index 00000000..8be5cc71
--- /dev/null
+++ b/packages/core/src/platform.ts
@@ -0,0 +1,195 @@
+import { randomUUID } from "node:crypto";
+
+export interface ProblemDetails {
+ type: string;
+ title: string;
+ status: number;
+ detail?: string;
+ instance?: string;
+ code?: string;
+ [key: string]: unknown;
+}
+
+export interface ProblemDetailsInput {
+ type?: string;
+ title: string;
+ status: number;
+ detail?: string;
+ instance?: string;
+ code?: string;
+ [key: string]: unknown;
+}
+
+export function problem(details: ProblemDetailsInput, headers?: HeadersInit): Response {
+ const body: ProblemDetails = {
+ ...details,
+ type: details.type ?? "about:blank",
+ title: details.title,
+ status: details.status,
+ };
+ return Response.json(body, {
+ status: body.status,
+ headers: {
+ "content-type": "application/problem+json; charset=utf-8",
+ ...Object.fromEntries(new Headers(headers)),
+ },
+ });
+}
+
+export type ServiceToken = string | symbol | { readonly key: symbol; readonly __type?: T };
+export function serviceToken(description: string): ServiceToken {
+ return { key: Symbol(description) };
+}
+function tokenKey(token: ServiceToken): string | symbol {
+ return typeof token === "object" ? token.key : token;
+}
+
+export class ServiceContainer {
+ readonly #values = new Map();
+ constructor(private readonly parent?: ServiceContainer) {}
+ set(token: ServiceToken, value: T): this {
+ this.#values.set(tokenKey(token), value);
+ return this;
+ }
+ has(token: ServiceToken): boolean {
+ return this.#values.has(tokenKey(token)) || !!this.parent?.has(token);
+ }
+ get(token: ServiceToken): T {
+ const key = tokenKey(token);
+ if (this.#values.has(key)) return this.#values.get(key) as T;
+ if (this.parent) return this.parent.get(token);
+ throw new Error(`WRN-SERVICE-NOT-FOUND: ${typeof key === "symbol" ? key.description : key}`);
+ }
+ tryGet(token: ServiceToken): T | undefined {
+ try {
+ return this.get(token);
+ } catch {
+ return undefined;
+ }
+ }
+ scope(): ServiceContainer {
+ return new ServiceContainer(this);
+ }
+}
+
+export type LifecyclePhase = "starting" | "started" | "stopping" | "stopped";
+export type LifecycleHandler = (signal: AbortSignal) => void | Promise;
+export class ApplicationLifecycle {
+ readonly #handlers = new Map();
+ #controller = new AbortController();
+ on(phase: LifecyclePhase, handler: LifecycleHandler): () => void {
+ const handlers = this.#handlers.get(phase) ?? [];
+ handlers.push(handler);
+ this.#handlers.set(phase, handlers);
+ return () => {
+ const index = handlers.indexOf(handler);
+ if (index >= 0) handlers.splice(index, 1);
+ };
+ }
+ async run(phase: LifecyclePhase): Promise {
+ if (phase === "stopping") this.#controller.abort("application stopping");
+ const handlers = this.#handlers.get(phase) ?? [];
+ const sequence =
+ phase === "stopping" || phase === "stopped" ? [...handlers].reverse() : handlers;
+ for (const handler of sequence) await handler(this.#controller.signal);
+ }
+ get signal(): AbortSignal {
+ return this.#controller.signal;
+ }
+}
+
+export interface HealthCheckResult {
+ status: "up" | "down" | "degraded";
+ message?: string;
+ details?: unknown;
+ durationMs?: number;
+}
+export type HealthCheck = () => HealthCheckResult | Promise;
+export class HealthRegistry {
+ readonly #checks = new Map();
+ register(name: string, check: HealthCheck): () => void {
+ this.#checks.set(name, check);
+ return () => this.#checks.delete(name);
+ }
+ async check(): Promise<{
+ status: "up" | "down" | "degraded";
+ checks: Record;
+ }> {
+ const checks: Record = {};
+ for (const [name, check] of this.#checks) {
+ const start = performance.now();
+ try {
+ checks[name] = {
+ ...(await check()),
+ durationMs: Math.round((performance.now() - start) * 100) / 100,
+ };
+ } catch (error) {
+ checks[name] = {
+ status: "down",
+ message: error instanceof Error ? error.message : String(error),
+ durationMs: Math.round((performance.now() - start) * 100) / 100,
+ };
+ }
+ }
+ const values = Object.values(checks);
+ const status = values.some((item) => item.status === "down")
+ ? "down"
+ : values.some((item) => item.status === "degraded")
+ ? "degraded"
+ : "up";
+ return { status, checks };
+ }
+}
+
+export function requestId(headers: Headers, preferred?: string): string {
+ const existing =
+ preferred ?? headers.get("x-request-id") ?? headers.get("traceparent")?.split("-")[1];
+ return existing && /^[A-Za-z0-9._:-]{8,128}$/.test(existing) ? existing : randomUUID();
+}
+
+export interface IdempotencyRecord {
+ key: string;
+ value: T;
+ expiresAt: number;
+}
+export interface IdempotencyStore {
+ get(key: string): Promise | null>;
+ set(record: IdempotencyRecord): Promise;
+ delete(key: string): Promise;
+}
+export function memoryIdempotencyStore(
+ now: () => number = Date.now,
+): IdempotencyStore {
+ const records = new Map>();
+ return {
+ async get(key) {
+ const value = records.get(key);
+ if (!value) return null;
+ if (value.expiresAt <= now()) {
+ records.delete(key);
+ return null;
+ }
+ return value;
+ },
+ async set(record) {
+ records.set(record.key, record);
+ },
+ async delete(key) {
+ records.delete(key);
+ },
+ };
+}
+
+export async function withIdempotency(
+ store: IdempotencyStore,
+ key: string,
+ execute: () => Promise,
+ ttlMs = 24 * 60 * 60 * 1000,
+): Promise<{ value: T; replayed: boolean }> {
+ if (!key.trim()) throw new TypeError("idempotency key cannot be empty");
+ const existing = await store.get(key);
+ if (existing) return { value: existing.value, replayed: true };
+ const value = await execute();
+ await store.set({ key, value, expiresAt: Date.now() + ttlMs });
+ return { value, replayed: false };
+}
diff --git a/packages/csr/package.json b/packages/csr/package.json
index 277ecee4..46b99fc4 100644
--- a/packages/csr/package.json
+++ b/packages/csr/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/csr",
- "version": "0.3.6",
+ "version": "0.4.0",
"type": "module",
"main": "src/index.ts",
"exports": {
diff --git a/packages/csr/src/nav-runtime.ts b/packages/csr/src/nav-runtime.ts
index 57412f3b..d5d093af 100644
--- a/packages/csr/src/nav-runtime.ts
+++ b/packages/csr/src/nav-runtime.ts
@@ -85,6 +85,46 @@ export const NAV_RUNTIME = String.raw`
return loaded;
}
+ function runtimeIds(root) {
+ var ids = {};
+ if (!root || !root.querySelectorAll) return ids;
+ var nodes = [];
+ if (root.matches && root.matches("[data-wrnexus-runtime]")) nodes.push(root);
+ root.querySelectorAll("[data-wrnexus-runtime]").forEach(function (node) { nodes.push(node); });
+ nodes.forEach(function (node) {
+ String(node.getAttribute("data-wrnexus-runtime") || "")
+ .split(/[\s,]+/)
+ .forEach(function (id) { if (id) ids[id] = true; });
+ });
+ return ids;
+ }
+
+ function packageRuntimeRegistry() {
+ return window.__wrnexusRuntimes || {};
+ }
+
+ function mountPackageRuntimes(root) {
+ var ids = runtimeIds(root);
+ var registry = packageRuntimeRegistry();
+ Object.keys(ids).forEach(function (id) {
+ var runtime = registry[id];
+ if (!runtime || typeof runtime.mount !== "function") return;
+ try { runtime.mount(root); }
+ catch (error) { console.error("[wrnexus] failed to mount runtime", id, error); }
+ });
+ }
+
+ function unmountPackageRuntimes(root) {
+ var ids = runtimeIds(root);
+ var registry = packageRuntimeRegistry();
+ Object.keys(ids).forEach(function (id) {
+ var runtime = registry[id];
+ if (!runtime || typeof runtime.unmount !== "function") return;
+ try { runtime.unmount(root); }
+ catch (error) { console.error("[wrnexus] failed to unmount runtime", id, error); }
+ });
+ }
+
/**
* Append framework runtimes declared by the incoming document but not
* currently loaded.
@@ -106,8 +146,37 @@ export const NAV_RUNTIME = String.raw`
var element =
document.createElement("script");
+ Array.prototype.forEach.call(
+ script.attributes,
+ function (attribute) {
+ if (attribute.name === "src") return;
+ element.setAttribute(
+ attribute.name,
+ attribute.value,
+ );
+ },
+ );
element.src = src;
- element.async = false;
+ if (!script.hasAttribute("async")) {
+ element.async = false;
+ }
+ element.addEventListener("load", function () {
+ try {
+ mountPackageRuntimes(document);
+ window.dispatchEvent(
+ new CustomEvent(
+ "wrnexus:runtime-loaded",
+ { detail: { src: src } },
+ ),
+ );
+ } catch (_) {}
+ });
+ element.addEventListener("error", function () {
+ console.error(
+ "[wrnexus] failed to load client runtime",
+ src,
+ );
+ });
document.body.appendChild(element);
});
@@ -120,6 +189,7 @@ export const NAV_RUNTIME = String.raw`
* disposal here guarantees that unmount hooks run before replacement.
*/
function dispose(root) {
+ unmountPackageRuntimes(root);
try {
if (
typeof window.__wrnexusDisposeBehaviors ===
@@ -141,6 +211,7 @@ export const NAV_RUNTIME = String.raw`
* All framework hydration functions must remain idempotent.
*/
function rehydrate(root) {
+ mountPackageRuntimes(root);
try {
if (
typeof window.__wrnexusHydrateScopes ===
@@ -342,17 +413,6 @@ export const NAV_RUNTIME = String.raw`
return null;
}
- var contentType =
- response.headers.get("content-type") ||
- "";
-
- if (
- contentType.indexOf("text/html") === -1
- ) {
- hardNavigate(url);
- return null;
- }
-
return response.text().then(function (text) {
if (inFlight !== token) {
return;
diff --git a/packages/csr/test/nav.test.ts b/packages/csr/test/nav.test.ts
index ff398bc5..0ceaaa2f 100644
--- a/packages/csr/test/nav.test.ts
+++ b/packages/csr/test/nav.test.ts
@@ -104,3 +104,25 @@ test("rebinds theme controls after swapping the page", async () => {
expect(boundRoot).toBe(win.document);
expect(win.document.querySelector("[data-wire-theme-toggle]")).not.toBeNull();
});
+
+test("unmounts and remounts package runtimes during client navigation", async () => {
+ install(
+ ``,
+ );
+ let mounts = 0;
+ let unmounts = 0;
+ win.__wrnexusRuntimes = {
+ captcha: {
+ mount: () => mounts++,
+ unmount: () => unmounts++,
+ },
+ };
+ nextHtml = ``;
+
+ win.document.getElementById("lnk").click();
+ await flush();
+
+ expect(unmounts).toBe(1);
+ expect(mounts).toBe(1);
+ expect(win.document.getElementById("app").textContent).toContain("New");
+});
diff --git a/packages/db/package.json b/packages/db/package.json
index ef192140..e086557a 100644
--- a/packages/db/package.json
+++ b/packages/db/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/db",
- "version": "0.3.6",
+ "version": "0.4.0",
"private": true,
"type": "module",
"main": "src/index.ts",
diff --git a/packages/db/src/advanced.ts b/packages/db/src/advanced.ts
new file mode 100644
index 00000000..ed35e5b5
--- /dev/null
+++ b/packages/db/src/advanced.ts
@@ -0,0 +1,139 @@
+import type { Db, Row } from "./driver.ts";
+import type { Model } from "./schema.ts";
+import type { Dialect } from "./sql.ts";
+
+const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
+function ident(value: string): string {
+ if (!IDENT.test(value)) throw new Error(`Unsafe identifier: ${value}`);
+ return value;
+}
+function ph(dialect: Dialect, index: number): string {
+ return dialect === "postgres" ? `$${index}` : "?";
+}
+function encode(value: unknown): string {
+ return btoa(unescape(encodeURIComponent(JSON.stringify(value))))
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_")
+ .replace(/=+$/, "");
+}
+function decode(value: string): T {
+ const pad = value.length % 4 ? "=".repeat(4 - (value.length % 4)) : "";
+ return JSON.parse(
+ decodeURIComponent(escape(atob(value.replace(/-/g, "+").replace(/_/g, "/") + pad))),
+ ) as T;
+}
+
+export interface CursorPageOptions {
+ limit?: number;
+ after?: string;
+ before?: string;
+ column?: string;
+ direction?: "asc" | "desc";
+ maxLimit?: number;
+}
+export interface CursorPage {
+ items: T[];
+ nextCursor?: string;
+ previousCursor?: string;
+ hasMore: boolean;
+}
+interface CursorValue {
+ value: unknown;
+ direction: "asc" | "desc";
+}
+
+export async function cursorPaginate(
+ db: Db,
+ query: { sql: string; params?: unknown[]; model?: Model },
+ options: CursorPageOptions = {},
+): Promise> {
+ const column = ident(options.column ?? "id");
+ const direction = options.direction ?? "asc";
+ const max = options.maxLimit ?? 100;
+ const limit = Math.min(max, Math.max(1, Math.floor(options.limit ?? 20)));
+ const params = [...(query.params ?? [])];
+ const cursor = options.after
+ ? decode(options.after)
+ : options.before
+ ? decode(options.before)
+ : undefined;
+ const comparison = options.before
+ ? direction === "asc"
+ ? "<"
+ : ">"
+ : direction === "asc"
+ ? ">"
+ : "<";
+ const reverse = !!options.before;
+ let sql = `SELECT * FROM (${query.sql}) AS __wrn_cursor`;
+ if (cursor) {
+ params.push(cursor.value);
+ sql += ` WHERE ${column} ${comparison} ${ph(db.driver.dialect, params.length)}`;
+ }
+ const order = reverse ? (direction === "asc" ? "DESC" : "ASC") : direction.toUpperCase();
+ params.push(limit + 1);
+ sql += ` ORDER BY ${column} ${order} LIMIT ${ph(db.driver.dialect, params.length)}`;
+ let rows = await db.all(sql, params, query.model);
+ const hasMore = rows.length > limit;
+ if (hasMore) rows = rows.slice(0, limit);
+ if (reverse) rows.reverse();
+ const first = rows[0]?.[column];
+ const last = rows.at(-1)?.[column];
+ return {
+ items: rows,
+ hasMore,
+ ...(last !== undefined && (hasMore || rows.length === limit)
+ ? { nextCursor: encode({ value: last, direction }) }
+ : {}),
+ ...(first !== undefined && (options.after || options.before)
+ ? { previousCursor: encode({ value: first, direction }) }
+ : {}),
+ };
+}
+
+export async function optimisticUpdate(
+ db: Db,
+ input: {
+ table: string;
+ idColumn?: string;
+ id: unknown;
+ versionColumn?: string;
+ version: number;
+ values: Record;
+ },
+): Promise {
+ const table = ident(input.table);
+ const idColumn = ident(input.idColumn ?? "id");
+ const versionColumn = ident(input.versionColumn ?? "version");
+ const entries = Object.entries(input.values);
+ if (!entries.length) return input.version;
+ for (const [column] of entries) ident(column);
+ const params = entries.map(([, value]) => value);
+ const assignments = entries.map(
+ ([column], index) => `${column} = ${ph(db.driver.dialect, index + 1)}`,
+ );
+ assignments.push(`${versionColumn} = ${versionColumn} + 1`);
+ params.push(input.id, input.version);
+ const result = await db.exec(
+ `UPDATE ${table} SET ${assignments.join(", ")} WHERE ${idColumn} = ${ph(db.driver.dialect, params.length - 1)} AND ${versionColumn} = ${ph(db.driver.dialect, params.length)}`,
+ params,
+ );
+ if (result.changes !== 1) throw new Error("WRN-DB-OPTIMISTIC-LOCK");
+ return input.version + 1;
+}
+
+export function tenantScope(
+ sql: string,
+ tenantId: unknown,
+ dialect: Dialect,
+ existingParams = 0,
+ column = "tenantId",
+): { sql: string; params: unknown[] } {
+ ident(column);
+ const wrapped = `SELECT * FROM (${sql}) AS __wrn_tenant WHERE ${column} = ${ph(dialect, existingParams + 1)}`;
+ return { sql: wrapped, params: [tenantId] };
+}
+
+export function softDeleteClause(column = "deletedAt"): string {
+ return `${ident(column)} IS NULL`;
+}
diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts
index dd9ead37..ce4a40bd 100644
--- a/packages/db/src/index.ts
+++ b/packages/db/src/index.ts
@@ -17,6 +17,7 @@ export {
parseMigration,
loadMigrations,
appliedMigrations,
+ applyMigrations,
migrate,
rollback,
status,
@@ -27,3 +28,5 @@ export { parseQueries, generateQueriesFile } from "./generate.ts";
export type { QueryDef, QueryKind, ModelRef } from "./generate.ts";
export { paginate, loadRelated } from "./query.ts";
export type { Paginated, PageOptions, RelationOptions } from "./query.ts";
+export { cursorPaginate, optimisticUpdate, tenantScope, softDeleteClause } from "./advanced.ts";
+export type { CursorPage, CursorPageOptions } from "./advanced.ts";
diff --git a/packages/db/src/migrate.ts b/packages/db/src/migrate.ts
index 68c68c62..1dfa130c 100644
--- a/packages/db/src/migrate.ts
+++ b/packages/db/src/migrate.ts
@@ -63,21 +63,26 @@ export async function appliedMigrations(db: Db): Promise {
return rows.map((r) => r.name);
}
-/** Apply all pending migrations (each in a transaction). Returns applied names. */
-export async function migrate(db: Db, dir: string): Promise {
+/** Apply an ordered migration list (each in a transaction). Returns applied names. */
+export async function applyMigrations(db: Db, migrations: readonly Migration[]): Promise {
const applied = new Set(await appliedMigrations(db));
- const pending = loadMigrations(dir).filter((m) => !applied.has(m.name));
+ const pending = migrations.filter((migration) => !applied.has(migration.name));
const done: string[] = [];
- for (const m of pending) {
+ for (const migration of pending) {
await db.tx(async (tx) => {
- if (m.up) await tx.exec(m.up);
- await tx.exec(`INSERT INTO ${MIGRATIONS_TABLE} (name) VALUES (?)`, [m.name]);
+ if (migration.up) await tx.exec(migration.up);
+ await tx.exec(`INSERT INTO ${MIGRATIONS_TABLE} (name) VALUES (?)`, [migration.name]);
});
- done.push(m.name);
+ done.push(migration.name);
}
return done;
}
+/** Apply all pending migrations from a directory. */
+export async function migrate(db: Db, dir: string): Promise {
+ return applyMigrations(db, loadMigrations(dir));
+}
+
/** Roll back the most recently applied migration. Returns its name, or null. */
export async function rollback(db: Db, dir: string): Promise {
const applied = await appliedMigrations(db);
diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json
index 0268ae12..6f8654b6 100644
--- a/packages/dev-server/package.json
+++ b/packages/dev-server/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
- "version": "0.3.6",
+ "version": "0.4.0",
"type": "module",
"main": "src/index.ts",
"exports": {
diff --git a/packages/dev-server/src/assets.ts b/packages/dev-server/src/assets.ts
index d8efbe46..ca1fe63f 100644
--- a/packages/dev-server/src/assets.ts
+++ b/packages/dev-server/src/assets.ts
@@ -24,6 +24,7 @@ import { UPLOAD_RUNTIME, UPLOAD_JS_HREF, UPLOADS_PREFIX, serveStoredFile } from
import type { Mode } from "@wrnexus/core";
import type { AssetServer } from "./runtime.ts";
import { servePublicAsset } from "./public.ts";
+import { servePluginAsset, type ServedPluginAsset } from "./plugin-assets.ts";
/** Style inputs the dev asset server needs to build `/__wrnexus/styles.css`. */
export interface DevStyles {
@@ -31,6 +32,8 @@ export interface DevStyles {
config?: StylesConfig;
appRoot: string;
publicDir?: string;
+ sources?: string[];
+ entries?: string[];
}
/** A dev asset server also supports invalidating its caches in-process. */
@@ -64,6 +67,7 @@ export function createDevAssetServer(
theme?: ResolvedTheme,
uiCss?: string,
schemasJs?: string,
+ pluginAssets: readonly ServedPluginAsset[] = [],
): DevAssetServer {
let cssCache: string | null = null;
let schemasCode = schemasJs ?? "window.__wireSchemas={};";
@@ -107,16 +111,28 @@ export function createDevAssetServer(
}
if (pathname === "/__wrnexus/styles.css") {
- if (!styles?.entry) return new Response("Not Found", { status: 404 });
+ if (!styles?.entry && !styles?.entries?.length) {
+ return new Response("Not Found", { status: 404 });
+ }
if (cssCache === null) {
cssCache = await renderStyles(
- { entryPath: styles.entry, appDir, appRoot: styles.appRoot, mode },
+ {
+ entryPath: styles.entry,
+ appDir,
+ appRoot: styles.appRoot,
+ mode,
+ sources: styles.sources,
+ entries: styles.entries,
+ },
styles.config,
);
}
return cssResponse(cssCache);
}
+ const pluginAsset = await servePluginAsset(pluginAssets, pathname, mode);
+ if (pluginAsset) return pluginAsset;
+
return servePublicAsset(styles?.publicDir, pathname, mode);
},
};
diff --git a/packages/dev-server/src/index.ts b/packages/dev-server/src/index.ts
index 39fb35e3..7a4a0bad 100644
--- a/packages/dev-server/src/index.ts
+++ b/packages/dev-server/src/index.ts
@@ -6,7 +6,7 @@
* the running process while the HMR socket morphs fresh HTML into the browser.
*/
-import { resolve, dirname, join } from "node:path";
+import { resolve, dirname, isAbsolute, join } from "node:path";
import type { Middleware, Mode, SecurityConfig, SeoConfig } from "@wrnexus/core";
import { buildRouter, type Router } from "@wrnexus/router";
import {
@@ -19,20 +19,22 @@ import {
import { uiComponentsDir, uiCss } from "@wrnexus/ui";
import { renderSchemasScript, type ObjectSchema, type SchemaDescriptor } from "@wrnexus/validation";
import { loadLocales, resolveI18n, type I18nConfig } from "@wrnexus/i18n";
-import { migrate, setDb, registerDb } from "@wrnexus/db";
+import { applyMigrations, migrate, setDb, registerDb } from "@wrnexus/db";
import { connectFromConfig } from "@wrnexus/db/connect";
import { configureStorage, type StorageConfig } from "@wrnexus/uploader";
import { realtimeBusFromConfig } from "./realtime-bus.ts";
import { invalidateModule, loadModule, setCompileCacheDir } from "./pipeline.ts";
import { createHandlers, type WsData } from "./runtime.ts";
import { createDevAssetServer } from "./assets.ts";
+import { pluginAssetsFromContributions } from "./plugin-assets.ts";
+import { resolvePackageMigrations } from "./plugin-migrations.ts";
import { HmrHub } from "./hmr.ts";
import { startWatcher } from "./watch.ts";
export { RESTART_EXIT_CODE } from "./restart.ts";
import { resetDevCache } from "./cache.ts";
import type { DevToolbarConfig } from "@wrnexus/dev-toolbar/types";
-import { createPluginRunner, type PluginInput } from "@wrnexus/plugin";
+import { createPluginRunner, discoverPlugins, type PluginInput } from "@wrnexus/plugin";
import type { ObservabilityConfig, TenancyConfig } from "@wrnexus/styles";
import { createDevToolbarCollector, type DevToolbarCollector } from "@wrnexus/dev-toolbar/server";
@@ -160,7 +162,12 @@ export async function startServer(opts: ServeOptions): Promise {
const appDir = resolve(opts.appDir);
const appRoot = dirname(appDir);
const mode: Mode = opts.mode ?? "development";
- const pluginRunner = createPluginRunner(opts.plugins, {
+ const discoveredPlugins = await discoverPlugins(appRoot, opts.plugins, {
+ includeDevDependencies: true,
+ strict: true,
+ warn: (message) => console.warn(`[wrnexus:plugin] ${message}`),
+ });
+ const pluginRunner = createPluginRunner(discoveredPlugins, {
root: appRoot,
mode,
command: "dev",
@@ -171,13 +178,20 @@ export async function startServer(opts: ServeOptions): Promise {
await pluginRunner.configResolved(
Object.freeze({ ...opts }) as Readonly>,
);
+ const pluginContributions = await pluginRunner.contributions();
+ const pluginToolbarPanels = await pluginRunner.devToolbarPanels();
+ const componentDirs = [uiComponentsDir(), ...pluginContributions.componentDirs];
const hmr = opts.hmr ?? mode === "development";
const port = opts.port ?? 3000;
const hostname = opts.hostname ?? "::";
const displayHost = hostname === "0.0.0.0" || hostname === "::" ? "localhost" : hostname;
- const router = buildRouter(appDir, { componentDirs: [uiComponentsDir()] });
+ const router = buildRouter(appDir, {
+ componentDirs,
+ externalRoutes: pluginContributions.routes,
+ middlewareFiles: pluginContributions.middleware,
+ });
const styleEntry = opts.styleEntry ?? null;
const devToolbarConfig = resolveDevToolbarConfig(mode, opts.devToolbar);
@@ -215,7 +229,12 @@ export async function startServer(opts: ServeOptions): Promise {
? registerDb(name, connectFromConfig(cfg, appRoot))
: setDb(connectFromConfig(cfg, appRoot));
const dir = name ? join(appDir, "db", name, "migrations") : join(appDir, "db", "migrations");
- const applied = await migrate(db, dir);
+ const appApplied = await migrate(db, dir);
+ const packageApplied = await applyMigrations(
+ db,
+ resolvePackageMigrations(pluginContributions.migrations, name ?? undefined),
+ );
+ const applied = [...appApplied, ...packageApplied];
if (applied.length) {
console.log(
`[wrnexus] applied ${applied.length} migration(s)${name ? ` to '${name}'` : ""}`,
@@ -242,10 +261,16 @@ export async function startServer(opts: ServeOptions): Promise {
config: opts.stylesConfig,
appRoot,
publicDir: join(appRoot, "public"),
+ sources: [
+ ...componentDirs,
+ ...pluginContributions.styles.flatMap((style) => (style.source ? [style.source] : [])),
+ ],
+ entries: pluginContributions.styles.flatMap((style) => (style.entry ? [style.entry] : [])),
},
theme,
uiStyles,
schemasJs,
+ pluginAssetsFromContributions(pluginContributions),
);
const hub = hmr ? new HmrHub() : undefined;
@@ -264,7 +289,7 @@ export async function startServer(opts: ServeOptions): Promise {
loadModule,
getMiddleware: middleware.load,
assets,
- hasStyles: !!styleEntry,
+ hasStyles: !!styleEntry || pluginContributions.styles.some((style) => !!style.entry),
hasUi: true,
theme,
i18n,
@@ -275,6 +300,7 @@ export async function startServer(opts: ServeOptions): Promise {
security: opts.security,
observability: opts.observability,
tenancy: opts.tenancy,
+ clientRuntimes: pluginContributions.clientRuntimes,
hub,
realtimeBus: realtimeBusFromConfig(opts.realtime),
devToolbar:
@@ -283,6 +309,31 @@ export async function startServer(opts: ServeOptions): Promise {
config: devToolbarConfig,
collector: devToolbarCollector,
root: appRoot,
+ panels: pluginToolbarPanels,
+ platform: {
+ plugins: pluginRunner.plugins.map((plugin) => ({
+ name: plugin.name,
+ version: plugin.version,
+ })),
+ runtimes: pluginContributions.clientRuntimes.map((runtime) => ({
+ id: runtime.id,
+ publicPath: runtime.publicPath,
+ type: runtime.type,
+ load: runtime.load,
+ })),
+ assets: pluginContributions.assets.map((asset) => ({
+ id: asset.id,
+ publicPath: asset.publicPath,
+ contentType: asset.contentType,
+ })),
+ componentDirs,
+ styles: pluginContributions.styles,
+ routes: {
+ pages: router.pages.length,
+ api: router.api.length,
+ realtime: router.realtime.length,
+ },
+ },
}
: undefined,
};
@@ -304,6 +355,7 @@ export async function startServer(opts: ServeOptions): Promise {
handlers,
assets,
devToolbarCollector,
+ pluginContributions,
});
} catch (error) {
server.stop();
@@ -319,22 +371,26 @@ export async function startServer(opts: ServeOptions): Promise {
// Allow VS Code/Bun to finish writing pasted content.
await new Promise((resolvePromise) => setTimeout(resolvePromise, 50));
- for (const relative of files) {
- invalidateModule(resolve(appDir, relative));
+ for (const file of files) {
+ invalidateModule(isAbsolute(file) ? file : resolve(appDir, file));
}
+ if (files.some((file) => file.endsWith(".wrn"))) assets.invalidateCss();
Object.assign(
router,
buildRouter(appDir, {
- componentDirs: [uiComponentsDir()],
+ componentDirs,
+ externalRoutes: pluginContributions.routes,
+ middlewareFiles: pluginContributions.middleware,
}),
);
middleware.invalidate();
- if (files.some((file) => file === "schemas" || file.startsWith("schemas/"))) {
+ const appFiles = files.filter((file) => !isAbsolute(file));
+ if (appFiles.some((file) => file === "schemas" || file.startsWith("schemas/"))) {
assets.updateSchemas(await schemaRuntime(router));
}
- if (files.some((file) => file === "locales" || file.startsWith("locales/"))) {
+ if (appFiles.some((file) => file === "locales" || file.startsWith("locales/"))) {
const messages = loadLocales(join(appDir, "locales"));
runtimeDeps.i18n = Object.keys(messages).length
? resolveI18n(messages, opts.i18n)
@@ -350,8 +406,23 @@ export async function startServer(opts: ServeOptions): Promise {
files,
});
};
+ const packageWatchDirs = [
+ ...componentDirs,
+ ...pluginContributions.clientRuntimes.flatMap((runtime) =>
+ runtime.entry ? [dirname(runtime.entry)] : [],
+ ),
+ ...pluginContributions.assets.flatMap((asset) => (asset.entry ? [dirname(asset.entry)] : [])),
+ ...pluginContributions.styles.flatMap((style) =>
+ [style.source, style.entry ? dirname(style.entry) : undefined].filter(
+ (value): value is string => !!value,
+ ),
+ ),
+ ...pluginContributions.routes.map((route) => dirname(route.entry)),
+ ...pluginContributions.middleware.map((file) => dirname(file)),
+ ];
watcher = startWatcher({
appDir,
+ extraDirs: packageWatchDirs,
hub,
assets,
devToolbarCollector,
diff --git a/packages/dev-server/src/plugin-assets.ts b/packages/dev-server/src/plugin-assets.ts
new file mode 100644
index 00000000..195aadad
--- /dev/null
+++ b/packages/dev-server/src/plugin-assets.ts
@@ -0,0 +1,157 @@
+import { readFile } from "node:fs/promises";
+import type {
+ ClientRuntimeDefinition,
+ PackageAssetDefinition,
+ PluginContributions,
+} from "@wrnexus/plugin";
+import { contentTypeForPath, normalizeClientRuntime, normalizePackageAsset } from "@wrnexus/plugin";
+import type { ScriptAsset } from "@wrnexus/ssr";
+
+export interface ServedPluginAsset {
+ id: string;
+ publicPath: string;
+ entry?: string;
+ source?: string | Uint8Array;
+ contentType: string;
+ immutable: boolean;
+ runtime?: boolean;
+ runtimeType?: "module" | "script";
+ bundle?: boolean;
+}
+
+export interface ClientRuntimeAsset extends ClientRuntimeDefinition {
+ publicPath: string;
+}
+
+function toBody(value: Uint8Array): ArrayBuffer {
+ return Uint8Array.from(value).buffer;
+}
+
+export function pluginAssetsFromContributions(
+ contributions: PluginContributions,
+): ServedPluginAsset[] {
+ const assets: ServedPluginAsset[] = [];
+ for (const input of contributions.clientRuntimes) {
+ const runtime = normalizeClientRuntime(input);
+ assets.push({
+ id: `runtime:${runtime.id}`,
+ publicPath: runtime.publicPath!,
+ entry: runtime.entry,
+ source: runtime.source,
+ contentType: "text/javascript; charset=utf-8",
+ immutable: false,
+ runtime: true,
+ runtimeType: runtime.type,
+ bundle: runtime.bundle,
+ });
+ }
+ for (const input of contributions.assets) {
+ const asset = normalizePackageAsset(input);
+ assets.push({
+ id: `asset:${asset.id}`,
+ publicPath: asset.publicPath!,
+ entry: asset.entry,
+ source: asset.source,
+ contentType: asset.contentType ?? contentTypeForPath(asset.entry ?? asset.publicPath!),
+ immutable: asset.immutable ?? false,
+ });
+ }
+ return assets;
+}
+
+export async function readPluginAsset(asset: ServedPluginAsset): Promise {
+ if (typeof asset.source === "string") return asset.source;
+ if (asset.source instanceof Uint8Array) return toBody(asset.source);
+ if (!asset.entry) throw new Error(`Plugin asset '${asset.id}' has no source.`);
+
+ const shouldBundle = asset.runtime && (asset.bundle ?? /\.[cm]?tsx?$/.test(asset.entry));
+ if (!shouldBundle) return toBody(await readFile(asset.entry));
+
+ const result = await Bun.build({
+ entrypoints: [asset.entry],
+ target: "browser",
+ format: asset.runtimeType === "script" ? "iife" : "esm",
+ minify: false,
+ sourcemap: "inline",
+ });
+ if (!result.success || !result.outputs[0]) {
+ throw new Error(
+ `Plugin runtime '${asset.id}' failed to build:\n${result.logs.map(String).join("\n")}`,
+ );
+ }
+ return toBody(new Uint8Array(await result.outputs[0].arrayBuffer()));
+}
+
+export async function servePluginAsset(
+ assets: readonly ServedPluginAsset[],
+ pathname: string,
+ mode: "development" | "production",
+): Promise {
+ const asset = assets.find((entry) => entry.publicPath === pathname);
+ if (!asset) return null;
+ try {
+ const body = await readPluginAsset(asset);
+ return new Response(body, {
+ headers: {
+ "content-type": asset.contentType,
+ "cache-control":
+ mode === "production" && asset.immutable
+ ? "public, max-age=31536000, immutable"
+ : "no-cache",
+ "x-content-type-options": "nosniff",
+ },
+ });
+ } catch (error) {
+ console.error(`[wrnexus] failed to serve plugin asset ${asset.publicPath}`, error);
+ return new Response("Plugin asset unavailable", { status: 503 });
+ }
+}
+
+export function runtimeScript(input: ClientRuntimeDefinition): ScriptAsset {
+ const runtime = normalizeClientRuntime(input);
+ return {
+ src: runtime.publicPath!,
+ type: runtime.type === "script" ? "classic" : "module",
+ async: runtime.load === "eager" ? false : undefined,
+ defer: runtime.load !== "eager",
+ integrity: runtime.integrity,
+ crossOrigin: runtime.crossOrigin,
+ attributes: {
+ "data-wrnexus-runtime-src": runtime.id,
+ ...(runtime.attributes ?? {}),
+ },
+ };
+}
+
+export function runtimeIdsFromMarkup(body: string): Set {
+ const ids = new Set();
+ for (const match of body.matchAll(/\bdata-wrnexus-runtime\s*=\s*["']([^"']+)["']/gi)) {
+ for (const id of match[1]!.split(/[\s,]+/)) {
+ if (id) ids.add(id);
+ }
+ }
+ return ids;
+}
+
+export function runtimeScriptsForMarkup(
+ body: string,
+ runtimes: readonly ClientRuntimeDefinition[] = [],
+): ScriptAsset[] {
+ const ids = runtimeIdsFromMarkup(body);
+ return runtimes.filter((runtime) => ids.has(runtime.id)).map(runtimeScript);
+}
+
+export function mergePluginAssets(
+ runtimes: readonly ClientRuntimeDefinition[],
+ assets: readonly PackageAssetDefinition[],
+): ServedPluginAsset[] {
+ return pluginAssetsFromContributions({
+ componentDirs: [],
+ clientRuntimes: [...runtimes],
+ assets: [...assets],
+ styles: [],
+ routes: [],
+ middleware: [],
+ migrations: [],
+ });
+}
diff --git a/packages/dev-server/src/plugin-migrations.ts b/packages/dev-server/src/plugin-migrations.ts
new file mode 100644
index 00000000..666db150
--- /dev/null
+++ b/packages/dev-server/src/plugin-migrations.ts
@@ -0,0 +1,56 @@
+import { existsSync, readFileSync, statSync } from "node:fs";
+import { basename } from "node:path";
+import { loadMigrations, parseMigration, type Migration } from "@wrnexus/db";
+import type { PackageMigrationDefinition } from "@wrnexus/plugin";
+
+function migrationName(definition: PackageMigrationDefinition, name?: string): string {
+ const prefix = definition.id.trim().replace(/[^a-zA-Z0-9_.-]+/g, "_");
+ const suffix = name?.trim().replace(/[^a-zA-Z0-9_.-]+/g, "_");
+ return suffix ? `${prefix}__${suffix}` : prefix;
+}
+
+function databaseMatches(definition: PackageMigrationDefinition, database?: string): boolean {
+ const target = definition.database?.trim() || "default";
+ return target === (database?.trim() || "default");
+}
+
+/** Resolve package-owned migrations into the same ordered contract as app migrations. */
+export function resolvePackageMigrations(
+ definitions: readonly PackageMigrationDefinition[],
+ database?: string,
+): Migration[] {
+ const output: Migration[] = [];
+ for (const definition of definitions) {
+ if (!databaseMatches(definition, database)) continue;
+ if (definition.source !== undefined) {
+ const parsed = parseMigration(migrationName(definition), definition.source);
+ output.push(parsed);
+ continue;
+ }
+ const entry = definition.entry;
+ if (!entry || !existsSync(entry)) {
+ throw new Error(
+ `WRN-PLUGIN-MIGRATION-MISSING: ${definition.id} points to ${entry ?? ""}.`,
+ );
+ }
+ const stat = statSync(entry);
+ if (stat.isDirectory()) {
+ for (const migration of loadMigrations(entry)) {
+ output.push({ ...migration, name: migrationName(definition, migration.name) });
+ }
+ continue;
+ }
+ if (!stat.isFile() || !entry.endsWith(".sql")) {
+ throw new Error(
+ `WRN-PLUGIN-MIGRATION-ENTRY: ${definition.id} must be a .sql file or directory.`,
+ );
+ }
+ output.push(
+ parseMigration(
+ migrationName(definition, basename(entry, ".sql")),
+ readFileSync(entry, "utf8"),
+ ),
+ );
+ }
+ return output;
+}
diff --git a/packages/dev-server/src/prod.ts b/packages/dev-server/src/prod.ts
index 8a7487d7..46d07b31 100644
--- a/packages/dev-server/src/prod.ts
+++ b/packages/dev-server/src/prod.ts
@@ -41,6 +41,7 @@ import {
import { realtimeBusFromConfig } from "./realtime-bus.ts";
import { createHandlers, type AssetServer, type WsData } from "./runtime.ts";
import { servePublicAsset } from "./public.ts";
+import type { ClientRuntimeDefinition } from "@wrnexus/plugin";
type RouteModule = Record;
@@ -62,6 +63,12 @@ export interface ProdManifest {
layouts: { name: string; mod: RouteModule }[];
}
+export interface ProductionPluginAsset {
+ path: string;
+ contentType: string;
+ immutable?: boolean;
+}
+
export interface ProdOptions {
/** Absolute path to the pre-built global stylesheet, if any. */
stylesPath?: string;
@@ -108,6 +115,10 @@ export interface ProdOptions {
storage?: StorageConfig;
/** Cache-busting version appended to framework asset URLs. */
assetVersion?: string;
+ /** Package browser runtimes already emitted by the production build. */
+ clientRuntimes?: ClientRuntimeDefinition[];
+ /** Public URL to emitted package asset metadata. */
+ pluginAssets?: Record;
/** Absolute path to copied public assets, if any. */
publicDir?: string;
/** Raw HTML appended to every page head. */
@@ -238,6 +249,15 @@ function createProdAssetServer(opts: ProdOptions): AssetServer {
if (pathname === "/__wrnexus/framework.css")
return serveFile(opts.frameworkCssPath, CSS_HEADERS);
if (pathname === "/__wrnexus/styles.css") return serveFile(opts.stylesPath, CSS_HEADERS);
+ const pluginAsset = opts.pluginAssets?.[pathname];
+ if (pluginAsset) {
+ return serveFile(pluginAsset.path, {
+ "content-type": pluginAsset.contentType,
+ "cache-control":
+ pluginAsset.immutable === false ? "no-cache" : "public, max-age=31536000, immutable",
+ "x-content-type-options": "nosniff",
+ });
+ }
return servePublicAsset(opts.publicDir, pathname, MODE);
},
};
@@ -306,6 +326,7 @@ export function createProductionHandlers(
inlineStyles: opts.inlineStyles,
stylesIncludeFramework: opts.stylesIncludeFramework,
assetVersion: opts.assetVersion,
+ clientRuntimes: opts.clientRuntimes,
head: opts.head,
seo: opts.seo,
mobile: opts.mobile,
diff --git a/packages/dev-server/src/runtime.ts b/packages/dev-server/src/runtime.ts
index 748fc6a3..7dcfef78 100644
--- a/packages/dev-server/src/runtime.ts
+++ b/packages/dev-server/src/runtime.ts
@@ -36,7 +36,9 @@ import {
type TFunction,
} from "@wrnexus/core";
import type { Router } from "@wrnexus/router";
-import { renderDocument } from "@wrnexus/ssr";
+import { renderDocument, type RenderScript, type ScriptAsset } from "@wrnexus/ssr";
+import type { ClientRuntimeDefinition } from "@wrnexus/plugin";
+import { runtimeScriptsForMarkup } from "./plugin-assets.ts";
import {
THEME_COOKIE,
THEME_CSS_HREF,
@@ -58,7 +60,11 @@ import {
} from "@wrnexus/i18n";
import { runMiddleware } from "./pipeline.ts";
import type { HmrHub } from "./hmr.ts";
-import type { DevToolbarConfig } from "@wrnexus/dev-toolbar/types";
+import type {
+ DevToolbarConfig,
+ DevToolbarPanel,
+ DevToolbarPlatformSnapshot,
+} from "@wrnexus/dev-toolbar/types";
import {
createServerIssue,
@@ -125,6 +131,8 @@ export interface RuntimeDeps {
inlineStyles?: string;
/** Production cache-busting version appended to framework asset URLs. */
assetVersion?: string;
+ /** Package browser runtimes resolved by the plugin system. */
+ clientRuntimes?: ClientRuntimeDefinition[];
/** Raw HTML appended to every page head (e.g. CDN framework links). */
head?: string;
/** Global SEO defaults. */
@@ -152,6 +160,8 @@ export interface RuntimeDeps {
config: DevToolbarConfig;
collector: DevToolbarCollector;
root: string;
+ platform?: DevToolbarPlatformSnapshot;
+ panels?: DevToolbarPanel[];
};
}
@@ -835,6 +845,8 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
collector: deps.devToolbar.collector,
editor: deps.devToolbar.config.editor,
allowOpenEditor: deps.devToolbar.config.openEditor !== false,
+ platform: deps.devToolbar.platform,
+ panels: deps.devToolbar.panels,
});
if (toolbarResponse) return secure(toolbarResponse);
}
@@ -1111,7 +1123,10 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
selfClose === "/" ? { inner: "", end: openEnd } : readElementBody(body, tag!, openEnd);
i = end;
- const component = router.components.find((c) => c.name === name);
+ const normalizedName = name.toLowerCase();
+ const component = router.components.find(
+ (candidate) => candidate.name.toLowerCase() === normalizedName,
+ );
if (!component) {
console.warn(`[wrnexus] no component registered for '${name}'`);
result += body.slice(tagStart, end);
@@ -1280,7 +1295,9 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
if (deps.i18n) body = translateHtml(body, ctx.t);
// Point 3: only ship the JS this page actually uses.
- const scripts = collectScripts(body).map((src) => versionAssetUrl(src, deps.assetVersion));
+ const scripts = collectScripts(body, deps.clientRuntimes).map((script) =>
+ versionRenderScript(script, deps.assetVersion),
+ );
if (pwaServiceWorkerEnabled)
scripts.push(versionAssetUrl("/__wrnexus/pwa.js", deps.assetVersion));
if (deps.mobile?.enabled !== false && usesMobileRuntime(body))
@@ -1319,7 +1336,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
// Conditional GET: hash the page CONTENT (`body`), not the assembled shell —
// the shell carries a per-request CSP nonce in dev, which would otherwise make
// the ETag change every request. Same content → same ETag → 304 on revalidate.
- const tag = etag(`${htmlAttrs ?? ""}\n${scripts.join(",")}\n${body}`);
+ const tag = etag(`${htmlAttrs ?? ""}\n${JSON.stringify(scripts)}\n${body}`);
const method = ctx.req.method.toUpperCase();
if ((method === "GET" || method === "HEAD") && notModified(ctx.req, tag)) {
return new Response(null, {
@@ -1640,10 +1657,13 @@ export function resolveTProps(
* server-rendered into the HTML; the only script is the reactive runtime, and
* only when the page actually contains a scope or a browser-side API fetch.
*/
-export function collectScripts(body: string): string[] {
+export function collectScripts(
+ body: string,
+ clientRuntimes: readonly ClientRuntimeDefinition[] = [],
+): RenderScript[] {
// Client-side navigation is an app-wide progressive enhancement: it must load
// on every page (you navigate *from* any page), and degrades to full loads.
- const scripts: string[] = ["/__wrnexus/nav.js"];
+ const scripts: RenderScript[] = ["/__wrnexus/nav.js"];
if (/\bdata-scope=/.test(body) || /\bdata-wrnexus-csr=/.test(body)) {
scripts.push("/__wrnexus/reactive.js");
}
@@ -1667,6 +1687,10 @@ export function collectScripts(body: string): string[] {
if (/\bdata-uploader\b/.test(body)) {
scripts.push("/__wrnexus/uploader.js");
}
+ // Package runtimes are declarative. Components mark the rendered HTML with
+ // `data-wrnexus-runtime="id"`; the corresponding package chunk is loaded
+ // once, without requiring application-authored script tags or public copies.
+ scripts.push(...runtimeScriptsForMarkup(body, clientRuntimes));
return scripts;
}
@@ -1686,6 +1710,11 @@ function versionAssetUrl(src: string, version?: string): string {
return `${src}${src.includes("?") ? "&" : "?"}v=${encodeURIComponent(version)}`;
}
+function versionRenderScript(script: RenderScript, version?: string): RenderScript {
+ if (typeof script === "string") return versionAssetUrl(script, version);
+ return { ...script, src: versionAssetUrl(script.src, version) } satisfies ScriptAsset;
+}
+
function escapeStyleContent(css: string): string {
return css.replace(/<\/style/gi, "<\\/style");
}
diff --git a/packages/dev-server/src/watch.ts b/packages/dev-server/src/watch.ts
index ceb3856b..1045a9ea 100644
--- a/packages/dev-server/src/watch.ts
+++ b/packages/dev-server/src/watch.ts
@@ -8,13 +8,16 @@
* process and HMR socket stay alive.
*/
-import { watch, type FSWatcher } from "node:fs";
+import { existsSync, statSync, watch, type FSWatcher } from "node:fs";
+import { isAbsolute, join, relative, resolve } from "node:path";
import type { HmrHub } from "./hmr.ts";
import type { DevAssetServer } from "./assets.ts";
import type { DevToolbarCollector } from "@wrnexus/dev-toolbar/server";
export interface WatchOptions {
appDir: string;
+ /** Additional package component/runtime/style directories watched for HMR. */
+ extraDirs?: string[];
hub: HmrHub;
assets: DevAssetServer;
devToolbarCollector?: DevToolbarCollector;
@@ -39,8 +42,12 @@ function classify(rel: string): Kind {
return "server";
}
-/** Returns the watcher so the running server can close it during shutdown. */
-export function startWatcher(opts: WatchOptions): FSWatcher | undefined {
+export interface WatchHandle {
+ close(): void;
+}
+
+/** Returns a composite watcher so the running server can close every source root. */
+export function startWatcher(opts: WatchOptions): WatchHandle | undefined {
const { appDir, hub, assets } = opts;
const pending = new Set();
const pendingFiles = new Set();
@@ -72,18 +79,45 @@ export function startWatcher(opts: WatchOptions): FSWatcher | undefined {
pendingFiles.clear();
};
- try {
- return watch(appDir, { recursive: true }, (_event, filename) => {
- if (!filename) return;
- const rel = filename.toString().replace(/\\/g, "/");
- if (isIgnored(rel)) return;
- pendingFiles.add(rel);
- pending.add(classify(rel));
- if (timer) clearTimeout(timer);
- timer = setTimeout(flush, 200); // debounce editor write bursts
- });
- } catch (err) {
- console.warn("[wrnexus] file watching unavailable; HMR disabled", err);
- return undefined;
+ const appRoot = resolve(appDir);
+ const candidates = [appRoot, ...(opts.extraDirs ?? []).map((dir) => resolve(dir))];
+ const roots = [...new Set(candidates)]
+ .filter((dir) => existsSync(dir) && statSync(dir).isDirectory())
+ .filter(
+ (dir, index, values) =>
+ !values.some((other, otherIndex) => {
+ if (otherIndex >= index) return false;
+ const nested = relative(other, dir);
+ return nested === "" || (!nested.startsWith("..") && !isAbsolute(nested));
+ }),
+ );
+ const watchers: FSWatcher[] = [];
+
+ for (const root of roots) {
+ try {
+ const external = root !== appRoot;
+ watchers.push(
+ watch(root, { recursive: true }, (_event, filename) => {
+ if (!filename) return;
+ const relativeFile = filename.toString().replace(/\\/g, "/");
+ if (isIgnored(relativeFile)) return;
+ const file = external ? join(root, relativeFile).replace(/\\/g, "/") : relativeFile;
+ pendingFiles.add(file);
+ pending.add(classify(file));
+ if (timer) clearTimeout(timer);
+ timer = setTimeout(flush, 200); // debounce editor write bursts
+ }),
+ );
+ } catch (error) {
+ console.warn(`[wrnexus] file watching unavailable for ${root}`, error);
+ }
}
+
+ if (!watchers.length) return undefined;
+ return {
+ close() {
+ if (timer) clearTimeout(timer);
+ for (const watcher of watchers) watcher.close();
+ },
+ };
}
diff --git a/packages/dev-server/test/plugin-assets.test.ts b/packages/dev-server/test/plugin-assets.test.ts
new file mode 100644
index 00000000..28bc23bb
--- /dev/null
+++ b/packages/dev-server/test/plugin-assets.test.ts
@@ -0,0 +1,67 @@
+import { expect, test } from "bun:test";
+import {
+ mergePluginAssets,
+ runtimeIdsFromMarkup,
+ runtimeScriptsForMarkup,
+ servePluginAsset,
+} from "../src/plugin-assets.ts";
+import { resolvePackageMigrations } from "../src/plugin-migrations.ts";
+
+test("injects only runtimes referenced by rendered markup", () => {
+ const runtimes = [
+ { id: "captcha", source: "window.captcha = true" },
+ { id: "editor", source: "window.editor = true" },
+ ];
+ const body = '';
+ expect([...runtimeIdsFromMarkup(body)]).toEqual(["captcha"]);
+ const scripts = runtimeScriptsForMarkup(body, runtimes);
+ expect(scripts).toHaveLength(1);
+ expect(scripts[0]).toMatchObject({
+ src: "/__wrnexus/assets/captcha.js",
+ type: "module",
+ defer: true,
+ });
+});
+
+test("serves package assets with a safe content type", async () => {
+ const assets = mergePluginAssets(
+ [{ id: "captcha", source: "window.captcha = true", type: "script" }],
+ [],
+ );
+ const response = await servePluginAsset(assets, "/__wrnexus/assets/captcha.js", "development");
+ expect(response?.status).toBe(200);
+ expect(response?.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
+ expect(response?.headers.get("x-content-type-options")).toBe("nosniff");
+ expect(await response?.text()).toContain("window.captcha");
+});
+
+test("resolves inline package migrations for the requested database", () => {
+ const migrations = resolvePackageMigrations(
+ [
+ { id: "default-schema", source: "-- +up\nCREATE TABLE one(id INTEGER);" },
+ {
+ id: "analytics-schema",
+ database: "analytics",
+ source: "-- +up\nCREATE TABLE events(id INTEGER);",
+ },
+ ],
+ "analytics",
+ );
+ expect(migrations).toHaveLength(1);
+ expect(migrations[0]?.name).toBe("analytics-schema");
+ expect(migrations[0]?.up).toContain("CREATE TABLE events");
+});
+
+import { collectScripts } from "../src/runtime.ts";
+
+test("collectScripts automatically adds a referenced package runtime once", () => {
+ const scripts = collectScripts(
+ '',
+ [{ id: "captcha", source: "window.captcha = true", type: "script" }],
+ );
+ expect(
+ scripts.filter(
+ (script) => typeof script !== "string" && script.src === "/__wrnexus/assets/captcha.js",
+ ),
+ ).toHaveLength(1);
+});
diff --git a/packages/dev-toolbar/package.json b/packages/dev-toolbar/package.json
index a9834260..ead2e964 100644
--- a/packages/dev-toolbar/package.json
+++ b/packages/dev-toolbar/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-toolbar",
- "version": "0.3.6",
+ "version": "0.4.0",
"private": true,
"type": "module",
"sideEffects": false,
diff --git a/packages/dev-toolbar/src/server/routes.ts b/packages/dev-toolbar/src/server/routes.ts
index e89c9a6c..8c3438f4 100644
--- a/packages/dev-toolbar/src/server/routes.ts
+++ b/packages/dev-toolbar/src/server/routes.ts
@@ -2,6 +2,7 @@ import type { DevToolbarCollector } from "./collector.ts";
import { DEV_TOOLBAR_CSS, DEV_TOOLBAR_RUNTIME } from "../client/index.ts";
import { openInEditor } from "./editor.ts";
import { serializeDevToolbarJson } from "./serialize.ts";
+import type { DevToolbarPanel, DevToolbarPlatformSnapshot } from "../types.ts";
export interface DevToolbarRouteOptions {
mode: string;
@@ -9,6 +10,8 @@ export interface DevToolbarRouteOptions {
collector: DevToolbarCollector;
editor?: string;
allowOpenEditor?: boolean;
+ platform?: DevToolbarPlatformSnapshot;
+ panels?: DevToolbarPanel[];
}
const json = (value: unknown, status = 200) =>
@@ -36,6 +39,8 @@ export async function handleDevToolbarRoute(
return json({
issues: options.collector.getIssues(url.searchParams.get("pathname") ?? undefined),
});
+ if (url.pathname === "/__wrnexus/dev-toolbar/platform" && request.method === "GET")
+ return json({ platform: options.platform ?? null, panels: options.panels ?? [] });
if (url.pathname === "/__wrnexus/dev-toolbar/open-editor" && request.method === "POST") {
if (options.allowOpenEditor === false)
return json({ error: "Open in editor is disabled." }, 403);
diff --git a/packages/dev-toolbar/src/types.ts b/packages/dev-toolbar/src/types.ts
index 3bce4fc9..581a5254 100644
--- a/packages/dev-toolbar/src/types.ts
+++ b/packages/dev-toolbar/src/types.ts
@@ -132,3 +132,24 @@ declare global {
__wrnexusDevToolbar?: DevToolbarClientApi;
}
}
+
+export interface DevToolbarPanel {
+ id: string;
+ title: string;
+ icon?: string;
+ description?: string;
+ badge?: number | string;
+ order?: number;
+ issues?: unknown[];
+ data?: unknown;
+}
+
+export interface DevToolbarPlatformSnapshot {
+ plugins?: Array<{ name: string; version?: string }>;
+ runtimes?: Array<{ id: string; publicPath?: string; type?: string; load?: string }>;
+ assets?: Array<{ id: string; publicPath?: string; contentType?: string }>;
+ componentDirs?: string[];
+ styles?: unknown[];
+ routes?: { pages: number; api: number; realtime: number };
+ [key: string]: unknown;
+}
diff --git a/packages/encryption/package.json b/packages/encryption/package.json
index 56098593..6628e0f6 100644
--- a/packages/encryption/package.json
+++ b/packages/encryption/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/encryption",
- "version": "0.3.6",
+ "version": "0.4.0",
"private": true,
"type": "module",
"main": "src/index.ts",
diff --git a/packages/encryption/src/index.ts b/packages/encryption/src/index.ts
index 3b7ad4db..0d293fdf 100644
--- a/packages/encryption/src/index.ts
+++ b/packages/encryption/src/index.ts
@@ -138,3 +138,5 @@ export async function deriveKey(password: string, salt: string): Promise
);
return toB64(new Uint8Array(bits));
}
+export { createKeyring, seal, open, sealedKeyId, needsRotation } from "./keyring.ts";
+export type { EncryptionKey, EncryptionKeyring } from "./keyring.ts";
diff --git a/packages/encryption/src/keyring.ts b/packages/encryption/src/keyring.ts
new file mode 100644
index 00000000..febd7cae
--- /dev/null
+++ b/packages/encryption/src/keyring.ts
@@ -0,0 +1,101 @@
+import { decrypt, encrypt, generateKey } from "./index.ts";
+
+export interface EncryptionKey {
+ id: string;
+ secret: string;
+ active?: boolean;
+ createdAt?: number;
+}
+
+export interface EncryptionKeyring {
+ active(): EncryptionKey;
+ get(id: string): EncryptionKey | undefined;
+ keys(): EncryptionKey[];
+ rotate(key?: EncryptionKey): Promise;
+ remove(id: string): boolean;
+}
+
+const KEY_ID = /^[A-Za-z0-9._-]{1,64}$/;
+
+function validateKey(key: EncryptionKey): void {
+ if (!KEY_ID.test(key.id)) {
+ throw new TypeError(`Invalid encryption key id: ${key.id}`);
+ }
+ if (!key.secret.trim()) {
+ throw new TypeError(`Encryption key '${key.id}' has an empty secret`);
+ }
+}
+
+export function createKeyring(initial: EncryptionKey[]): EncryptionKeyring {
+ const values = new Map();
+ let activeCount = 0;
+ for (const key of initial) {
+ validateKey(key);
+ if (values.has(key.id)) {
+ throw new Error(`WRN-ENCRYPTION-KEYRING-DUPLICATE: ${key.id}`);
+ }
+ if (key.active) activeCount++;
+ values.set(key.id, { ...key });
+ }
+ if (!values.size) throw new Error("WRN-ENCRYPTION-KEYRING-EMPTY");
+ if (activeCount > 1) throw new Error("WRN-ENCRYPTION-KEYRING-MULTIPLE-ACTIVE");
+
+ const activeInternal = (): EncryptionKey => {
+ const key = [...values.values()].find((entry) => entry.active) ?? [...values.values()].at(-1);
+ if (!key) throw new Error("WRN-ENCRYPTION-ACTIVE-KEY-MISSING");
+ return key;
+ };
+
+ return {
+ active: () => ({ ...activeInternal() }),
+ get(id) {
+ const key = values.get(id);
+ return key ? { ...key } : undefined;
+ },
+ keys: () => [...values.values()].map((key) => ({ ...key })),
+ async rotate(key) {
+ const next = key ?? {
+ id: `key-${Date.now().toString(36)}`,
+ secret: await generateKey(),
+ createdAt: Date.now(),
+ };
+ validateKey(next);
+ for (const current of values.values()) current.active = false;
+ const stored = { ...next, active: true };
+ values.set(stored.id, stored);
+ return { ...stored };
+ },
+ remove(id) {
+ if (!values.has(id)) return false;
+ if (values.size <= 1) {
+ throw new Error("WRN-ENCRYPTION-KEYRING-LAST-KEY");
+ }
+ if (activeInternal().id === id) {
+ throw new Error("WRN-ENCRYPTION-KEYRING-ACTIVE-REMOVE");
+ }
+ return values.delete(id);
+ },
+ };
+}
+
+/** Versioned payload: `wrn1..`. */
+export async function seal(plaintext: string, keyring: EncryptionKeyring): Promise {
+ const key = keyring.active();
+ return `wrn1.${key.id}.${await encrypt(plaintext, key.secret)}`;
+}
+
+export async function open(sealed: string, keyring: EncryptionKeyring): Promise {
+ const match = /^wrn1\.([A-Za-z0-9._-]{1,64})\.(.+)$/.exec(sealed);
+ if (!match) throw new Error("WRN-ENCRYPTION-PAYLOAD-VERSION");
+ const key = keyring.get(match[1]!);
+ if (!key) throw new Error(`WRN-ENCRYPTION-KEY-NOT-FOUND: ${match[1]}`);
+ return decrypt(match[2]!, key.secret);
+}
+
+export function sealedKeyId(sealed: string): string | null {
+ return /^wrn1\.([A-Za-z0-9._-]{1,64})\./.exec(sealed)?.[1] ?? null;
+}
+
+export function needsRotation(sealed: string, keyring: EncryptionKeyring): boolean {
+ return sealedKeyId(sealed) !== keyring.active().id;
+}
diff --git a/packages/encryption/test/encryption.test.ts b/packages/encryption/test/encryption.test.ts
index 489d6ecd..a0ed0df6 100644
--- a/packages/encryption/test/encryption.test.ts
+++ b/packages/encryption/test/encryption.test.ts
@@ -7,6 +7,7 @@ import {
sha256,
hmacSign,
hmacVerify,
+ createKeyring,
} from "../src/index.ts";
test("sha256 is stable and hex-encoded", async () => {
@@ -56,3 +57,20 @@ test("deriveKey is deterministic for the same password+salt", async () => {
// usable as an encryption key
expect(await decrypt(await encrypt("x", k1), k1)).toBe("x");
});
+
+test("keyrings reject duplicate keys and return defensive copies", () => {
+ expect(() =>
+ createKeyring([
+ { id: "one", secret: "secret-one", active: true },
+ { id: "one", secret: "secret-two" },
+ ]),
+ ).toThrow("DUPLICATE");
+
+ const keyring = createKeyring([
+ { id: "one", secret: "secret-one", active: true },
+ { id: "two", secret: "secret-two" },
+ ]);
+ const active = keyring.active();
+ active.secret = "changed";
+ expect(keyring.active().secret).toBe("secret-one");
+});
diff --git a/packages/helpers/package.json b/packages/helpers/package.json
index 2f8d337f..688dd2df 100644
--- a/packages/helpers/package.json
+++ b/packages/helpers/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/helpers",
- "version": "0.3.6",
+ "version": "0.4.0",
"private": true,
"type": "module",
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
diff --git a/packages/helpers/src/index.ts b/packages/helpers/src/index.ts
index dc87bcd5..e68c5403 100644
--- a/packages/helpers/src/index.ts
+++ b/packages/helpers/src/index.ts
@@ -145,3 +145,14 @@ export {
currentAppOrigin,
workspaceAppOrigins,
} from "./workspace.ts";
+export {
+ backoffDelay,
+ sleep,
+ retry,
+ withTimeout,
+ stableStringify,
+ safeJsonParse,
+ clamp,
+ once,
+} from "./resilience.ts";
+export type { RetryOptions } from "./resilience.ts";
diff --git a/packages/helpers/src/resilience.ts b/packages/helpers/src/resilience.ts
new file mode 100644
index 00000000..3c4e00b2
--- /dev/null
+++ b/packages/helpers/src/resilience.ts
@@ -0,0 +1,161 @@
+export interface RetryOptions {
+ attempts?: number;
+ minDelayMs?: number;
+ maxDelayMs?: number;
+ factor?: number;
+ jitter?: number;
+ signal?: AbortSignal;
+ retryIf?: (error: unknown, attempt: number) => boolean | Promise;
+ onRetry?: (error: unknown, attempt: number, delayMs: number) => void | Promise;
+}
+
+function nonNegative(value: number, label: string): number {
+ if (!Number.isFinite(value) || value < 0) {
+ throw new RangeError(`${label} must be a non-negative number`);
+ }
+ return value;
+}
+
+export function backoffDelay(
+ attempt: number,
+ options: Pick = {},
+): number {
+ if (!Number.isInteger(attempt) || attempt < 1) {
+ throw new RangeError("attempt must be a positive integer");
+ }
+ const min = nonNegative(options.minDelayMs ?? 100, "minDelayMs");
+ const max = nonNegative(options.maxDelayMs ?? 30_000, "maxDelayMs");
+ if (max < min) throw new RangeError("maxDelayMs must be at least minDelayMs");
+ const factor = options.factor ?? 2;
+ if (!Number.isFinite(factor) || factor < 1) {
+ throw new RangeError("factor must be at least 1");
+ }
+ const jitter = Math.min(1, Math.max(0, options.jitter ?? 0.2));
+ const raw = Math.min(max, min * factor ** Math.max(0, attempt - 1));
+ return Math.round(raw * (1 - jitter + Math.random() * jitter * 2));
+}
+
+export function sleep(ms: number, signal?: AbortSignal): Promise {
+ nonNegative(ms, "sleep duration");
+ if (signal?.aborted) {
+ return Promise.reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
+ }
+
+ return new Promise((resolve, reject) => {
+ const abort = (): void => {
+ clearTimeout(timer);
+ reject(signal?.reason ?? new DOMException("Aborted", "AbortError"));
+ };
+ const timer = setTimeout(() => {
+ signal?.removeEventListener("abort", abort);
+ resolve();
+ }, ms);
+ signal?.addEventListener("abort", abort, { once: true });
+ });
+}
+
+export async function retry(
+ operation: (attempt: number, signal?: AbortSignal) => Promise,
+ options: RetryOptions = {},
+): Promise {
+ const attempts = options.attempts ?? 3;
+ if (!Number.isInteger(attempts) || attempts < 1) {
+ throw new RangeError("attempts must be a positive integer");
+ }
+
+ let last: unknown;
+ for (let attempt = 1; attempt <= attempts; attempt++) {
+ options.signal?.throwIfAborted();
+ try {
+ return await operation(attempt, options.signal);
+ } catch (error) {
+ last = error;
+ const retryAllowed = options.retryIf ? await options.retryIf(error, attempt) : true;
+ if (attempt >= attempts || !retryAllowed) throw error;
+ const delay = backoffDelay(attempt, options);
+ await options.onRetry?.(error, attempt, delay);
+ await sleep(delay, options.signal);
+ }
+ }
+ throw last;
+}
+
+export async function withTimeout(
+ promise: Promise,
+ timeoutMs: number,
+ message = "Operation timed out",
+ signal?: AbortSignal,
+): Promise {
+ nonNegative(timeoutMs, "timeoutMs");
+ signal?.throwIfAborted();
+
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(new Error(message)), timeoutMs);
+ const abort = (): void => controller.abort(signal?.reason);
+ signal?.addEventListener("abort", abort, { once: true });
+
+ try {
+ return await Promise.race([
+ promise,
+ new Promise((_resolve, reject) => {
+ controller.signal.addEventListener("abort", () => reject(controller.signal.reason), {
+ once: true,
+ });
+ }),
+ ]);
+ } finally {
+ clearTimeout(timeout);
+ signal?.removeEventListener("abort", abort);
+ }
+}
+
+export function stableStringify(value: unknown): string {
+ const ancestors = new Set