Skip to content

Commit 478229e

Browse files
authored
Add 6 additive pre-1.0 features (#34)
* Add 6 additive pre-1.0 features StorageAdapter: - Add clear() to interface, implement on Memory + Redis - Add count(), keys(), has() introspection methods Events: - Add durationMs to bot:reject event for timing visibility - Add errorMessage to error events + extractErrorMessage() helper Rendering hints: - Add limitVideoQuality, useSystemFonts, disablePrefetch Probe: - Add runProbeWithRetry() with exponential backoff + jitter (ESM only, IIFE bundle unchanged at 1022 bytes) * Add .ckb/ to .gitignore * Fix PR #34 review issues: remove has(), add SCAN, fix retry on 5xx - Remove redundant has() from StorageAdapter (pure alias for exists()) - Replace Redis KEYS with SCAN-based iteration, fall back to KEYS - Fix runProbeWithRetry to retry on 5xx (was silently returning) - Extract shared probe internals to _internal.ts to eliminate duplication - Move Hono validationStart before JSON parse for consistency - Add JSDoc to durationMs fields clarifying measurement semantics - Improve extractErrorMessage to handle plain objects with .message - Add tests for 5xx retry, SCAN operations, extractErrorMessage - Update CHANGELOG with has() breaking change and migration guide
1 parent c4e8f87 commit 478229e

41 files changed

Lines changed: 755 additions & 21 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ coverage/
77
*.tsbuildinfo
88
.turbo/
99
.DS_Store
10+
.ckb/
1011
*.log

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,27 @@
1010
- **`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)
1111
- **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
1212
- **Remove `disableAutoplay` rendering hint**`disableAutoplay` triggered on identical conditions to `deferHeavyComponents` (`isLowEnd || isSlowConnection || isBatteryConstrained`). Use `deferHeavyComponents` instead
13+
- **Remove `has()` from `StorageAdapter`**`has()` was a redundant alias for `exists()`. Custom adapters must remove their `has()` implementation
1314
- **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
1415

16+
### Migration Guide
17+
18+
If you have a custom `StorageAdapter` implementation:
19+
20+
- Remove the `has()` method — use `exists()` instead
21+
- Replace any `adapter.has(token)` calls with `adapter.exists(token)`
22+
1523
### Features
1624

1725
- **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
1826
- **`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
27+
- **`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
28+
- **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
29+
- **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
30+
- **`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`
31+
- **`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`
32+
- **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)
33+
- **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)
1934

2035
## 0.4.0 (2026-02-24)
2136

docs/api/types.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ if (isBotSignals(signals)) {
8585
}
8686
```
8787

88+
### `extractErrorMessage(err: unknown): string`
89+
90+
Extracts a string message from an unknown error value. Returns `err.message` for `Error` instances, otherwise `String(err)`.
91+
8892
### `classifyFromHeaders(headers): DeviceTiers`
8993

9094
Classifies device capabilities from HTTP request headers (User-Agent and Client Hints). Useful for first-request classification before the probe has run.
@@ -225,8 +229,14 @@ type DeviceRouterEvent =
225229
durationMs: number;
226230
}
227231
| { type: 'profile:store'; sessionToken: string; signals: StoredSignals; durationMs: number }
228-
| { type: 'bot:reject'; sessionToken: string; signals: RawSignals }
229-
| { type: 'error'; error: unknown; phase: 'middleware' | 'endpoint'; sessionToken?: string };
232+
| { type: 'bot:reject'; sessionToken: string; signals: RawSignals; durationMs: number }
233+
| {
234+
type: 'error';
235+
error: unknown;
236+
errorMessage: string;
237+
phase: 'middleware' | 'endpoint';
238+
sessionToken?: string;
239+
};
230240
```
231241

232242
### `OnEventCallback`

docs/getting-started.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,3 +369,6 @@ Based on device tiers, the middleware provides boolean rendering hints:
369369
- `useImagePlaceholders` — Show placeholders instead of full images
370370
- `preferServerRendering` — Favor SSR over client-side rendering
371371
- `disable3dEffects` — Disable WebGL/3D content (no GPU or software renderer)
372+
- `limitVideoQuality` — Serve lower resolution video, skip HD streams
373+
- `useSystemFonts` — Skip loading custom web fonts
374+
- `disablePrefetch` — Don't speculatively fetch resources

docs/profile-schema.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,6 @@ The full classified result attached to requests by the middleware.
7676
| `useImagePlaceholders` | `boolean` | Slow connection (2g/3g) |
7777
| `preferServerRendering` | `boolean` | Low-end device |
7878
| `disable3dEffects` | `boolean` | No GPU or software renderer |
79+
| `limitVideoQuality` | `boolean` | Slow connection or low battery |
80+
| `useSystemFonts` | `boolean` | Low-end device or slow connection |
81+
| `disablePrefetch` | `boolean` | Slow connection or low battery |

examples/shared/demo-template.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ export function renderDemoPage({
2020
useImagePlaceholders: true,
2121
preferServerRendering: true,
2222
disable3dEffects: true,
23+
limitVideoQuality: true,
24+
useSystemFonts: true,
25+
disablePrefetch: true,
2326
}
2427
: forceParam === 'full'
2528
? {
@@ -29,6 +32,9 @@ export function renderDemoPage({
2932
useImagePlaceholders: false,
3033
preferServerRendering: false,
3134
disable3dEffects: false,
35+
limitVideoQuality: false,
36+
useSystemFonts: false,
37+
disablePrefetch: false,
3238
}
3339
: profile?.hints;
3440

packages/middleware-express/src/__tests__/endpoint.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ function createMockStorage(): StorageAdapter {
99
set: vi.fn().mockResolvedValue(undefined),
1010
delete: vi.fn().mockResolvedValue(undefined),
1111
exists: vi.fn().mockResolvedValue(false),
12+
clear: vi.fn().mockResolvedValue(undefined),
13+
count: vi.fn().mockResolvedValue(0),
14+
keys: vi.fn().mockResolvedValue([]),
1215
};
1316
}
1417

@@ -302,6 +305,7 @@ describe('createProbeEndpoint', () => {
302305
const event = botEvents[0] as Extract<DeviceRouterEvent, { type: 'bot:reject' }>;
303306
expect(typeof event.sessionToken).toBe('string');
304307
expect(event.signals).toEqual({});
308+
expect(typeof event.durationMs).toBe('number');
305309
});
306310

307311
it('emits error event on storage failure', async () => {
@@ -323,6 +327,7 @@ describe('createProbeEndpoint', () => {
323327
expect(event.phase).toBe('endpoint');
324328
expect(event.error).toBeInstanceOf(Error);
325329
expect((event.error as Error).message).toBe('Redis down');
330+
expect(event.errorMessage).toBe('Redis down');
326331
expect(res.status).toHaveBeenCalledWith(500);
327332
});
328333

packages/middleware-express/src/__tests__/middleware.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ function createMockStorage(): StorageAdapter {
1414
store.delete(token);
1515
}),
1616
exists: vi.fn(async (token: string) => store.has(token)),
17+
clear: vi.fn(async () => store.clear()),
18+
count: vi.fn(async () => store.size),
19+
keys: vi.fn(async () => [...store.keys()]),
1720
_store: store,
1821
} as StorageAdapter & { _store: Map<string, DeviceProfile> };
1922
}
@@ -395,6 +398,7 @@ describe('createMiddleware', () => {
395398
expect(event.phase).toBe('middleware');
396399
expect(event.error).toBeInstanceOf(Error);
397400
expect((event.error as Error).message).toBe('Storage down');
401+
expect(event.errorMessage).toBe('Storage down');
398402
expect(next).toHaveBeenCalledWith(expect.any(Error));
399403
});
400404

packages/middleware-express/src/endpoint.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Request, Response } from 'express';
22
import type { StorageAdapter } from '@device-router/storage';
33
import type { DeviceProfile, RawSignals, OnEventCallback } from '@device-router/types';
4-
import { isValidSignals, isBotSignals, emitEvent } from '@device-router/types';
4+
import { isValidSignals, isBotSignals, emitEvent, extractErrorMessage } from '@device-router/types';
55
import { randomUUID } from 'node:crypto';
66

77
export interface EndpointOptions {
@@ -29,6 +29,7 @@ export function createProbeEndpoint(options: EndpointOptions) {
2929
let sessionToken: string | undefined;
3030
try {
3131
const signals = req.body as unknown;
32+
const validationStart = performance.now();
3233

3334
if (!isValidSignals(signals)) {
3435
res.status(400).json({ ok: false, error: 'Invalid probe payload' });
@@ -39,7 +40,8 @@ export function createProbeEndpoint(options: EndpointOptions) {
3940
sessionToken = existingToken || randomUUID();
4041

4142
if (rejectBots && isBotSignals(signals)) {
42-
emitEvent(onEvent, { type: 'bot:reject', sessionToken, signals });
43+
const durationMs = performance.now() - validationStart;
44+
emitEvent(onEvent, { type: 'bot:reject', sessionToken, signals, durationMs });
4345
res.status(403).json({ ok: false, error: 'Bot detected' });
4446
return;
4547
}
@@ -83,6 +85,7 @@ export function createProbeEndpoint(options: EndpointOptions) {
8385
emitEvent(onEvent, {
8486
type: 'error',
8587
error: err,
88+
errorMessage: extractErrorMessage(err),
8689
phase: 'endpoint',
8790
sessionToken,
8891
});

packages/middleware-express/src/middleware.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
classifyFromHeaders,
77
resolveFallback,
88
emitEvent,
9+
extractErrorMessage,
910
validateThresholds,
1011
} from '@device-router/types';
1112
import type {
@@ -111,6 +112,7 @@ export function createMiddleware(options: MiddlewareOptions) {
111112
emitEvent(onEvent, {
112113
type: 'error',
113114
error: err,
115+
errorMessage: extractErrorMessage(err),
114116
phase: 'middleware',
115117
sessionToken: req.cookies?.[cookieName] as string | undefined,
116118
});

0 commit comments

Comments
 (0)