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