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