From c4e86b2f270593641d176b2967f4ffd08c181f97 Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Fri, 28 Nov 2025 16:09:35 -0600 Subject: [PATCH 1/5] polyfill date to sync client time --- js/app/src/polyfills.ts | 3 + .../src/streamplace-provider/poller.tsx | 3 + js/components/src/time-sync/index.ts | 12 ++ js/components/src/time-sync/time-sync.ts | 110 ++++++++++++++++++ js/components/src/time-sync/useTimeSync.tsx | 56 +++++++++ .../content/docs/lex-reference/openapi.json | 27 +++++ .../place-stream-server-getservertime.md | 64 ++++++++++ .../place/stream/server/getServerTime.json | 28 +++++ pkg/spxrpc/place_stream_server.go | 15 +++ pkg/spxrpc/stubs.go | 14 +++ pkg/streamplace/servergetServerTime.go | 29 +++++ 11 files changed, 361 insertions(+) create mode 100644 js/app/src/polyfills.ts create mode 100644 js/components/src/time-sync/index.ts create mode 100644 js/components/src/time-sync/time-sync.ts create mode 100644 js/components/src/time-sync/useTimeSync.tsx create mode 100644 js/docs/src/content/docs/lex-reference/server/place-stream-server-getservertime.md create mode 100644 lexicons/place/stream/server/getServerTime.json create mode 100644 pkg/spxrpc/place_stream_server.go create mode 100644 pkg/streamplace/servergetServerTime.go diff --git a/js/app/src/polyfills.ts b/js/app/src/polyfills.ts new file mode 100644 index 000000000..7f64bb2a6 --- /dev/null +++ b/js/app/src/polyfills.ts @@ -0,0 +1,3 @@ +import { initializeTimeSync } from "@streamplace/components/src/time-sync"; + +initializeTimeSync(); diff --git a/js/components/src/streamplace-provider/poller.tsx b/js/components/src/streamplace-provider/poller.tsx index b32a5b8aa..7e4721597 100644 --- a/js/components/src/streamplace-provider/poller.tsx +++ b/js/components/src/streamplace-provider/poller.tsx @@ -7,6 +7,7 @@ import { useStreamplaceStore, } from "../streamplace-store"; import { usePDSAgent } from "../streamplace-store/xrpc"; +import { useTimeSync } from "../time-sync"; export default function Poller({ children }: { children: React.ReactNode }) { const url = useStreamplaceStore((state) => state.url); @@ -19,6 +20,8 @@ export default function Poller({ children }: { children: React.ReactNode }) { (state) => state.liveUsersRefresh, ); + useTimeSync(); + useEffect(() => { if (pdsAgent && did) { getChatProfile(); diff --git a/js/components/src/time-sync/index.ts b/js/components/src/time-sync/index.ts new file mode 100644 index 000000000..047ea5cde --- /dev/null +++ b/js/components/src/time-sync/index.ts @@ -0,0 +1,12 @@ +export { + checkClockDrift, + getSyncedDate, + getSystemDate, + getSystemTime, + getTimeOffset, + initializeTimeSync, + setTimeOffset, + syncTimeWithServer, +} from "./time-sync"; + +export { useTimeSync } from "./useTimeSync"; diff --git a/js/components/src/time-sync/time-sync.ts b/js/components/src/time-sync/time-sync.ts new file mode 100644 index 000000000..d6911b48b --- /dev/null +++ b/js/components/src/time-sync/time-sync.ts @@ -0,0 +1,110 @@ +import { Platform } from "react-native"; + +let timeOffset = 0; +let hasWarned = false; +let OriginalDate: DateConstructor = Date; + +const CLOCK_DRIFT_THRESHOLD_MS = 5000; // 5 seconds + +export function getTimeOffset(): number { + return timeOffset; +} + +export function setTimeOffset(offset: number): void { + timeOffset = offset; +} + +export function checkClockDrift(serverTime: string): { + hasDrift: boolean; + driftMs: number; + driftSeconds: number; +} { + console.log("checking drift"); + const serverDate = new Date(serverTime); + const clientDate = new Date(); + const drift = Math.abs(serverDate.getTime() - clientDate.getTime()); + + if (drift > CLOCK_DRIFT_THRESHOLD_MS) { + const driftSeconds = Math.round(drift / 1000); + if (!hasWarned) { + hasWarned = true; + console.warn( + `clock drift detected: ${driftSeconds}s difference from server time. ` + + `this may cause issues with time-sensitive operations. ` + + `please sync your system clock.`, + ); + } + return { hasDrift: true, driftMs: drift, driftSeconds }; + } else { + return { + hasDrift: false, + driftMs: drift, + driftSeconds: Math.round(drift / 1000), + }; + } +} + +export function syncTimeWithServer(serverTime: string): void { + const serverDate = new Date(serverTime); + const clientDate = new Date(); + const offset = serverDate.getTime() - clientDate.getTime(); + + setTimeOffset(offset); +} + +export function getSyncedDate(): Date { + const now = new Date(); + if (timeOffset !== 0) { + return new Date(now.getTime() + timeOffset); + } + return now; +} + +export function getSystemDate(): Date { + return new OriginalDate(); +} + +export function getSystemTime(): number { + return OriginalDate.now(); +} + +export function initializeTimeSync(): void { + if (Platform.OS !== "web") { + return; + } + + // store original Date + OriginalDate = Date; + const OriginalDatePrototype = OriginalDate.prototype; + + // create patched Date constructor + function PatchedDate(this: any, ...args: any[]): any { + // If called as a function (no `new`), forward to original Date to get the string form + if (!(this instanceof PatchedDate)) { + return OriginalDate.apply(undefined, args as any); + } + + // If called as a constructor, construct a Date with synced time when no args provided + if (args.length === 0) { + const syncedTime = OriginalDate.now() + timeOffset; + return Reflect.construct(OriginalDate, [syncedTime], PatchedDate); + } + + // Otherwise construct with the provided arguments + return Reflect.construct(OriginalDate, args, PatchedDate); + } + + // copy static methods + PatchedDate.now = function (): number { + return OriginalDate.now() + timeOffset; + }; + + PatchedDate.parse = OriginalDate.parse; + PatchedDate.UTC = OriginalDate.UTC; + + // copy prototype + PatchedDate.prototype = OriginalDatePrototype; + + // replace global Date + (globalThis as any).Date = PatchedDate; +} diff --git a/js/components/src/time-sync/useTimeSync.tsx b/js/components/src/time-sync/useTimeSync.tsx new file mode 100644 index 000000000..a6ccfb57e --- /dev/null +++ b/js/components/src/time-sync/useTimeSync.tsx @@ -0,0 +1,56 @@ +import { TriangleAlert } from "lucide-react-native"; +import { useEffect, useRef } from "react"; +import { Platform } from "react-native"; +import { StreamplaceAgent } from "streamplace"; +import { useToast } from "../components/ui/toast"; +import { useUrl } from "../streamplace-store/streamplace-store"; +import { checkClockDrift, syncTimeWithServer } from "./time-sync"; + +export function useTimeSync() { + const url = useUrl(); + const t = useToast(); + const hasShownWarning = useRef(false); + + useEffect(() => { + const checkTime = async () => { + if (Platform.OS !== "web") { + return; + } + try { + const agent = new StreamplaceAgent(url); + const response = await agent.place.stream.server.getServerTime(); + const serverTime = response.data.serverTime; + + // always sync with server time + syncTimeWithServer(serverTime); + + const driftInfo = checkClockDrift(serverTime); + + // only show warning if drift is significant + if (driftInfo.hasDrift && !hasShownWarning.current) { + hasShownWarning.current = true; + t.show( + "Clock drift detected!", + `Your device clock is ${driftInfo.driftSeconds}s off from server time. Please sync your system clock to avoid issues.`, + { + variant: "info", + iconLeft: TriangleAlert, + duration: 25, + }, + ); + console.log( + `time sync applied: offset ${driftInfo.driftMs}ms. Date() calls will now use server time.`, + ); + } + } catch (error) { + console.error("failed to sync time with server:", error); + } + }; + + checkTime(); + + const interval = setInterval(checkTime, 1800000); // every 30m + + return () => clearInterval(interval); + }, [url, t]); +} diff --git a/js/docs/src/content/docs/lex-reference/openapi.json b/js/docs/src/content/docs/lex-reference/openapi.json index ad81eb768..4e13d6b69 100644 --- a/js/docs/src/content/docs/lex-reference/openapi.json +++ b/js/docs/src/content/docs/lex-reference/openapi.json @@ -197,6 +197,33 @@ } } }, + "/xrpc/place.stream.server.getServerTime": { + "get": { + "summary": "Get the current server time for client clock synchronization", + "operationId": "place.stream.server.getServerTime", + "tags": ["place.stream.server"], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "serverTime": { + "type": "string", + "description": "Current server time in RFC3339 format", + "format": "date-time" + } + }, + "required": ["serverTime"] + } + } + } + } + } + } + }, "/xrpc/place.stream.server.getWebhook": { "get": { "summary": "Get details for a specific webhook.", diff --git a/js/docs/src/content/docs/lex-reference/server/place-stream-server-getservertime.md b/js/docs/src/content/docs/lex-reference/server/place-stream-server-getservertime.md new file mode 100644 index 000000000..414c6bafc --- /dev/null +++ b/js/docs/src/content/docs/lex-reference/server/place-stream-server-getservertime.md @@ -0,0 +1,64 @@ +--- +title: place.stream.server.getServerTime +description: Reference for the place.stream.server.getServerTime lexicon +--- + +**Lexicon Version:** 1 + +## Definitions + + + +### `main` + +**Type:** `query` + +Get the current server time for client clock synchronization + +**Parameters:** _(None defined)_ + +**Output:** + +- **Encoding:** `application/json` +- **Schema:** + +**Schema Type:** `object` + +| Name | Type | Req'd | Description | Constraints | +| ------------ | -------- | ----- | ------------------------------------- | ------------------ | +| `serverTime` | `string` | ✅ | Current server time in RFC3339 format | Format: `datetime` | + +--- + +## Lexicon Source + +```json +{ + "lexicon": 1, + "id": "place.stream.server.getServerTime", + "defs": { + "main": { + "type": "query", + "description": "Get the current server time for client clock synchronization", + "parameters": { + "type": "params", + "properties": {} + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["serverTime"], + "properties": { + "serverTime": { + "type": "string", + "format": "datetime", + "description": "Current server time in RFC3339 format" + } + } + } + } + } + } +} +``` diff --git a/lexicons/place/stream/server/getServerTime.json b/lexicons/place/stream/server/getServerTime.json new file mode 100644 index 000000000..14e67b048 --- /dev/null +++ b/lexicons/place/stream/server/getServerTime.json @@ -0,0 +1,28 @@ +{ + "lexicon": 1, + "id": "place.stream.server.getServerTime", + "defs": { + "main": { + "type": "query", + "description": "Get the current server time for client clock synchronization", + "parameters": { + "type": "params", + "properties": {} + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["serverTime"], + "properties": { + "serverTime": { + "type": "string", + "format": "datetime", + "description": "Current server time in RFC3339 format" + } + } + } + } + } + } +} diff --git a/pkg/spxrpc/place_stream_server.go b/pkg/spxrpc/place_stream_server.go new file mode 100644 index 000000000..5a74f5bd0 --- /dev/null +++ b/pkg/spxrpc/place_stream_server.go @@ -0,0 +1,15 @@ +package spxrpc + +import ( + "context" + "time" + + placestreamtypes "stream.place/streamplace/pkg/streamplace" +) + +func (s *Server) handlePlaceStreamServerGetServerTime(ctx context.Context) (*placestreamtypes.ServerGetServerTime_Output, error) { + serverTime := time.Now().UTC().Format(time.RFC3339) + return &placestreamtypes.ServerGetServerTime_Output{ + ServerTime: serverTime, + }, nil +} diff --git a/pkg/spxrpc/stubs.go b/pkg/spxrpc/stubs.go index 35f1a1413..2c5e15f4f 100644 --- a/pkg/spxrpc/stubs.go +++ b/pkg/spxrpc/stubs.go @@ -269,6 +269,7 @@ func (s *Server) RegisterHandlersPlaceStream(e *echo.Echo) error { e.GET("/xrpc/place.stream.live.getSegments", s.HandlePlaceStreamLiveGetSegments) e.POST("/xrpc/place.stream.server.createWebhook", s.HandlePlaceStreamServerCreateWebhook) e.POST("/xrpc/place.stream.server.deleteWebhook", s.HandlePlaceStreamServerDeleteWebhook) + e.GET("/xrpc/place.stream.server.getServerTime", s.HandlePlaceStreamServerGetServerTime) e.GET("/xrpc/place.stream.server.getWebhook", s.HandlePlaceStreamServerGetWebhook) e.GET("/xrpc/place.stream.server.listWebhooks", s.HandlePlaceStreamServerListWebhooks) e.POST("/xrpc/place.stream.server.updateWebhook", s.HandlePlaceStreamServerUpdateWebhook) @@ -404,6 +405,19 @@ func (s *Server) HandlePlaceStreamServerDeleteWebhook(c echo.Context) error { return c.JSON(200, out) } +func (s *Server) HandlePlaceStreamServerGetServerTime(c echo.Context) error { + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamServerGetServerTime") + defer span.End() + var out *placestreamtypes.ServerGetServerTime_Output + var handleErr error + // func (s *Server) handlePlaceStreamServerGetServerTime(ctx context.Context) (*placestreamtypes.ServerGetServerTime_Output, error) + out, handleErr = s.handlePlaceStreamServerGetServerTime(ctx) + if handleErr != nil { + return handleErr + } + return c.JSON(200, out) +} + func (s *Server) HandlePlaceStreamServerGetWebhook(c echo.Context) error { ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandlePlaceStreamServerGetWebhook") defer span.End() diff --git a/pkg/streamplace/servergetServerTime.go b/pkg/streamplace/servergetServerTime.go new file mode 100644 index 000000000..d672843d0 --- /dev/null +++ b/pkg/streamplace/servergetServerTime.go @@ -0,0 +1,29 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package streamplace + +// schema: place.stream.server.getServerTime + +import ( + "context" + + "github.com/bluesky-social/indigo/lex/util" +) + +// ServerGetServerTime_Output is the output of a place.stream.server.getServerTime call. +type ServerGetServerTime_Output struct { + // serverTime: Current server time in RFC3339 format + ServerTime string `json:"serverTime" cborgen:"serverTime"` +} + +// ServerGetServerTime calls the XRPC method "place.stream.server.getServerTime". +func ServerGetServerTime(ctx context.Context, c util.LexClient) (*ServerGetServerTime_Output, error) { + var out ServerGetServerTime_Output + + params := map[string]interface{}{} + if err := c.LexDo(ctx, util.Query, "", "place.stream.server.getServerTime", params, nil, &out); err != nil { + return nil, err + } + + return &out, nil +} -- 2.51.2 From 1ec257fa6d2c9823162297d08e2ce2651cc7643e Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Fri, 28 Nov 2025 16:40:42 -0600 Subject: [PATCH 2/5] whoops --- js/components/src/time-sync/time-sync.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/js/components/src/time-sync/time-sync.ts b/js/components/src/time-sync/time-sync.ts index d6911b48b..46cb13f8c 100644 --- a/js/components/src/time-sync/time-sync.ts +++ b/js/components/src/time-sync/time-sync.ts @@ -19,7 +19,6 @@ export function checkClockDrift(serverTime: string): { driftMs: number; driftSeconds: number; } { - console.log("checking drift"); const serverDate = new Date(serverTime); const clientDate = new Date(); const drift = Math.abs(serverDate.getTime() - clientDate.getTime()); -- 2.51.2 From 9dabf5aa747b6ecf8172618340213f764cb68606 Mon Sep 17 00:00:00 2001 From: natalie <22222885+espeon@users.noreply.github.com> Date: Fri, 28 Nov 2025 19:40:13 -0600 Subject: [PATCH 3/5] Apply suggestions from code review Co-authored-by: Eli Mallon --- js/components/src/time-sync/time-sync.ts | 4 ++-- js/components/src/time-sync/useTimeSync.tsx | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/js/components/src/time-sync/time-sync.ts b/js/components/src/time-sync/time-sync.ts index 46cb13f8c..166e2eeb6 100644 --- a/js/components/src/time-sync/time-sync.ts +++ b/js/components/src/time-sync/time-sync.ts @@ -44,8 +44,8 @@ export function checkClockDrift(serverTime: string): { } export function syncTimeWithServer(serverTime: string): void { - const serverDate = new Date(serverTime); - const clientDate = new Date(); + const serverDate = new OriginalDate(serverTime); + const clientDate = new OriginalDate(); const offset = serverDate.getTime() - clientDate.getTime(); setTimeOffset(offset); diff --git a/js/components/src/time-sync/useTimeSync.tsx b/js/components/src/time-sync/useTimeSync.tsx index a6ccfb57e..bb3c2cb9a 100644 --- a/js/components/src/time-sync/useTimeSync.tsx +++ b/js/components/src/time-sync/useTimeSync.tsx @@ -18,11 +18,13 @@ export function useTimeSync() { } try { const agent = new StreamplaceAgent(url); + const start = Date.getTime(); const response = await agent.place.stream.server.getServerTime(); + const roundTripLatency = Date.getTime() - start; const serverTime = response.data.serverTime; // always sync with server time - syncTimeWithServer(serverTime); + syncTimeWithServer(serverTime, roundTripLatency / 2); const driftInfo = checkClockDrift(serverTime); -- 2.51.2 From d023d1e73f3193de0632d5e4a83beb7273286c79 Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Sun, 30 Nov 2025 15:01:03 -0600 Subject: [PATCH 4/5] take network latency into consideration (for airplane wifi, etc.) --- js/components/src/time-sync/time-sync.ts | 7 +++++-- js/components/src/time-sync/useTimeSync.tsx | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/js/components/src/time-sync/time-sync.ts b/js/components/src/time-sync/time-sync.ts index 166e2eeb6..bffb239c6 100644 --- a/js/components/src/time-sync/time-sync.ts +++ b/js/components/src/time-sync/time-sync.ts @@ -43,10 +43,13 @@ export function checkClockDrift(serverTime: string): { } } -export function syncTimeWithServer(serverTime: string): void { +export function syncTimeWithServer( + serverTime: string, + networkLatencyMs: number, +): void { const serverDate = new OriginalDate(serverTime); const clientDate = new OriginalDate(); - const offset = serverDate.getTime() - clientDate.getTime(); + const offset = serverDate.getTime() - clientDate.getTime() - networkLatencyMs; setTimeOffset(offset); } diff --git a/js/components/src/time-sync/useTimeSync.tsx b/js/components/src/time-sync/useTimeSync.tsx index bb3c2cb9a..ddabfd8fa 100644 --- a/js/components/src/time-sync/useTimeSync.tsx +++ b/js/components/src/time-sync/useTimeSync.tsx @@ -18,9 +18,9 @@ export function useTimeSync() { } try { const agent = new StreamplaceAgent(url); - const start = Date.getTime(); + const start = new Date().getTime(); const response = await agent.place.stream.server.getServerTime(); - const roundTripLatency = Date.getTime() - start; + const roundTripLatency = new Date().getTime() - start; const serverTime = response.data.serverTime; // always sync with server time -- 2.51.2 From c53ea6174a1cf96824f163443dee375a8bafe362 Mon Sep 17 00:00:00 2001 From: "Natalie B." <22222885+espeon@users.noreply.github.com> Date: Sun, 30 Nov 2025 15:02:42 -0600 Subject: [PATCH 5/5] format as 8601 like in indigo --- pkg/spxrpc/place_stream_server.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/spxrpc/place_stream_server.go b/pkg/spxrpc/place_stream_server.go index 5a74f5bd0..6dfabf3f2 100644 --- a/pkg/spxrpc/place_stream_server.go +++ b/pkg/spxrpc/place_stream_server.go @@ -4,11 +4,12 @@ import ( "context" "time" + "github.com/bluesky-social/indigo/util" placestreamtypes "stream.place/streamplace/pkg/streamplace" ) func (s *Server) handlePlaceStreamServerGetServerTime(ctx context.Context) (*placestreamtypes.ServerGetServerTime_Output, error) { - serverTime := time.Now().UTC().Format(time.RFC3339) + serverTime := time.Now().UTC().Format(util.ISO8601) return &placestreamtypes.ServerGetServerTime_Output{ ServerTime: serverTime, }, nil -- 2.51.2