Live video on the AT Protocol

Merge pull request #750 from streamplace/natb/sync-client-time

feat: polyfill date to sync client time

authored by Eli Mallon and committed by GitHub 608694aa 20091b96

+366
+3
js/app/src/polyfills.ts
···
··· 1 + import { initializeTimeSync } from "@streamplace/components/src/time-sync"; 2 + 3 + initializeTimeSync();
+3
js/components/src/streamplace-provider/poller.tsx
··· 7 useStreamplaceStore, 8 } from "../streamplace-store"; 9 import { usePDSAgent } from "../streamplace-store/xrpc"; 10 11 export default function Poller({ children }: { children: React.ReactNode }) { 12 const url = useStreamplaceStore((state) => state.url); ··· 18 const liveUserRefresh = useStreamplaceStore( 19 (state) => state.liveUsersRefresh, 20 ); 21 22 useEffect(() => { 23 if (pdsAgent && did) {
··· 7 useStreamplaceStore, 8 } from "../streamplace-store"; 9 import { usePDSAgent } from "../streamplace-store/xrpc"; 10 + import { useTimeSync } from "../time-sync"; 11 12 export default function Poller({ children }: { children: React.ReactNode }) { 13 const url = useStreamplaceStore((state) => state.url); ··· 19 const liveUserRefresh = useStreamplaceStore( 20 (state) => state.liveUsersRefresh, 21 ); 22 + 23 + useTimeSync(); 24 25 useEffect(() => { 26 if (pdsAgent && did) {
+12
js/components/src/time-sync/index.ts
···
··· 1 + export { 2 + checkClockDrift, 3 + getSyncedDate, 4 + getSystemDate, 5 + getSystemTime, 6 + getTimeOffset, 7 + initializeTimeSync, 8 + setTimeOffset, 9 + syncTimeWithServer, 10 + } from "./time-sync"; 11 + 12 + export { useTimeSync } from "./useTimeSync";
+112
js/components/src/time-sync/time-sync.ts
···
··· 1 + import { Platform } from "react-native"; 2 + 3 + let timeOffset = 0; 4 + let hasWarned = false; 5 + let OriginalDate: DateConstructor = Date; 6 + 7 + const CLOCK_DRIFT_THRESHOLD_MS = 5000; // 5 seconds 8 + 9 + export function getTimeOffset(): number { 10 + return timeOffset; 11 + } 12 + 13 + export function setTimeOffset(offset: number): void { 14 + timeOffset = offset; 15 + } 16 + 17 + export function checkClockDrift(serverTime: string): { 18 + hasDrift: boolean; 19 + driftMs: number; 20 + driftSeconds: number; 21 + } { 22 + const serverDate = new Date(serverTime); 23 + const clientDate = new Date(); 24 + const drift = Math.abs(serverDate.getTime() - clientDate.getTime()); 25 + 26 + if (drift > CLOCK_DRIFT_THRESHOLD_MS) { 27 + const driftSeconds = Math.round(drift / 1000); 28 + if (!hasWarned) { 29 + hasWarned = true; 30 + console.warn( 31 + `clock drift detected: ${driftSeconds}s difference from server time. ` + 32 + `this may cause issues with time-sensitive operations. ` + 33 + `please sync your system clock.`, 34 + ); 35 + } 36 + return { hasDrift: true, driftMs: drift, driftSeconds }; 37 + } else { 38 + return { 39 + hasDrift: false, 40 + driftMs: drift, 41 + driftSeconds: Math.round(drift / 1000), 42 + }; 43 + } 44 + } 45 + 46 + export function syncTimeWithServer( 47 + serverTime: string, 48 + networkLatencyMs: number, 49 + ): void { 50 + const serverDate = new OriginalDate(serverTime); 51 + const clientDate = new OriginalDate(); 52 + const offset = serverDate.getTime() - clientDate.getTime() - networkLatencyMs; 53 + 54 + setTimeOffset(offset); 55 + } 56 + 57 + export function getSyncedDate(): Date { 58 + const now = new Date(); 59 + if (timeOffset !== 0) { 60 + return new Date(now.getTime() + timeOffset); 61 + } 62 + return now; 63 + } 64 + 65 + export function getSystemDate(): Date { 66 + return new OriginalDate(); 67 + } 68 + 69 + export function getSystemTime(): number { 70 + return OriginalDate.now(); 71 + } 72 + 73 + export function initializeTimeSync(): void { 74 + if (Platform.OS !== "web") { 75 + return; 76 + } 77 + 78 + // store original Date 79 + OriginalDate = Date; 80 + const OriginalDatePrototype = OriginalDate.prototype; 81 + 82 + // create patched Date constructor 83 + function PatchedDate(this: any, ...args: any[]): any { 84 + // If called as a function (no `new`), forward to original Date to get the string form 85 + if (!(this instanceof PatchedDate)) { 86 + return OriginalDate.apply(undefined, args as any); 87 + } 88 + 89 + // If called as a constructor, construct a Date with synced time when no args provided 90 + if (args.length === 0) { 91 + const syncedTime = OriginalDate.now() + timeOffset; 92 + return Reflect.construct(OriginalDate, [syncedTime], PatchedDate); 93 + } 94 + 95 + // Otherwise construct with the provided arguments 96 + return Reflect.construct(OriginalDate, args, PatchedDate); 97 + } 98 + 99 + // copy static methods 100 + PatchedDate.now = function (): number { 101 + return OriginalDate.now() + timeOffset; 102 + }; 103 + 104 + PatchedDate.parse = OriginalDate.parse; 105 + PatchedDate.UTC = OriginalDate.UTC; 106 + 107 + // copy prototype 108 + PatchedDate.prototype = OriginalDatePrototype; 109 + 110 + // replace global Date 111 + (globalThis as any).Date = PatchedDate; 112 + }
+58
js/components/src/time-sync/useTimeSync.tsx
···
··· 1 + import { TriangleAlert } from "lucide-react-native"; 2 + import { useEffect, useRef } from "react"; 3 + import { Platform } from "react-native"; 4 + import { StreamplaceAgent } from "streamplace"; 5 + import { useToast } from "../components/ui/toast"; 6 + import { useUrl } from "../streamplace-store/streamplace-store"; 7 + import { checkClockDrift, syncTimeWithServer } from "./time-sync"; 8 + 9 + export function useTimeSync() { 10 + const url = useUrl(); 11 + const t = useToast(); 12 + const hasShownWarning = useRef(false); 13 + 14 + useEffect(() => { 15 + const checkTime = async () => { 16 + if (Platform.OS !== "web") { 17 + return; 18 + } 19 + try { 20 + const agent = new StreamplaceAgent(url); 21 + const start = new Date().getTime(); 22 + const response = await agent.place.stream.server.getServerTime(); 23 + const roundTripLatency = new Date().getTime() - start; 24 + const serverTime = response.data.serverTime; 25 + 26 + // always sync with server time 27 + syncTimeWithServer(serverTime, roundTripLatency / 2); 28 + 29 + const driftInfo = checkClockDrift(serverTime); 30 + 31 + // only show warning if drift is significant 32 + if (driftInfo.hasDrift && !hasShownWarning.current) { 33 + hasShownWarning.current = true; 34 + t.show( 35 + "Clock drift detected!", 36 + `Your device clock is ${driftInfo.driftSeconds}s off from server time. Please sync your system clock to avoid issues.`, 37 + { 38 + variant: "info", 39 + iconLeft: TriangleAlert, 40 + duration: 25, 41 + }, 42 + ); 43 + console.log( 44 + `time sync applied: offset ${driftInfo.driftMs}ms. Date() calls will now use server time.`, 45 + ); 46 + } 47 + } catch (error) { 48 + console.error("failed to sync time with server:", error); 49 + } 50 + }; 51 + 52 + checkTime(); 53 + 54 + const interval = setInterval(checkTime, 1800000); // every 30m 55 + 56 + return () => clearInterval(interval); 57 + }, [url, t]); 58 + }
+27
js/docs/src/content/docs/lex-reference/openapi.json
··· 197 } 198 } 199 }, 200 "/xrpc/place.stream.server.getWebhook": { 201 "get": { 202 "summary": "Get details for a specific webhook.",
··· 197 } 198 } 199 }, 200 + "/xrpc/place.stream.server.getServerTime": { 201 + "get": { 202 + "summary": "Get the current server time for client clock synchronization", 203 + "operationId": "place.stream.server.getServerTime", 204 + "tags": ["place.stream.server"], 205 + "responses": { 206 + "200": { 207 + "description": "Success", 208 + "content": { 209 + "application/json": { 210 + "schema": { 211 + "type": "object", 212 + "properties": { 213 + "serverTime": { 214 + "type": "string", 215 + "description": "Current server time in RFC3339 format", 216 + "format": "date-time" 217 + } 218 + }, 219 + "required": ["serverTime"] 220 + } 221 + } 222 + } 223 + } 224 + } 225 + } 226 + }, 227 "/xrpc/place.stream.server.getWebhook": { 228 "get": { 229 "summary": "Get details for a specific webhook.",
+64
js/docs/src/content/docs/lex-reference/server/place-stream-server-getservertime.md
···
··· 1 + --- 2 + title: place.stream.server.getServerTime 3 + description: Reference for the place.stream.server.getServerTime lexicon 4 + --- 5 + 6 + **Lexicon Version:** 1 7 + 8 + ## Definitions 9 + 10 + <a name="main"></a> 11 + 12 + ### `main` 13 + 14 + **Type:** `query` 15 + 16 + Get the current server time for client clock synchronization 17 + 18 + **Parameters:** _(None defined)_ 19 + 20 + **Output:** 21 + 22 + - **Encoding:** `application/json` 23 + - **Schema:** 24 + 25 + **Schema Type:** `object` 26 + 27 + | Name | Type | Req'd | Description | Constraints | 28 + | ------------ | -------- | ----- | ------------------------------------- | ------------------ | 29 + | `serverTime` | `string` | ✅ | Current server time in RFC3339 format | Format: `datetime` | 30 + 31 + --- 32 + 33 + ## Lexicon Source 34 + 35 + ```json 36 + { 37 + "lexicon": 1, 38 + "id": "place.stream.server.getServerTime", 39 + "defs": { 40 + "main": { 41 + "type": "query", 42 + "description": "Get the current server time for client clock synchronization", 43 + "parameters": { 44 + "type": "params", 45 + "properties": {} 46 + }, 47 + "output": { 48 + "encoding": "application/json", 49 + "schema": { 50 + "type": "object", 51 + "required": ["serverTime"], 52 + "properties": { 53 + "serverTime": { 54 + "type": "string", 55 + "format": "datetime", 56 + "description": "Current server time in RFC3339 format" 57 + } 58 + } 59 + } 60 + } 61 + } 62 + } 63 + } 64 + ```
+28
lexicons/place/stream/server/getServerTime.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.stream.server.getServerTime", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get the current server time for client clock synchronization", 8 + "parameters": { 9 + "type": "params", 10 + "properties": {} 11 + }, 12 + "output": { 13 + "encoding": "application/json", 14 + "schema": { 15 + "type": "object", 16 + "required": ["serverTime"], 17 + "properties": { 18 + "serverTime": { 19 + "type": "string", 20 + "format": "datetime", 21 + "description": "Current server time in RFC3339 format" 22 + } 23 + } 24 + } 25 + } 26 + } 27 + } 28 + }
+16
pkg/spxrpc/place_stream_server.go
···
··· 1 + package spxrpc 2 + 3 + import ( 4 + "context" 5 + "time" 6 + 7 + "github.com/bluesky-social/indigo/util" 8 + placestreamtypes "stream.place/streamplace/pkg/streamplace" 9 + ) 10 + 11 + func (s *Server) handlePlaceStreamServerGetServerTime(ctx context.Context) (*placestreamtypes.ServerGetServerTime_Output, error) { 12 + serverTime := time.Now().UTC().Format(util.ISO8601) 13 + return &placestreamtypes.ServerGetServerTime_Output{ 14 + ServerTime: serverTime, 15 + }, nil 16 + }
+14
pkg/spxrpc/stubs.go
··· 269 e.GET("/xrpc/place.stream.live.getSegments", s.HandlePlaceStreamLiveGetSegments) 270 e.POST("/xrpc/place.stream.server.createWebhook", s.HandlePlaceStreamServerCreateWebhook) 271 e.POST("/xrpc/place.stream.server.deleteWebhook", s.HandlePlaceStreamServerDeleteWebhook) 272 e.GET("/xrpc/place.stream.server.getWebhook", s.HandlePlaceStreamServerGetWebhook) 273 e.GET("/xrpc/place.stream.server.listWebhooks", s.HandlePlaceStreamServerListWebhooks) 274 e.POST("/xrpc/place.stream.server.updateWebhook", s.HandlePlaceStreamServerUpdateWebhook) ··· 398 var handleErr error 399 // func (s *Server) handlePlaceStreamServerDeleteWebhook(ctx context.Context,body *placestreamtypes.ServerDeleteWebhook_Input) (*placestreamtypes.ServerDeleteWebhook_Output, error) 400 out, handleErr = s.handlePlaceStreamServerDeleteWebhook(ctx, &body) 401 if handleErr != nil { 402 return handleErr 403 }
··· 269 e.GET("/xrpc/place.stream.live.getSegments", s.HandlePlaceStreamLiveGetSegments) 270 e.POST("/xrpc/place.stream.server.createWebhook", s.HandlePlaceStreamServerCreateWebhook) 271 e.POST("/xrpc/place.stream.server.deleteWebhook", s.HandlePlaceStreamServerDeleteWebhook) 272 + e.GET("/xrpc/place.stream.server.getServerTime", s.HandlePlaceStreamServerGetServerTime) 273 e.GET("/xrpc/place.stream.server.getWebhook", s.HandlePlaceStreamServerGetWebhook) 274 e.GET("/xrpc/place.stream.server.listWebhooks", s.HandlePlaceStreamServerListWebhooks) 275 e.POST("/xrpc/place.stream.server.updateWebhook", s.HandlePlaceStreamServerUpdateWebhook) ··· 399 var handleErr error 400 // func (s *Server) handlePlaceStreamServerDeleteWebhook(ctx context.Context,body *placestreamtypes.ServerDeleteWebhook_Input) (*placestreamtypes.ServerDeleteWebhook_Output, error) 401 out, handleErr = s.handlePlaceStreamServerDeleteWebhook(ctx, &body) 402 + if handleErr != nil { 403 + return handleErr 404 + } 405 + return c.JSON(200, out) 406 + } 407 + 408 + func (s *Server) HandlePlaceStreamServerGetServerTime(c echo.Context) error { 409 + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamServerGetServerTime") 410 + defer span.End() 411 + var out *placestreamtypes.ServerGetServerTime_Output 412 + var handleErr error 413 + // func (s *Server) handlePlaceStreamServerGetServerTime(ctx context.Context) (*placestreamtypes.ServerGetServerTime_Output, error) 414 + out, handleErr = s.handlePlaceStreamServerGetServerTime(ctx) 415 if handleErr != nil { 416 return handleErr 417 }
+29
pkg/streamplace/servergetServerTime.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package streamplace 4 + 5 + // schema: place.stream.server.getServerTime 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + // ServerGetServerTime_Output is the output of a place.stream.server.getServerTime call. 14 + type ServerGetServerTime_Output struct { 15 + // serverTime: Current server time in RFC3339 format 16 + ServerTime string `json:"serverTime" cborgen:"serverTime"` 17 + } 18 + 19 + // ServerGetServerTime calls the XRPC method "place.stream.server.getServerTime". 20 + func ServerGetServerTime(ctx context.Context, c util.LexClient) (*ServerGetServerTime_Output, error) { 21 + var out ServerGetServerTime_Output 22 + 23 + params := map[string]interface{}{} 24 + if err := c.LexDo(ctx, util.Query, "", "place.stream.server.getServerTime", params, nil, &out); err != nil { 25 + return nil, err 26 + } 27 + 28 + return &out, nil 29 + }