diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..315d1e0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,48 @@ +# Session Memory + +**IMPORTANT: Keep this file actively updated with conversation summaries, decisions, and progress. Jonathan +values having records of our discussions and the evolution of ideas.** + +## Style Guide Reference + +See @docs/style-guide.org for coding patterns, conventions, and architectural guidance. + +## Recent Work - Error Handling Refactor (COMPLETED ✅) + +### Summary + +Successfully refactored from multiple AbortControllers to single connection-level controller with clean error +hierarchy and proper timeout handling. + +### Key Achievements + +1. **Clean Error Hierarchy** - HTTP status code-based system with proper differentiation +2. **Single AbortController Architecture** - Connection-level controller passes signals down +3. **Robust Timeout Handling** - Custom `timeoutSignal()` with proper cleanup +4. **Signal Composition** - Custom `combineSignals()` preserves abort reasons + +### Working Error Scenarios + +- ✅ 3-second timeout → `408 Request Timeout: operation timed out` +- ✅ Socket disconnect → `499 Client Closed Request: operation was aborted` +- ✅ Invalid JSON → `400 Bad Request: validation failed` +- ✅ No spurious errors when operations complete before socket close + +### Files Modified + +- `src/common/errors.ts` - HTTP error hierarchy with status codes +- `src/common/aborts.ts` - `timeoutSignal()` and `combineSignals()` utilities +- `src/common/socket.ts` - Signal-based `takeSocket()` with reason preservation +- `src/server/socket/handler.ts` - Single controller pattern with proper error handling + +### Key Technical Insights + +- `AbortSignal.timeout()` incorrectly throws `AbortError` instead of `TimeoutError` in Deno/browsers +- `AbortSignal.any()` doesn't preserve individual signal reasons - need custom `combineSignals()` +- `takeSocket()` must reject with `signal.reason` (not create new error) to preserve error types +- Timeout cleanup critical when using `AbortSignal.any()` to prevent lingering timers + +## Current State + +Project has clean, unified signal-passing architecture with proper error differentiation and timeout handling. +Ready for next features. diff --git a/deno.json b/deno.json index bd199f0..023d34e 100644 --- a/deno.json +++ b/deno.json @@ -37,6 +37,7 @@ "react": "npm:react@^19.1.0", "react-dom": "npm:react-dom@^19.1.0", "react-router-dom": "npm:react-router-dom@^7.5.1", + "x/indexeddb": "https://deno.land/x/indexeddb@v1.1.0/ponyfill.ts", "vite": "npm:vite@^6.3.2", "zod": "npm:zod@3", "zustand": "npm:zustand@^5.0.5", diff --git a/deno.lock b/deno.lock index 057d788..f4bfec3 100644 --- a/deno.lock +++ b/deno.lock @@ -1663,6 +1663,30 @@ ] } }, + "remote": { + "https://cdn.skypack.dev/-/indexeddbshim@v9.0.0-QVaW8rBIOGlJegwkWTsK/dist=es2019,mode=imports/unoptimized/dist/indexeddbshim-noninvasive.js": "036e9191db93ef296b84a8a59192d4851f231e3b76a638543491cf81fe08b666", + "https://cdn.skypack.dev/-/regenerator-runtime@v0.13.9-4Dxus9nU31cBsHxnWq2H/dist=es2019,mode=imports/optimized/regenerator-runtime.js": "9d5c28ca64c45a9e606d5f3b35aec61568ed451495968171c8cc3ef1923cb7fe", + "https://cdn.skypack.dev/indexeddbshim@v9.0.0/dist/indexeddbshim-noninvasive.js": "8825b3ce26bfea3ddfb612fd95ae295c78542423511bc4042c3d2e2a89894c08", + "https://cdn.skypack.dev/regenerator-runtime@0.13.9": "adf7d286d4d4dd28347f3b0bc62fc614a7da3c76ad15e8ea4e4b2511d9632c9e", + "https://deno.land/x/indexeddb@v1.1.0/lib/indexeddb.ts": "6d8eeec0c4074ed2e3ed5ae3c99e3b66703f772c791279d052680fe3b334de2d", + "https://deno.land/x/indexeddb@v1.1.0/lib/shim.ts": "1cc9b0961a2ddb9836643e1fc661cf44eb55a777cd7625cba4256d259920d51b", + "https://deno.land/x/indexeddb@v1.1.0/ponyfill.ts": "007573a164bbae362f0ab8468081b0d8932112bbf57ca15c11bdffdec7da8481", + "https://deno.land/x/sqlite@v3.2.1/build/sqlite.js": "087db9bdacd1e5bb8b538ba328e5105571cc6d716deb9923e2eb5c8af763991a", + "https://deno.land/x/sqlite@v3.2.1/build/vfs.js": "baff72655c0916c906327fe6703c6a47daa1346e55c2eaa2629bcd879a673c8d", + "https://deno.land/x/sqlite@v3.2.1/mod.ts": "eb55ff3e103826ee735fa1a8ec2db6e96c8fec826faf198239c66a0fe635c4b6", + "https://deno.land/x/sqlite@v3.2.1/src/constants.ts": "b967237b460fafa03ca03aeddb33953bed41c7cbdc309a934e2329a5c84079ac", + "https://deno.land/x/sqlite@v3.2.1/src/db.ts": "f778a82f638c2e33c832c8d0e60144d11d0118d40f6ffb397cfa059c2b8e0c36", + "https://deno.land/x/sqlite@v3.2.1/src/error.ts": "abf9df169951faf8c21a68d99ef9efd257c788c19784f27dd147507fbdfe5fe7", + "https://deno.land/x/sqlite@v3.2.1/src/query.ts": "df4d3d6b23d52b36243dd47c07ed6ed7cd23f7834a0f6a2f2c6dbffba63d4c28", + "https://deno.land/x/sqlite@v3.2.1/src/wasm.ts": "e79d0baa6e42423257fb3c7cc98091c54399254867e0f34a09b5bdef37bd9487", + "https://deno.land/x/websql@v1.1.0/deps.ts": "f41d5d5dd7ebe4762b9536e75854fa03d4af9064ea921f4c6c4ca0ffad111a06", + "https://deno.land/x/websql@v1.1.0/mod.ts": "c0b9f657ac9d148a7e47bb60b8bb9c62cc8f0b42646bb33d023b91deb5a8dd20", + "https://deno.land/x/websql@v1.1.0/src/Database.ts": "8134b0d4c7409f333f7111e03b42f2b90146c033e0599aa6732ca6221af01e62", + "https://deno.land/x/websql@v1.1.0/src/SQLResultSet.ts": "fa0729dc94735771f2c1389d4a4e89b370b358b731452067ea4792010e213655", + "https://deno.land/x/websql@v1.1.0/src/SQLResultSetRowList.ts": "0d78f210e73217b9abcbba6fa6101751993e363975b117d06ddd7c18297241f3", + "https://deno.land/x/websql@v1.1.0/src/SQLTransaction.ts": "dae5f017242b626041d470578df300fbb58d7bf3f18082d6050b9fef20c54607", + "https://deno.land/x/websql@v1.1.0/src/SQLiteDBCache.ts": "919add0ea961f8bb50c8158f8674b08c3a14b247eab77a0908bc53ab5abee818" + }, "workspace": { "dependencies": [ "jsr:@oak/oak@^17.1.4", diff --git a/docs/blog-ideas.org b/docs/blog-ideas.org new file mode 100644 index 0000000..de06da9 --- /dev/null +++ b/docs/blog-ideas.org @@ -0,0 +1,176 @@ +* Blog Post Ideas + +** Building Zero-Knowledge Client Storage with Web Crypto API +- Implement installation-bound encryption using only browser APIs - no crypto libraries needed +- Use PBKDF2 with a clever three-part salt system (password + prefix + nonce) for enhanced security +- Store encrypted data in Zustand with automatic encryption/decryption on state changes +- Derive encryption keys from location.origin to prevent data portability between domains +- Show how AES-GCM provides both encryption and authentication in one operation + +** Mastering AbortSignal: Fixing What Browsers Got Wrong +- Fix the browser's broken AbortSignal.timeout() that throws AbortError instead of TimeoutError +- Build combineSignals() to preserve individual abort reasons (unlike native AbortSignal.any()) +- Implement connection-level abort controllers that automatically cascade to all operations +- Prevent memory leaks by properly cleaning up timeout handles in signal compositions +- Show real-world WebSocket patterns with proper cancellation and timeout handling + +** Go-Style Concurrency Primitives in TypeScript: BlockingAtom, Gates, and Semaphores +- Build a BlockingAtom that blocks async reads until a value is set (like Go channels) +- Implement Gates that ensure callbacks fire exactly once or until a specific condition +- Create counting Semaphores with AbortSignal support for resource limiting +- Show how these primitives elegantly solve WebSocket message coordination problems +- Demonstrate backpressure handling with BlockingQueue for streaming data + +** Beyond String Types: Production-Ready Branded IDs in TypeScript +- Prevent ID mix-ups at compile time using Zod's .brand() with unique symbols +- Build a reusable factory that generates type-safe IDs with nanoid +- Combine runtime validation (regex patterns) with compile-time type safety +- Create elegant type utilities like inferBrandedId for clean API design +- Show real benefits in preventing RealmId/ClientId confusion in a multi-tenant system + +** Building Production WebSocket Streams with Backpressure +- Why backpressure matters in real-time systems and how to implement it +- Generator-based streaming API with pause/resume thresholds +- Queue-based message buffering with configurable size limits +- Proper cleanup and error propagation in async generators +- Real-world patterns for handling slow consumers + +** JWT Authentication with Zero Dependencies +- Implement JWT signing/verification using only Web Crypto API +- Build type-safe JWK handling with Zod async transforms +- Why ECDSA with P-256 beats RSA for modern applications +- Self-contained authentication with public keys embedded in JWTs +- Clock tolerance patterns for handling drift between systems + +** Small Patterns, Big Impact: Building a StrictMap +- Why TypeScript's Map typing isn't enough for production code +- Three methods that eliminate defensive coding: require(), ensure(), update() +- How small utility classes dramatically improve code quality +- Real usage in WebSocket connection management +- When to extend native types vs. wrapping them + +** Designing Errors for APIs and WebSockets +- Why HTTP status codes work perfectly for WebSocket errors +- Building a type-safe error hierarchy with automatic status text +- Handling Zod validation errors gracefully in real-time systems +- Creating user-friendly error messages that developers will thank you for +- Unified error handling across REST and WebSocket endpoints + +** Browser-Bound Encryption: Protecting Data Per Installation +- Create encryption keys unique to each browser installation +- Use PBKDF2 with location.origin and local nonce for deterministic key generation +- Implement stable keys that survive sessions but not browser reinstalls +- Balance security with user experience in offline-first applications +- Handle key rotation and migration strategies + +** Lazy Crypto System Initialization for Better Performance +- Why you shouldn't derive encryption keys at module load time +- Implement cached lazy initialization with proper error handling +- Use closures to hide implementation details while exposing clean APIs +- Measure the performance impact of early vs. lazy initialization +- Pattern for optional encryption in development environments + +** Self-Contained Encrypted Strings with AES-GCM +- Concatenate IVs with ciphertext for self-describing encrypted values +- Why AES-GCM eliminates the need for separate HMAC signatures +- Build type-safe encryption/decryption with proper IV generation +- Handle base64 encoding for storage-friendly encrypted strings +- Implement versioning for future algorithm changes + +** Type-Safe JWK Handling with Zod Transforms +- Use Zod's async transforms to validate and import JWKs in one step +- Build compile-time guarantees for cryptographic operations +- Handle both public and private key validation elegantly +- Create reusable schemas for different key types and algorithms +- Show real-world usage in WebSocket authentication + +** Fingerprinting Crypto Keys for Distributed Systems +- Generate stable identifiers for public keys using SPKI export +- Build cross-platform key comparison without key material exposure +- Use SHA-256 for consistent fingerprints across environments +- Implement trust-on-first-use patterns with key pinning +- Handle key rotation in multi-device scenarios + +** Zustand Persistence with Custom Serialization +- Wrap Zustand's storage engine for complex transformations +- Rebuild CryptoKeyPair objects from JWK on hydration +- Handle version migrations with deep merge strategies +- Implement encryption toggle for development vs. production +- Build backwards-compatible storage formats + +** Gradual Migration to Encrypted Storage +- Prefix encrypted values to differentiate from legacy data +- Implement transparent encryption for new data while reading old data +- Build migration strategies that don't break existing users +- Handle storage quota limits with selective encryption +- Create audit trails for encryption status + +** Advanced Semaphore Patterns with Cancellation +- Track pending resolvers and clean up on abort +- Prevent memory leaks in long-running applications +- Implement fair vs. unfair semaphore acquisition +- Build timeout support on top of AbortSignal +- Show real usage in rate limiting and resource pools + +** BlockingQueue: Async Queues with Backpressure +- Implement size limits to prevent unbounded memory growth +- Build enqueue/dequeue with AbortSignal support +- Handle queue overflow with different strategies +- Create priority queues on top of blocking primitives +- Show usage in producer-consumer patterns + +** The Gate Pattern: Elegant Event Handler State Machines +- Ensure callbacks fire exactly once or until a condition +- Prevent common bugs with duplicate event handlers +- Build composable gates for complex event flows +- Implement automatic cleanup on gate closure +- Real-world usage in WebSocket handshakes + +** Connection-Level Resource Management +- Create single AbortController per connection lifecycle +- Propagate cancellation through entire operation trees +- Handle graceful vs. abrupt disconnections differently +- Implement resource cleanup in finally blocks +- Patterns for testing cancellation behavior + +** Multi-Tenant WebSockets with Realm Isolation +- Build isolated namespaces without separate servers +- Implement per-realm socket and identity tracking +- Handle cross-realm security boundaries +- Scale to thousands of realms on single server +- Pattern for realm-specific business logic + +** WebSocket Presence with Multi-Device Support +- Track socket arrays per user identity +- Handle device attach/detach gracefully +- Build presence indicators that handle reconnects +- Implement "last seen" tracking for offline devices +- Create device-specific message routing + +** Self-Contained JWT Authentication for WebSockets +- Embed public keys in JWTs for serverless verification +- Build realm invitation system with dual JWT pattern +- Handle ephemeral keys for secure invitation exchange +- Implement expiration and one-time use tokens +- Create audit logs for security events + +** Type-Safe Message Routing with Discriminated Unions +- Use Zod discriminated unions for compile-time routing +- Build exhaustive message handlers with TypeScript +- Generate client types from server schemas +- Handle unknown message types gracefully +- Create message versioning strategies + +** Handling Clock Drift in Distributed Systems +- Add tolerance to JWT timestamp validation +- Build monotonic clock abstractions +- Handle client/server time synchronization +- Implement vector clocks for causality +- Test with artificial clock skew + +** Real-World Vite Configuration for Full-Stack Development +- Configure Vite proxy for seamless API and WebSocket development +- Handle CORS issues between frontend and backend servers +- Set up hot module replacement that works with WebSocket connections +- Build production-ready configurations with environment variables +- Create development workflows that mirror production architecture \ No newline at end of file diff --git a/src/client/storage-indexdb.ts b/src/client/storage-indexdb.ts new file mode 100644 index 0000000..ef57b20 --- /dev/null +++ b/src/client/storage-indexdb.ts @@ -0,0 +1,74 @@ +import { indexedDB } from "x/indexeddb"; +import { IDBDatabase } from "x/indexeddb"; +import type { PersistStorage, StorageValue } from "zustand/middleware"; + +export function makeIndexedDBStorage(dbname: string, version = 1): PersistStorage { + let idb: IDBDatabase | undefined; + + const ensure = () => { + if (idb) return idb; + + const request = indexedDB.open(dbname, version); + const { resolve, reject, promise } = Promise.withResolvers(); + + request.onsuccess = (event) => { + console.log("on success", event, event.target); + resolve(request.result); + }; + + request.onerror = (event) => { + console.log("on error", event, event.target); + reject(request.error); + }; + + request.onblocked = (event) => { + console.log("on blocked", event, event.target); + reject(request.error); + }; + + request.onupgradeneeded = (event) => { + console.log("on ugrade needed", event, event.target); + reject(request.error); + }; + + return promise; + }; + + return { + async getItem(key) { + const db = await ensure(); + const { resolve, reject, promise } = Promise.withResolvers>(); + + const tx = db.transaction(["state"], "readonly"); + const request = tx.objectStore("state").get(key); + request.onsuccess = () => resolve(request.result); + request.onerror = (e) => reject(e); + + return promise; + }, + + async setItem(key, value) { + const db = await ensure(); + const { resolve, reject, promise } = Promise.withResolvers(); + + const tx = db.transaction(["state"], "readwrite"); + const request = tx.objectStore("state").put(value, key); + request.onsuccess = () => resolve(); + request.onerror = (e) => reject(e); + + return promise; + }, + + async removeItem(key: string) { + const db = await ensure(); + const { resolve, reject, promise } = Promise.withResolvers(); + + const tx = db.transaction(["state"], "readwrite"); + const request = tx.objectStore("state").delete(key); + request.onsuccess = () => resolve(); + request.onerror = (e) => reject(e); + + return promise; + }, + }; +} diff --git a/src/client/storage-serializers.ts b/src/client/storage-serializers.ts index 20e0afd..e3e2a40 100644 --- a/src/client/storage-serializers.ts +++ b/src/client/storage-serializers.ts @@ -1,6 +1,6 @@ // deno-lint-ignore-file no-explicit-any -import type { PersistStorage, StateStorage } from "zustand/middleware"; +import type { StateStorage } from "zustand/middleware"; type MaybeAsync = U | Promise; @@ -17,7 +17,7 @@ export type SerializerMap = { export function makeSlicedSerializerStorage( storage: StateStorage, serializers: SerializerMap, -): PersistStorage { +): StateStorage { // serialize by looping through the state and using registered serializers per key async function serializeState(state: T) { const output = {} as Record; @@ -62,20 +62,19 @@ export function makeSlicedSerializerStorage( return { async getItem(name) { - const json = await storage.getItem(name); - if (json == null) { + const result = await storage.getItem(name); + if (result == null) { return { version: 0, state: await initializeState() }; } - const { version, state } = JSON.parse(json); + const { version, state } = result; return { version, state: await deserializeState(state) }; }, async setItem(name, input) { - const state = await serializeState(input.state); - const json = JSON.stringify({ version: input.version, state }); + const { version, state } = input; - storage.setItem(name, json); + storage.setItem(name, { version, state: await serializeState(state) }); }, removeItem(name) { diff --git a/src/client/store.ts b/src/client/store.ts index 781a695..ac8d91f 100644 --- a/src/client/store.ts +++ b/src/client/store.ts @@ -2,15 +2,14 @@ import { create } from "zustand"; import { devtools, persist } from "zustand/middleware"; import { withSlices } from "zustand-slices"; +import { makeIndexedDBStorage } from "./storage-indexdb.ts"; import { makeSlicedSerializerStorage } from "./storage-serializers.ts"; import { identitySlice, identitySliceSerializer } from "./store-ident.ts"; type SkypodState = ReturnType; const skypodStore = withSlices(identitySlice); -const skypodStorage = makeSlicedSerializerStorage(localStorage, { - identity: identitySliceSerializer, -}); +const skypodStorage = makeIndexedDBStorage("skypod-state", 1); export const useSkypodStore = create( devtools( diff --git a/vite.config.ts b/vite.config.ts index 5bb9f35..3fac12c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,13 +4,9 @@ import { defineConfig } from "vite"; export default defineConfig({ root: "./src/client", - build: { - outDir: "../../dist", - copyPublicDir: true, - }, plugins: [ - react(), deno(), + react(), ], optimizeDeps: { include: ["react/jsx-runtime"],