diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07b151f..3e4faed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,3 +69,42 @@ jobs: size=$(stat -c%s packages/probe/dist/device-router-probe.min.js.gz 2>/dev/null || stat -f%z packages/probe/dist/device-router-probe.min.js.gz) echo "Probe gzipped size: ${size} bytes" [ "$size" -le 1024 ] || (echo "Probe exceeds 1 KB limit" && exit 1) + + package-lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm build + + - name: publint + run: pnpm lint:publint + + - name: attw + run: pnpm lint:attw + + e2e: + runs-on: ubuntu-latest + needs: [test] + steps: + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm test:e2e diff --git a/.gitignore b/.gitignore index fb8e427..485e666 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ coverage/ *.tsbuildinfo .turbo/ .DS_Store +.ckb/ *.log diff --git a/CHANGELOG.md b/CHANGELOG.md index 479bccf..8b4b1c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,36 @@ # Changelog -## Unreleased +## 1.0.0 (2026-03-04) + +### Breaking Changes + +- **Normalize `ConnectionThresholds` field names** — Renamed `downlink2gUpperBound` → `lowUpperBound`, `downlink3gUpperBound` → `midUpperBound`, `downlink4gUpperBound` → `highUpperBound` to match the `CpuThresholds`/`MemoryThresholds` naming pattern. Update any custom `connection` threshold objects in your `thresholds` config +- **Rename `ConnectionTier` value `'fast'` → `'high'`** — Aligns connection tier vocabulary with CPU, memory, and GPU tiers which all use `'low' | 'mid' | 'high'`. Update any code comparing `tiers.connection === 'fast'` to use `'high'` instead +- **`classify()` and `deriveHints()` now accept `StoredSignals` instead of `RawSignals`** — These functions never used `userAgent` or `viewport` (which are stripped before storage). The narrower type makes the API honest. Existing call sites are unaffected — `RawSignals` is structurally assignable to `StoredSignals` +- **`profile:store` event now carries `StoredSignals` instead of `RawSignals`** — The event previously emitted the raw probe payload (including `userAgent`/`viewport`), not what was actually stored. The `signals` field now matches the persisted data. `bot:reject` still carries `RawSignals` (it fires before stripping) +- **Rename default cookie `dr_session` → `device-router-session`** — Self-documenting name before 1.0 locks the cookie in. If you hardcode `cookieName: 'dr_session'` in your options you are unaffected; if you rely on the default, existing sessions will reset once on deploy +- **Remove `disableAutoplay` rendering hint** — `disableAutoplay` triggered on identical conditions to `deferHeavyComponents` (`isLowEnd || isSlowConnection || isBatteryConstrained`). Use `deferHeavyComponents` instead +- **Remove `has()` from `StorageAdapter`** — `has()` was a redundant alias for `exists()`. Custom adapters must remove their `has()` implementation +- **middleware-fastify: normalized return shape** — `createDeviceRouter()` now returns raw Fastify hooks instead of a `fastify-plugin` wrapped plugin. Migrate `await app.register(middleware)` → `app.addHook('preHandler', middleware)`. When using `injectProbe: true`, register the injection hook separately: `app.addHook('onSend', injectionMiddleware)`. Removed `fastify-plugin` dependency + +### Migration Guide + +If you have a custom `StorageAdapter` implementation: + +- Remove the `has()` method — use `exists()` instead +- Replace any `adapter.has(token)` calls with `adapter.exists(token)` + +### Features + +- **Composable middleware** — `createMiddleware()`, `createProbeEndpoint()`, and `createInjectionMiddleware()` are now first-class exports with full threshold validation and documentation. Use them independently for fine-grained control without the `createDeviceRouter()` factory +- **`loadProbeScript()` utility** — New helper exported from all middleware packages that reads the minified probe bundle and optionally rewrites the endpoint URL. Pairs with `createInjectionMiddleware()` for standalone probe injection +- **`clear()` on StorageAdapter** — `clear()` is now part of the `StorageAdapter` interface. `MemoryStorageAdapter` and `RedisStorageAdapter` both implement it. Redis implementation uses SCAN (when available) or KEYS + `DEL` with graceful error handling +- **StorageAdapter introspection** — New `count()` and `keys()` methods on `StorageAdapter` for inspecting stored profiles. `keys()` returns session tokens (not prefixed keys). All methods handle errors gracefully on Redis +- **Redis SCAN support** — `RedisStorageAdapter` uses the optional `scan()` method on the client for `clear()`, `count()`, and `keys()` operations, avoiding the blocking `KEYS` command. Falls back to `KEYS` when `scan` is not available +- **`durationMs` on `bot:reject` event** — The `bot:reject` event now includes `durationMs` measuring validation and bot detection time, matching the pattern used by `profile:store` +- **`errorMessage` on error events** — Error events now include a pre-extracted `errorMessage: string` field, avoiding the need to narrow the `error: unknown` field. New `extractErrorMessage()` helper exported from `@device-router/types` +- **New rendering hints** — Three new hints: `limitVideoQuality` (slow connection or low battery), `useSystemFonts` (low-end device or slow connection), `disablePrefetch` (slow connection or low battery) +- **Probe retry logic** — New `runProbeWithRetry()` function with exponential backoff and jitter. Exported from `@device-router/probe` as an ESM-only export (does not affect the IIFE bundle size) ## 0.4.0 (2026-02-24) @@ -47,7 +77,7 @@ ### Features - **GPU detection** — Classify GPU tier from WebGL renderer string: software renderers → `low`, RTX/RX 5000+/Apple M-series → `high`, everything else → `mid` -- **Battery API signal** — Collect battery level and charging status via `navigator.getBattery()` (Chromium-only, silently skipped elsewhere). When unplugged and below 15%, `deferHeavyComponents`, `reduceAnimations`, and `disableAutoplay` are forced on +- **Battery API signal** — Collect battery level and charging status via `navigator.getBattery()` (Chromium-only, silently skipped elsewhere). When unplugged and below 15%, `deferHeavyComponents` and `reduceAnimations` are forced on - **Signal validation** — New `isValidSignals()` type guard for validating incoming probe payloads - **Custom GPU thresholds** — `softwarePattern` and `highEndPattern` are configurable via `GpuThresholds` diff --git a/README.md b/README.md index d926db9..ae8ac83 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ A **~1 KB** client probe. One middleware call. Full device awareness on every re Responsive design adapts layout. DeviceRouter adapts **what you serve**. - A budget phone on 2G? Skip the heavy animations, defer non-critical components, prefer server-side rendering. -- A flagship on fiber? Go all out — autoplay, full interactivity, rich visuals. +- A flagship on fiber? Go all out — full interactivity, rich visuals. No user-agent sniffing. No guesswork. Real signals from real devices, classified into actionable tiers and rendering hints your server can act on immediately. @@ -120,7 +120,6 @@ Based on tiers and user preferences, DeviceRouter derives actionable booleans: | `serveMinimalCSS` | Low-end device | | `reduceAnimations` | Low-end device, prefers reduced motion, or low battery | | `useImagePlaceholders` | Slow connection (2G/3G) | -| `disableAutoplay` | Low-end device, slow connection, or low battery | | `preferServerRendering` | Low-end device | | `disable3dEffects` | No GPU or software renderer | @@ -157,6 +156,27 @@ const { middleware, probeEndpoint, injectionMiddleware } = createDeviceRouter({ No need to manually add `