Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ coverage/
*.tsbuildinfo
.turbo/
.DS_Store
.ckb/
*.log
34 changes: 32 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down Expand Up @@ -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`

Expand Down
28 changes: 24 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 |

Expand Down Expand Up @@ -157,6 +156,27 @@ const { middleware, probeEndpoint, injectionMiddleware } = createDeviceRouter({

No need to manually add `<script>` tags — the probe is injected before `</head>` in every HTML response.

## Composable Middleware

Don't need the full factory? Use the individual pieces directly:

```typescript
import {
createMiddleware,
createProbeEndpoint,
createInjectionMiddleware,
loadProbeScript,
} from '@device-router/middleware-express';

const middleware = createMiddleware({ storage, thresholds });
const endpoint = createProbeEndpoint({ storage, ttl: 3600 });
const injection = createInjectionMiddleware({
probeScript: loadProbeScript(),
});
```

Each piece validates its own options at creation time. See the [API docs](docs/api/middleware-express.md#standalone-functions) for full option tables.

## First-Request Handling

By default, `deviceProfile` is `null` on the first page load before the probe runs. Two opt-in strategies provide immediate classification:
Expand Down Expand Up @@ -224,8 +244,8 @@ new RedisStorageAdapter(redisClient, { prefix: 'dr:profile:' });

Each framework has an example app that renders a product landing page adapting in real time to device capabilities:

- **Full experience** (high-end device) — animated gradient hero, SVG icons, inline charts, pulsing CTA, hover transitions, autoplay visualizer
- **Lite experience** (low-end device) — flat solid backgrounds, Unicode icons, placeholder boxes, no animations, autoplay disabled
- **Full experience** (high-end device) — animated gradient hero, SVG icons, inline charts, pulsing CTA, hover transitions
- **Lite experience** (low-end device) — flat solid backgrounds, Unicode icons, placeholder boxes, no animations

Run any example to see it in action:

Expand Down
Loading