Skip to content

Commit 1380e3b

Browse files
authored
Production hardening, deployment docs, and dead signal cleanup (#16)
* Add cookieSecure option and privacy documentation Add cookieSecure option to all four middleware packages, allowing the Secure flag to be set on the session cookie. Defaults to false for local dev compatibility; set to true for HTTPS deployments. Add privacy and cookie consent section to getting-started guide covering GDPR, ePrivacy Directive, UK GDPR, CCPA/CPRA, and Brazil LGPD implications of collecting device capability signals. * Add deployment guide for Docker, Cloudflare Workers, and serverless * Document streaming response limitation for probe auto-injection * Handle Redis connection failures and corrupted JSON gracefully RedisStorageAdapter now catches errors on all methods instead of letting them propagate. get() and exists() return safe defaults (null/false), set() and delete() silently degrade. * Strip userAgent from stored profiles userAgent is collected by the probe for bot/crawler filtering but serves no purpose after that check. All four endpoints now strip it before persisting to storage. JSON schema and docs updated to reflect. * Strip viewport from stored profiles and expand isValidSignals tests Viewport is only used for bot detection (presence check), not classification. Strip it alongside userAgent before persisting. Remove orphaned Viewport definition from JSON schema. Add comprehensive unit tests for isValidSignals covering all validation branches (was only testing battery paths). * Document Redis error handling and add missing changelog entries * Prepare v0.3.0 release Bump all packages to 0.3.0, finalize changelog, and switch publish workflow to extract release notes from CHANGELOG.md via ffurrer2/extract-release-notes@v3.
1 parent 7378fcc commit 1380e3b

39 files changed

Lines changed: 731 additions & 68 deletions

.github/workflows/publish.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ jobs:
4646
env:
4747
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
4848

49+
- name: Extract release notes from changelog
50+
id: release-notes
51+
uses: ffurrer2/extract-release-notes@v3
52+
4953
- uses: softprops/action-gh-release@v2
5054
with:
51-
generate_release_notes: true
55+
body: ${{ steps.release-notes.outputs.release_notes }}

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,24 @@
22

33
## Unreleased
44

5+
## 0.3.0 (2026-02-23)
6+
7+
### Breaking Changes
8+
9+
- **userAgent and viewport stripped from stored profiles**`userAgent` and `viewport` are no longer persisted in device profiles. They are still collected by the probe and used for bot/crawler filtering at the endpoint, but stripped before storage. Stored `RawSignals` no longer contain `userAgent` or `viewport` fields
10+
11+
### Bug Fixes
12+
13+
- **Redis error handling**`RedisStorageAdapter` now catches connection failures and corrupted JSON on all methods. `get()`/`exists()` return safe defaults (`null`/`false`), `set()`/`delete()` silently degrade instead of throwing unhandled errors
14+
15+
### Documentation
16+
17+
- **Deployment guide** — New `docs/deployment.md` covering Docker (Node.js + Redis), Cloudflare Workers (Hono + KV), and serverless platforms (Lambda, Vercel). Includes Dockerfiles, docker-compose, custom StorageAdapter examples for edge runtimes, and production checklists
18+
- **Streaming injection limitation** — Documented that probe auto-injection requires a string response body; streaming responses are silently skipped (or buffered on Hono). Added framework-specific notes to all package READMEs and getting-started guide
19+
520
### Features
621

22+
- **Secure cookie option** — New `cookieSecure` option on all middleware packages sets the `Secure` flag on the session cookie. Set `cookieSecure: true` for HTTPS deployments
723
- **Threshold validation**`createDeviceRouter()` now validates custom thresholds at startup: rejects inverted bounds (e.g. `lowUpperBound >= midUpperBound`), non-positive values, and non-RegExp GPU patterns. Fails fast with descriptive errors instead of silently producing wrong classifications
824
- **First-request fallback** — Opt-in strategies to provide a classified profile on the very first page load, before the probe has run
925
- **Header-based classification**`classifyFromHeaders: true` classifies devices from User-Agent and Client Hints headers (`Sec-CH-UA-Mobile`, `Device-Memory`, `Save-Data`), sets `Accept-CH` response header to request hints from Chromium browsers

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ app.get('/', (req, res) => {
9797

9898
All signals are optional — the probe gracefully degrades based on what the browser supports.
9999

100+
> **Note:** The probe also collects `navigator.userAgent` and viewport dimensions for [bot/crawler filtering](docs/getting-started.md). They are used during probe submission and stripped before the profile is stored.
101+
100102
## Tier Classification
101103

102104
Devices are classified across three dimensions:
@@ -228,6 +230,7 @@ Open http://localhost:3000 — the probe runs on first load, refresh to see your
228230
## Documentation
229231

230232
- [Getting Started](docs/getting-started.md)
233+
- [Deployment Guide](docs/deployment.md) — Docker, Cloudflare Workers, serverless
231234
- [Profile Schema Reference](docs/profile-schema.md)
232235
- API Reference: [types](docs/api/types.md) | [probe](docs/api/probe.md) | [storage](docs/api/storage.md) | [express](docs/api/middleware-express.md) | [fastify](docs/api/middleware-fastify.md) | [hono](docs/api/middleware-hono.md) | [koa](docs/api/middleware-koa.md)
233236

docs/api/middleware-express.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ app.use(middleware);
2626
| `storage` | `StorageAdapter` | required | Storage backend |
2727
| `cookieName` | `string` | `'dr_session'` | Session cookie name |
2828
| `cookiePath` | `string` | `'/'` | Cookie path |
29+
| `cookieSecure` | `boolean` | `false` | Set `Secure` flag on the session cookie |
2930
| `ttl` | `number` | `86400` | Profile TTL in seconds |
3031
| `rejectBots` | `boolean` | `true` | Reject bot/crawler probe submissions (returns 403) |
3132
| `thresholds` | `TierThresholds` | built-in defaults | Custom tier classification thresholds (validated at startup) |

docs/api/middleware-fastify.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ await app.register(plugin, pluginOptions);
2828
| `storage` | `StorageAdapter` | required | Storage backend |
2929
| `cookieName` | `string` | `'dr_session'` | Session cookie name |
3030
| `cookiePath` | `string` | `'/'` | Cookie path |
31+
| `cookieSecure` | `boolean` | `false` | Set `Secure` flag on the session cookie |
3132
| `ttl` | `number` | `86400` | Profile TTL in seconds |
3233
| `rejectBots` | `boolean` | `true` | Reject bot/crawler probe submissions (returns 403) |
3334
| `thresholds` | `TierThresholds` | built-in defaults | Custom tier classification thresholds (validated at startup) |

docs/api/middleware-hono.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ app.use('*', middleware);
2727
| `storage` | `StorageAdapter` | required | Storage backend |
2828
| `cookieName` | `string` | `'dr_session'` | Session cookie name |
2929
| `cookiePath` | `string` | `'/'` | Cookie path |
30+
| `cookieSecure` | `boolean` | `false` | Set `Secure` flag on the session cookie |
3031
| `ttl` | `number` | `86400` | Profile TTL in seconds |
3132
| `rejectBots` | `boolean` | `true` | Reject bot/crawler probe submissions (returns 403) |
3233
| `thresholds` | `TierThresholds` | built-in defaults | Custom tier classification thresholds (validated at startup) |

docs/api/middleware-koa.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ app.use(middleware);
3434
| `storage` | `StorageAdapter` | required | Storage backend |
3535
| `cookieName` | `string` | `'dr_session'` | Session cookie name |
3636
| `cookiePath` | `string` | `'/'` | Cookie path |
37+
| `cookieSecure` | `boolean` | `false` | Set `Secure` flag on the session cookie |
3738
| `ttl` | `number` | `86400` | Profile TTL in seconds |
3839
| `rejectBots` | `boolean` | `true` | Reject bot/crawler probe submissions (returns 403) |
3940
| `thresholds` | `TierThresholds` | built-in defaults | Custom tier classification thresholds (validated at startup) |

docs/api/storage.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,14 @@ const storage = new RedisStorageAdapter({
4545
| ----------- | ----------------------- | --------------- | -------------------------------------------- |
4646
| `client` | Redis-compatible client | required | Must implement `get`, `set`, `del`, `exists` |
4747
| `keyPrefix` | `string` | `'dr:profile:'` | Key prefix for Redis keys |
48+
49+
### Error handling
50+
51+
All methods catch errors gracefully instead of throwing. If Redis is unavailable or returns corrupted data:
52+
53+
- `get()` returns `null` (treated as no stored profile)
54+
- `exists()` returns `false`
55+
- `set()` silently fails (the probe will re-run next session)
56+
- `delete()` silently fails (the key will expire via TTL)
57+
58+
This ensures middleware continues to work when Redis is temporarily down — requests proceed without a device profile rather than crashing.

docs/deployment.md

Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
# Deployment Guide
2+
3+
This guide covers deploying DeviceRouter in three environments: Docker (Node.js + Redis), Cloudflare Workers (edge), and serverless platforms (Lambda, Vercel, etc.).
4+
5+
## Docker (Node.js + Redis)
6+
7+
A production setup running Express with Redis storage behind a multi-stage Docker build.
8+
9+
### Dockerfile
10+
11+
```dockerfile
12+
# -- Build stage --
13+
FROM node:20-slim AS build
14+
RUN corepack enable
15+
WORKDIR /app
16+
17+
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
18+
COPY packages ./packages
19+
RUN pnpm install --frozen-lockfile
20+
RUN pnpm build
21+
22+
# -- Runtime stage --
23+
FROM node:20-slim
24+
RUN corepack enable
25+
WORKDIR /app
26+
27+
COPY --from=build /app/package.json /app/pnpm-lock.yaml /app/pnpm-workspace.yaml ./
28+
COPY --from=build /app/packages ./packages
29+
COPY --from=build /app/node_modules ./node_modules
30+
COPY server.ts ./
31+
32+
ENV NODE_ENV=production
33+
ENV PORT=3000
34+
EXPOSE 3000
35+
36+
CMD ["node", "--import", "tsx", "server.ts"]
37+
```
38+
39+
### docker-compose.yml
40+
41+
```yaml
42+
services:
43+
app:
44+
build: .
45+
ports:
46+
- '3000:3000'
47+
environment:
48+
PORT: 3000
49+
REDIS_URL: redis://redis:6379
50+
depends_on:
51+
redis:
52+
condition: service_healthy
53+
54+
redis:
55+
image: redis:7-alpine
56+
ports:
57+
- '6379:6379'
58+
volumes:
59+
- redis-data:/data
60+
healthcheck:
61+
test: ['CMD', 'redis-cli', 'ping']
62+
interval: 5s
63+
timeout: 3s
64+
retries: 5
65+
66+
volumes:
67+
redis-data:
68+
```
69+
70+
### Application entry point
71+
72+
```typescript
73+
// server.ts
74+
import express from 'express';
75+
import cookieParser from 'cookie-parser';
76+
import Redis from 'ioredis';
77+
import { createDeviceRouter } from '@device-router/middleware-express';
78+
import { RedisStorageAdapter } from '@device-router/storage';
79+
80+
const redis = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379');
81+
const storage = new RedisStorageAdapter({ client: redis });
82+
83+
const { middleware, probeEndpoint, injectionMiddleware } = createDeviceRouter({
84+
storage,
85+
cookieSecure: true,
86+
injectProbe: true,
87+
});
88+
89+
const app = express();
90+
app.use(cookieParser());
91+
app.use(express.json());
92+
93+
app.post('/device-router/probe', probeEndpoint);
94+
app.use(injectionMiddleware!);
95+
app.use(middleware);
96+
97+
app.get('/', (req, res) => {
98+
const profile = req.deviceProfile;
99+
if (profile?.hints.preferServerRendering) {
100+
return res.send('<html><body>Server-rendered page</body></html>');
101+
}
102+
res.send('<html><body>Full interactive experience</body></html>');
103+
});
104+
105+
const port = parseInt(process.env.PORT ?? '3000', 10);
106+
app.listen(port, () => console.log(`Listening on :${port}`));
107+
```
108+
109+
### Production checklist
110+
111+
- **`cookieSecure: true`** — always enable when serving over HTTPS. The session cookie is never sent over plain HTTP.
112+
- **Redis persistence** — the `redis-data` volume in the compose file persists data across restarts. For production, configure Redis AOF or RDB snapshots.
113+
- **Health checks** — add a `/healthz` endpoint that verifies Redis connectivity.
114+
- **Reverse proxy** — run behind nginx or a load balancer that handles TLS termination. DeviceRouter does not serve HTTPS itself.
115+
- **TTL** — the default session TTL is 24 hours. Lower it if you want profiles to refresh more frequently.
116+
117+
## Cloudflare Workers (Edge)
118+
119+
The Hono middleware is edge-compatible — it uses no Node.js-specific APIs at runtime. However, there are two things to handle differently on Workers: storage and probe injection.
120+
121+
### Storage: Cloudflare KV adapter
122+
123+
Redis is not available on Cloudflare Workers. Implement a `StorageAdapter` backed by Cloudflare KV:
124+
125+
```typescript
126+
// kv-storage.ts
127+
import type { StorageAdapter, DeviceProfile } from '@device-router/types';
128+
129+
export class KVStorageAdapter implements StorageAdapter {
130+
constructor(private kv: KVNamespace) {}
131+
132+
async get(sessionToken: string): Promise<DeviceProfile | null> {
133+
const value = await this.kv.get(sessionToken, 'json');
134+
return value as DeviceProfile | null;
135+
}
136+
137+
async set(sessionToken: string, profile: DeviceProfile, ttlSeconds: number): Promise<void> {
138+
await this.kv.put(sessionToken, JSON.stringify(profile), { expirationTtl: ttlSeconds });
139+
}
140+
141+
async delete(sessionToken: string): Promise<void> {
142+
await this.kv.delete(sessionToken);
143+
}
144+
145+
async exists(sessionToken: string): Promise<boolean> {
146+
const value = await this.kv.get(sessionToken);
147+
return value !== null;
148+
}
149+
}
150+
```
151+
152+
### Worker entry point
153+
154+
```typescript
155+
// src/index.ts
156+
import { Hono } from 'hono';
157+
import { createDeviceRouter } from '@device-router/middleware-hono';
158+
import type { DeviceRouterEnv } from '@device-router/middleware-hono';
159+
import { KVStorageAdapter } from './kv-storage';
160+
161+
type Bindings = { DEVICE_PROFILES: KVNamespace };
162+
type Env = DeviceRouterEnv & { Bindings: Bindings };
163+
164+
const app = new Hono<Env>();
165+
166+
// Initialize per-request because KV binding comes from the request context
167+
app.use('*', async (c, next) => {
168+
const storage = new KVStorageAdapter(c.env.DEVICE_PROFILES);
169+
const { middleware, probeEndpoint } = createDeviceRouter({ storage });
170+
171+
if (c.req.path === '/device-router/probe' && c.req.method === 'POST') {
172+
return probeEndpoint(c, next);
173+
}
174+
175+
return middleware(c, next);
176+
});
177+
178+
app.get('/', (c) => {
179+
const profile = c.get('deviceProfile');
180+
return c.json({ tier: profile?.tiers.cpu ?? null });
181+
});
182+
183+
export default app;
184+
```
185+
186+
### wrangler.toml
187+
188+
```toml
189+
name = "device-router-app"
190+
main = "src/index.ts"
191+
compatibility_date = "2024-01-01"
192+
193+
[[kv_namespaces]]
194+
binding = "DEVICE_PROFILES"
195+
id = "your-kv-namespace-id"
196+
```
197+
198+
Create the KV namespace:
199+
200+
```bash
201+
npx wrangler kv namespace create DEVICE_PROFILES
202+
```
203+
204+
### Probe injection on Workers
205+
206+
The `injectProbe: true` option does **not** work on Cloudflare Workers. It uses `readFileSync` at initialization to load the bundled probe script, which is a Node.js API unavailable on Workers.
207+
208+
Instead, inline the probe script tag in your HTML responses:
209+
210+
```typescript
211+
app.get('/', (c) => {
212+
const profile = c.get('deviceProfile');
213+
return c.html(`
214+
<html>
215+
<head>
216+
<script src="https://unpkg.com/@device-router/probe/dist/device-router-probe.min.js"></script>
217+
</head>
218+
<body>
219+
<p>CPU tier: ${profile?.tiers.cpu ?? 'unknown'}</p>
220+
</body>
221+
</html>
222+
`);
223+
});
224+
```
225+
226+
Or serve the probe script from a static asset and reference it directly.
227+
228+
## Serverless (Lambda, Vercel, etc.)
229+
230+
DeviceRouter works on serverless platforms with one constraint: **use external storage**.
231+
232+
### Why MemoryStorageAdapter won't work
233+
234+
Serverless functions are stateless. Each invocation may run in a fresh container. `MemoryStorageAdapter` stores profiles in process memory, which is lost between invocations (or across concurrent instances). Profiles will never be found on subsequent requests.
235+
236+
### Recommended: Upstash Redis
237+
238+
[Upstash](https://upstash.com) provides HTTP-based Redis that works in any serverless environment — no persistent TCP connections required.
239+
240+
```typescript
241+
import { Redis } from '@upstash/redis';
242+
import { RedisStorageAdapter } from '@device-router/storage';
243+
import IORedis from 'ioredis';
244+
245+
// Option 1: Use Upstash's REST-based client with ioredis compatibility
246+
const redis = new IORedis(process.env.REDIS_URL!);
247+
const storage = new RedisStorageAdapter({ client: redis });
248+
249+
// Then use `storage` in createDeviceRouter as usual
250+
```
251+
252+
If `@device-router/storage`'s `RedisStorageAdapter` expects an `ioredis`-compatible client and your serverless runtime doesn't support TCP connections (like Cloudflare Workers), implement a custom `StorageAdapter` using the Upstash REST client directly:
253+
254+
```typescript
255+
import { Redis } from '@upstash/redis';
256+
import type { StorageAdapter, DeviceProfile } from '@device-router/types';
257+
258+
export class UpstashStorageAdapter implements StorageAdapter {
259+
constructor(private redis: Redis) {}
260+
261+
async get(sessionToken: string): Promise<DeviceProfile | null> {
262+
return this.redis.get<DeviceProfile>(sessionToken);
263+
}
264+
265+
async set(sessionToken: string, profile: DeviceProfile, ttlSeconds: number): Promise<void> {
266+
await this.redis.set(sessionToken, profile, { ex: ttlSeconds });
267+
}
268+
269+
async delete(sessionToken: string): Promise<void> {
270+
await this.redis.del(sessionToken);
271+
}
272+
273+
async exists(sessionToken: string): Promise<boolean> {
274+
const result = await this.redis.exists(sessionToken);
275+
return result === 1;
276+
}
277+
}
278+
```
279+
280+
### Cold starts
281+
282+
Not a concern. DeviceRouter middleware is lightweight — no heavy initialization, no large dependency trees. The probe script is ~1 KB. Classification and hint derivation are pure synchronous functions with no I/O.
283+
284+
### General pattern
285+
286+
Regardless of platform:
287+
288+
1. Use `RedisStorageAdapter` or a custom `StorageAdapter` with external persistence
289+
2. Set `cookieSecure: true` (serverless platforms typically terminate TLS)
290+
3. The middleware, probe endpoint, and classification logic are all stateless — they work identically across invocations
291+
4. If your platform doesn't support `readFileSync` (edge runtimes), skip `injectProbe` and add the probe script tag manually

0 commit comments

Comments
 (0)