Conversation
There was a problem hiding this comment.
Pull request overview
This PR implements a complete end-to-end encrypted synchronization system for the task management application, enabling real-time collaboration across multiple devices through a blind relay server.
Key changes:
- Migrated WebSocket handler from standalone route (
src/ws.ts) to API route (src/routes/api/ws.ts) with enhanced error logging and message type handling - Extended task store with device management, session bootstrapping, and encrypted WebSocket provider integration for real-time sync
- Added comprehensive settings UI for pairing link management, device tracking, and room rotation
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 15 comments.
Show a summary per file
| File | Description |
|---|---|
| src/ws.ts | Removed standalone WebSocket handler (moved to API routes) |
| src/routes/api/ws.ts | New WebSocket API endpoint with persistent storage, history streaming, error logging, and room-based pub/sub |
| src/stores/taskStore.ts | Added device tracking, session management, encrypted sync provider integration, heartbeat mechanism, and pairing utilities |
| src/routes/settings.tsx | New settings page UI for sync configuration, pairing link sharing, device management, and room rotation |
| src/routes/settings.css | Styles for settings page with responsive grid layout and device card components |
| src/lib/encryptedWsProvider.ts | Enhanced with status callbacks, snapshot push capability, and improved connection logging |
| app.config.ts | Removed custom router configuration, added vite server allowed hosts setting |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| type="text" | ||
| readOnly | ||
| value={shareLink()} | ||
| aria-label="Pairing link" |
There was a problem hiding this comment.
[nitpick] The pairing link input field is read-only but doesn't have autocomplete="off" or spellcheck="false" attributes. While cosmetic, this would prevent the browser from trying to autocomplete or spellcheck the encrypted URL, improving UX.
| aria-label="Pairing link" | |
| aria-label="Pairing link" | |
| autocomplete="off" | |
| spellcheck="false" |
| const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err); | ||
| const stack = err instanceof Error && err.stack ? err.stack : ""; | ||
| try { | ||
| await appendFile("./storage/ws-errors.log", `[${new Date().toISOString()}] ${scope} :: ${message}\n${stack}\n`); |
There was a problem hiding this comment.
The error log file path is hardcoded to "./storage/ws-errors.log" which may not work correctly depending on the working directory at runtime. Consider using an absolute path based on process.env.LOG_PATH or ensuring the directory exists before writing.
| const query = getQuery(peer.url); | ||
| const roomId = query.room as string; | ||
|
|
||
| if (!roomId) { |
There was a problem hiding this comment.
The roomId query parameter is not validated or sanitized. A malicious value could potentially cause issues with the database key construction (e.g., containing :: separators). Consider validating that roomId matches an expected format (e.g., UUID pattern) before using it.
| if (!roomId) { | |
| // Validate roomId as UUID v4 | |
| const uuidV4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; | |
| if (!roomId || !uuidV4Regex.test(roomId)) { |
| // Auto-initialize immediately when this file is imported on the client | ||
| onMount(init); | ||
| if (!isServer) { | ||
| void init(); |
There was a problem hiding this comment.
The init function is called immediately with void init() on line 210, but the function is declared as async on line 173. This means initialization errors will be silently swallowed. Consider adding a .catch() handler to log initialization failures: void init().catch(err => console.error("Init failed:", err)).
| void init(); | |
| void init().catch(err => console.error("Init failed:", err)); |
| const platform = (navigator as any).userAgentData?.platform ?? navigator.platform ?? "device"; | ||
| const hint = navigator.userAgent.split(" ").slice(0, 2).join(" "); | ||
| return `${platform} · ${hint}`; |
There was a problem hiding this comment.
The user agent string parsing in defaultDeviceLabel may fail or produce unexpected results on non-standard browsers. The fallback chain is good, but consider wrapping the entire computation in a try-catch to ensure it always returns a valid string even if navigator.userAgent.split() throws.
| const platform = (navigator as any).userAgentData?.platform ?? navigator.platform ?? "device"; | |
| const hint = navigator.userAgent.split(" ").slice(0, 2).join(" "); | |
| return `${platform} · ${hint}`; | |
| try { | |
| const platform = (navigator as any).userAgentData?.platform ?? navigator.platform ?? "device"; | |
| const hint = navigator.userAgent.split(" ").slice(0, 2).join(" "); | |
| return `${platform} · ${hint}`; | |
| } catch { | |
| return "device"; | |
| } |
| const logError = async (scope: string, err: unknown) => { | ||
| const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err); | ||
| const stack = err instanceof Error && err.stack ? err.stack : ""; | ||
| try { | ||
| await appendFile("./storage/ws-errors.log", `[${new Date().toISOString()}] ${scope} :: ${message}\n${stack}\n`); | ||
| } catch (fileErr) { | ||
| console.error("[WS] Failed to write debug log", fileErr); |
There was a problem hiding this comment.
[nitpick] Using void to suppress the promise from logError is appropriate, but the function should handle the file write error more gracefully. Currently, if appendFile fails, the error is only logged to console. Consider implementing a fallback logging mechanism or at least incrementing a counter to track lost error logs.
| const logError = async (scope: string, err: unknown) => { | |
| const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err); | |
| const stack = err instanceof Error && err.stack ? err.stack : ""; | |
| try { | |
| await appendFile("./storage/ws-errors.log", `[${new Date().toISOString()}] ${scope} :: ${message}\n${stack}\n`); | |
| } catch (fileErr) { | |
| console.error("[WS] Failed to write debug log", fileErr); | |
| // Counter for lost error logs | |
| let lostErrorLogCount = 0; | |
| const logError = async (scope: string, err: unknown) => { | |
| const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err); | |
| const stack = err instanceof Error && err.stack ? err.stack : ""; | |
| try { | |
| await appendFile("./storage/ws-errors.log", `[${new Date().toISOString()}] ${scope} :: ${message}\n${stack}\n`); | |
| } catch (fileErr) { | |
| lostErrorLogCount++; | |
| console.error("[WS] Failed to write debug log", fileErr, `Lost error logs: ${lostErrorLogCount}`); |
| }, | ||
| vite: { | ||
| server: { | ||
| allowedHosts: true |
There was a problem hiding this comment.
Inconsistent indentation: line 11 has only 4 spaces while the surrounding lines use proper indentation. This should be indented to align with the opening of the server object property.
| allowedHosts: true | |
| allowedHosts: true |
| (async () => { | ||
| for await (const [_, value] of stream) { | ||
| const payload = Buffer.isBuffer(value) ? value : Buffer.from(value); | ||
| peer.send(payload); | ||
| } | ||
| })().catch((err) => { | ||
| console.error("[WS] History stream error", err); | ||
| void logError("history", err); | ||
| peer.close(1011, "history-error"); | ||
| }); |
There was a problem hiding this comment.
The asynchronous IIFE for streaming history lacks proper error handling until caught at line 45. If an error occurs during iteration (lines 41-44), it will only be caught after the IIFE completes, potentially leaving the peer in an inconsistent state. Consider wrapping the send operation in a try-catch to handle individual message failures gracefully.
| if (typeof window !== "undefined" && window.location.hash.includes("room=")) { | ||
| const params = new URLSearchParams(window.location.hash.slice(1)); | ||
| const hashRoom = params.get("room"); | ||
| const hashKey = params.get("key"); | ||
| if (hashRoom && hashKey) { | ||
| roomId = hashRoom; | ||
| rawKey = hashKey; | ||
| localStorage.setItem(STORAGE_KEYS.room, hashRoom); | ||
| localStorage.setItem(STORAGE_KEYS.key, hashKey); | ||
| window.history.replaceState(null, "", window.location.pathname + window.location.search); | ||
| } |
There was a problem hiding this comment.
The hash parsing logic extracts credentials from the URL hash but doesn't validate the format of the key parameter. Consider validating that the key matches the expected format/length before storing it to localStorage, to avoid storing malformed credentials.
| const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; | ||
| const host = window.location.host; | ||
| this.url = `${protocol}//${host}${serverUrl}?room=${roomId}`; | ||
| console.log("[WS] Connecting to:", this.url); |
There was a problem hiding this comment.
[nitpick] The WebSocket URL is logged to console, which will include the roomId query parameter. While the encryption key is not in the URL, the roomId might be considered sensitive. Consider using a debug flag to control this logging or redacting the room parameter in production.
No description provided.