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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
### Breaking Changes

- **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)
- **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

### Features
Expand Down
8 changes: 4 additions & 4 deletions docs/api/types.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

## Functions

### `classify(signals: RawSignals): DeviceTiers`
### `classify(signals: StoredSignals): DeviceTiers`

Classifies raw device signals into capability tiers.
Classifies device signals into capability tiers.

```typescript
import { classify } from '@device-router/types';
Expand All @@ -17,7 +17,7 @@ const tiers = classify({
// { cpu: 'high', memory: 'high', connection: 'high', gpu: 'none' }
```

### `deriveHints(tiers: DeviceTiers, signals?: RawSignals): RenderingHints`
### `deriveHints(tiers: DeviceTiers, signals?: StoredSignals): RenderingHints`

Derives rendering hints from device tiers.

Expand Down Expand Up @@ -224,7 +224,7 @@ type DeviceRouterEvent =
source: ProfileSource;
durationMs: number;
}
| { type: 'profile:store'; sessionToken: string; signals: RawSignals; durationMs: number }
| { type: 'profile:store'; sessionToken: string; signals: StoredSignals; durationMs: number }
| { type: 'bot:reject'; sessionToken: string; signals: RawSignals }
| { type: 'error'; error: unknown; phase: 'middleware' | 'endpoint'; sessionToken?: string };
```
Expand Down
25 changes: 25 additions & 0 deletions packages/middleware-express/src/__tests__/endpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,31 @@ describe('createProbeEndpoint', () => {
expect(typeof event.durationMs).toBe('number');
});

it('strips userAgent and viewport from profile:store signals', async () => {
const events: DeviceRouterEvent[] = [];
const onEvent = vi.fn((e: DeviceRouterEvent) => {
events.push(e);
});

const handler = createProbeEndpoint({ storage, onEvent });
const req = createMockReq({
hardwareConcurrency: 4,
userAgent: 'Mozilla/5.0 Test',
viewport: { width: 1920, height: 1080 },
});
const res = createMockRes();

await handler(req, res);

const event = events.find((e) => e.type === 'profile:store') as Extract<
DeviceRouterEvent,
{ type: 'profile:store' }
>;
expect(event.signals).toEqual({ hardwareConcurrency: 4 });
expect(event.signals).not.toHaveProperty('userAgent');
expect(event.signals).not.toHaveProperty('viewport');
});

it('emits bot:reject when bot detected', async () => {
const events: DeviceRouterEvent[] = [];
const onEvent = vi.fn((e: DeviceRouterEvent) => {
Expand Down
7 changes: 6 additions & 1 deletion packages/middleware-express/src/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,12 @@ export function createProbeEndpoint(options: EndpointOptions) {
await storage.set(sessionToken, profile, ttl);
const durationMs = performance.now() - start;

emitEvent(onEvent, { type: 'profile:store', sessionToken, signals, durationMs });
emitEvent(onEvent, {
type: 'profile:store',
sessionToken,
signals: storedSignals,
durationMs,
});

res.cookie(cookieName, sessionToken, {
path: cookiePath,
Expand Down
21 changes: 21 additions & 0 deletions packages/middleware-fastify/src/__tests__/endpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,27 @@ describe('createProbeEndpoint', () => {
);
});

it('strips userAgent and viewport from profile:store signals', async () => {
const onEvent = vi.fn();
const handler = createProbeEndpoint({ storage, onEvent });
const req = createMockReq({
hardwareConcurrency: 4,
userAgent: 'Mozilla/5.0 Test',
viewport: { width: 1920, height: 1080 },
});
const reply = createMockReply();

await handler(req, reply);

const event = onEvent.mock.calls[0][0] as Extract<
DeviceRouterEvent,
{ type: 'profile:store' }
>;
expect(event.signals).toEqual({ hardwareConcurrency: 4 });
expect(event.signals).not.toHaveProperty('userAgent');
expect(event.signals).not.toHaveProperty('viewport');
});

it('emits bot:reject when bot detected', async () => {
const onEvent = vi.fn();
const handler = createProbeEndpoint({ storage, onEvent });
Expand Down
7 changes: 6 additions & 1 deletion packages/middleware-fastify/src/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,12 @@ export function createProbeEndpoint(options: EndpointOptions) {
await storage.set(sessionToken, profile, ttl);
const durationMs = performance.now() - start;

emitEvent(onEvent, { type: 'profile:store', sessionToken, signals, durationMs });
emitEvent(onEvent, {
type: 'profile:store',
sessionToken,
signals: storedSignals,
durationMs,
});

reply.setCookie(cookieName, sessionToken, {
path: cookiePath,
Expand Down
25 changes: 25 additions & 0 deletions packages/middleware-hono/src/__tests__/endpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,31 @@ describe('createProbeEndpoint (hono)', () => {
expect(typeof (event as { durationMs: number }).durationMs).toBe('number');
});

it('strips userAgent and viewport from profile:store signals', async () => {
const onEvent = vi.fn();
const app = new Hono();
app.post('/probe', createProbeEndpoint({ storage, onEvent }));

const res = await app.request('/probe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
hardwareConcurrency: 4,
userAgent: 'Mozilla/5.0 Test',
viewport: { width: 1920, height: 1080 },
}),
});

expect(res.status).toBe(200);
const event = onEvent.mock.calls[0][0] as Extract<
DeviceRouterEvent,
{ type: 'profile:store' }
>;
expect(event.signals).toEqual({ hardwareConcurrency: 4 });
expect(event.signals).not.toHaveProperty('userAgent');
expect(event.signals).not.toHaveProperty('viewport');
});

it('emits bot:reject when bot detected', async () => {
const onEvent = vi.fn();
const app = new Hono();
Expand Down
7 changes: 6 additions & 1 deletion packages/middleware-hono/src/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,12 @@ export function createProbeEndpoint(options: EndpointOptions): Handler {
await storage.set(sessionToken, profile, ttl);
const durationMs = performance.now() - start;

emitEvent(onEvent, { type: 'profile:store', sessionToken, signals, durationMs });
emitEvent(onEvent, {
type: 'profile:store',
sessionToken,
signals: storedSignals,
durationMs,
});

setCookie(c, cookieName, sessionToken, {
path: cookiePath,
Expand Down
24 changes: 24 additions & 0 deletions packages/middleware-koa/src/__tests__/endpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,30 @@ describe('createProbeEndpoint (koa)', () => {
expect(typeof event.durationMs).toBe('number');
});

it('strips userAgent and viewport from profile:store signals', async () => {
const events: DeviceRouterEvent[] = [];
const onEvent = vi.fn((e: DeviceRouterEvent) => {
events.push(e);
});

const handler = createProbeEndpoint({ storage, onEvent });
const ctx = createMockCtx({
hardwareConcurrency: 4,
userAgent: 'Mozilla/5.0 Test',
viewport: { width: 1920, height: 1080 },
});

await handler(ctx);

const event = events.find((e) => e.type === 'profile:store') as Extract<
DeviceRouterEvent,
{ type: 'profile:store' }
>;
expect(event.signals).toEqual({ hardwareConcurrency: 4 });
expect(event.signals).not.toHaveProperty('userAgent');
expect(event.signals).not.toHaveProperty('viewport');
});

it('emits bot:reject when bot detected', async () => {
const events: DeviceRouterEvent[] = [];
const onEvent = vi.fn((e: DeviceRouterEvent) => {
Expand Down
7 changes: 6 additions & 1 deletion packages/middleware-koa/src/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,12 @@ export function createProbeEndpoint(options: EndpointOptions) {
await storage.set(sessionToken, profile, ttl);
const durationMs = performance.now() - start;

emitEvent(onEvent, { type: 'profile:store', sessionToken, signals, durationMs });
emitEvent(onEvent, {
type: 'profile:store',
sessionToken,
signals: storedSignals,
durationMs,
});

ctx.cookies.set(cookieName, sessionToken, {
path: cookiePath,
Expand Down
4 changes: 2 additions & 2 deletions packages/types/src/classify.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type {
RawSignals,
StoredSignals,
CpuTier,
MemoryTier,
ConnectionTier,
Expand Down Expand Up @@ -68,7 +68,7 @@ export function classifyGpu(
return 'mid';
}

export function classify(signals: RawSignals, thresholds?: TierThresholds): DeviceTiers {
export function classify(signals: StoredSignals, thresholds?: TierThresholds): DeviceTiers {
return {
cpu: classifyCpu(signals.hardwareConcurrency, thresholds?.cpu),
memory: classifyMemory(signals.deviceMemory, thresholds?.memory),
Expand Down
10 changes: 8 additions & 2 deletions packages/types/src/events.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import type { DeviceTiers, RenderingHints, RawSignals, ProfileSource } from './profile.js';
import type {
DeviceTiers,
RenderingHints,
StoredSignals,
RawSignals,
ProfileSource,
} from './profile.js';

export type DeviceRouterEvent =
| {
Expand All @@ -12,7 +18,7 @@ export type DeviceRouterEvent =
| {
type: 'profile:store';
sessionToken: string;
signals: RawSignals;
signals: StoredSignals;
durationMs: number;
}
| {
Expand Down
4 changes: 2 additions & 2 deletions packages/types/src/hints.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { DeviceTiers, RenderingHints, RawSignals } from './profile.js';
import type { DeviceTiers, RenderingHints, StoredSignals } from './profile.js';

export function deriveHints(tiers: DeviceTiers, signals?: RawSignals): RenderingHints {
export function deriveHints(tiers: DeviceTiers, signals?: StoredSignals): RenderingHints {
const isLowEnd = tiers.cpu === 'low' || tiers.memory === 'low';
const isSlowConnection = tiers.connection === '2g' || tiers.connection === '3g';
const isBatteryConstrained =
Expand Down