@pickleball/expo-sdk 0.1.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +163 -40
  3. package/dist/advanced-DcbHBCGb.d.ts +300 -0
  4. package/dist/advanced-DeQwg7MK.d.cts +300 -0
  5. package/dist/advanced.cjs +47 -0
  6. package/dist/advanced.d.cts +3 -0
  7. package/dist/advanced.d.ts +3 -0
  8. package/dist/advanced.js +47 -0
  9. package/dist/camera-controls-By8tHigY.d.cts +97 -0
  10. package/dist/camera-controls-rCBUGGPC.d.ts +97 -0
  11. package/dist/chunk-2JHULJZ7.js +265 -0
  12. package/dist/chunk-62YV3PCD.cjs +139 -0
  13. package/dist/chunk-6F3HN6US.js +50 -0
  14. package/dist/chunk-6YE5BL3A.cjs +50 -0
  15. package/dist/chunk-7OX2DSKI.cjs +33 -0
  16. package/dist/chunk-7Z7TN5N3.js +318 -0
  17. package/dist/chunk-DHDX3ZOO.js +800 -0
  18. package/dist/chunk-GXQYMPHE.cjs +9 -0
  19. package/dist/chunk-IUIRVKJI.cjs +2290 -0
  20. package/dist/chunk-KFQGP6VL.js +33 -0
  21. package/dist/chunk-LCWGNGEN.cjs +265 -0
  22. package/dist/chunk-TBUEXRFQ.cjs +318 -0
  23. package/dist/chunk-XGP2MN5R.js +2290 -0
  24. package/dist/chunk-XL4CISVQ.js +9 -0
  25. package/dist/chunk-YGSQ25UA.cjs +800 -0
  26. package/dist/chunk-ZSKSXOCZ.js +139 -0
  27. package/dist/convex-command-subscription-Cabh9tND.d.ts +41 -0
  28. package/dist/convex-command-subscription-DUgpu75l.d.cts +41 -0
  29. package/dist/device-agent/index.cjs +1427 -0
  30. package/dist/device-agent/index.d.cts +493 -0
  31. package/dist/device-agent/index.d.ts +493 -0
  32. package/dist/device-agent/index.js +1427 -0
  33. package/dist/engine-BSvSdCSE.d.cts +741 -0
  34. package/dist/engine-BSvSdCSE.d.ts +741 -0
  35. package/dist/index.cjs +426 -6174
  36. package/dist/index.d.cts +299 -1400
  37. package/dist/index.d.ts +299 -1400
  38. package/dist/index.js +413 -6063
  39. package/dist/match/index.cjs +2456 -0
  40. package/dist/match/index.d.cts +253 -0
  41. package/dist/match/index.d.ts +253 -0
  42. package/dist/match/index.js +2456 -0
  43. package/dist/match/remote/index.cjs +21 -0
  44. package/dist/match/remote/index.d.cts +67 -0
  45. package/dist/match/remote/index.d.ts +67 -0
  46. package/dist/match/remote/index.js +21 -0
  47. package/dist/match/voice/index.cjs +23 -0
  48. package/dist/match/voice/index.d.cts +114 -0
  49. package/dist/match/voice/index.d.ts +114 -0
  50. package/dist/match/voice/index.js +23 -0
  51. package/dist/plugin.cjs +52 -71
  52. package/dist/plugin.d.cts +28 -1
  53. package/dist/plugin.d.ts +28 -1
  54. package/dist/plugin.js +26 -23
  55. package/dist/realtime/index.cjs +31 -0
  56. package/dist/realtime/index.d.cts +15 -0
  57. package/dist/realtime/index.d.ts +15 -0
  58. package/dist/realtime/index.js +31 -0
  59. package/dist/types-CrmcX408.d.cts +176 -0
  60. package/dist/types-CrmcX408.d.ts +176 -0
  61. package/dist/types-DiKWjwvN.d.cts +96 -0
  62. package/dist/types-DiKWjwvN.d.ts +96 -0
  63. package/dist/ui/index.cjs +1297 -0
  64. package/dist/ui/index.d.cts +493 -0
  65. package/dist/ui/index.d.ts +493 -0
  66. package/dist/ui/index.js +1297 -0
  67. package/dist/version.cjs +4 -32
  68. package/dist/version.js +2 -1
  69. package/package.json +95 -15
  70. package/src/advanced.ts +49 -0
  71. package/src/auto-resume.tsx +259 -0
  72. package/src/camera-controls.ts +99 -0
  73. package/src/camera-grant-provider.ts +392 -0
  74. package/src/camera-settings.ts +133 -0
  75. package/src/contracts.ts +73 -0
  76. package/src/device-agent/agent.ts +696 -0
  77. package/src/device-agent/beat-policy.ts +46 -0
  78. package/src/device-agent/component.tsx +60 -0
  79. package/src/device-agent/convex-beat-transport.ts +140 -0
  80. package/src/device-agent/convex-command-subscription.ts +101 -0
  81. package/src/device-agent/convex-control-channel.ts +88 -0
  82. package/src/device-agent/http-transport.ts +164 -0
  83. package/src/device-agent/index.ts +26 -0
  84. package/src/device-agent/mqtt-channel.ts +379 -0
  85. package/src/device-agent/mqtt-codec.ts +347 -0
  86. package/src/device-agent/tournament.ts +352 -0
  87. package/src/engine.ts +601 -20
  88. package/src/errors.ts +16 -0
  89. package/src/http-session-provider.ts +21 -183
  90. package/src/index.ts +117 -14
  91. package/src/local-video-quality.ts +43 -0
  92. package/src/match/index.ts +29 -0
  93. package/src/match/provider.tsx +4 -2
  94. package/src/match/remote/hid-gesture.ts +10 -2
  95. package/src/match/remote/types.ts +6 -4
  96. package/src/match/voice/scheduler.ts +6 -1
  97. package/src/match/voice/service.ts +16 -4
  98. package/src/native-runtime.ts +283 -77
  99. package/src/plugin.ts +54 -20
  100. package/src/provider.tsx +129 -22
  101. package/src/realtime/index.ts +56 -0
  102. package/src/resume-policy.ts +85 -0
  103. package/src/resume.ts +132 -0
  104. package/src/rtmp-room-adapter.ts +348 -0
  105. package/src/session-grant.ts +256 -0
  106. package/src/ui/camera-screen.tsx +484 -0
  107. package/src/ui/controls-sheet.tsx +445 -0
  108. package/src/ui/copy.ts +326 -0
  109. package/src/ui/helpers.ts +105 -0
  110. package/src/ui/hooks.ts +59 -0
  111. package/src/ui/index.ts +42 -0
  112. package/src/ui/preview-view.tsx +39 -0
  113. package/src/ui/primitives.tsx +304 -0
  114. package/src/uplink-check.ts +173 -0
  115. package/dist/chunk-VHLUJQG5.js +0 -17
  116. package/src/livekit-room-adapter.ts +0 -353
  117. package/src/livestream-screen.tsx +0 -980
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Nhịp heartbeat thích ứng — bản CHÉP của luật trong
3
+ * `packages/shared/src/realtime.ts`.
4
+ *
5
+ * Vì sao chép chứ không import: gói expo-sdk cố ý KHÔNG phụ thuộc
6
+ * `@pickleball/shared` (guard `scripts/verify-pack.mjs` chặn), để app đối tác
7
+ * cài SDK không phải kéo theo cả gói dùng chung của backend. Đổi luật ở đây thì
8
+ * phải đổi cả hai nơi — bộ test hai bên dùng CÙNG bảng giá trị để phát hiện lệch.
9
+ */
10
+
11
+ export const BEAT_SLOW_MS = 60_000;
12
+ export const BEAT_FAST_MS = 12_000;
13
+ export const MQTT_HEALTHY_SILENCE_MS = 45_000;
14
+ export const MQTT_HEALTHY_HOLD_MS = 120_000;
15
+
16
+ export interface MqttBeatHealth {
17
+ connected: boolean;
18
+ subscribed: boolean;
19
+ lastInboundAt: number;
20
+ healthySince: number;
21
+ }
22
+
23
+ /** Đủ CẢ BỐN vế mới được coi là kênh khoẻ. */
24
+ export function mqttBeatHealthy(h: MqttBeatHealth, now: number): boolean {
25
+ if (!h.connected || !h.subscribed) return false;
26
+ if (h.lastInboundAt <= 0 || now - h.lastInboundAt >= MQTT_HEALTHY_SILENCE_MS) return false;
27
+ if (h.healthySince <= 0 || now - h.healthySince < MQTT_HEALTHY_HOLD_MS) return false;
28
+ return true;
29
+ }
30
+
31
+ /**
32
+ * Nhịp kế tiếp = min(luật nội bộ, hint của server), và THIẾU hint thì mặc định
33
+ * nhanh. Nhờ vậy máy chưa nhận được hint (server cũ, hoặc beat lỗi) không bao
34
+ * giờ tự ý giãn nhịp, còn tắt rollout chỉ là đổi một biến env phía server.
35
+ */
36
+ export function nextBeatDelayMs(input: {
37
+ health: MqttBeatHealth | null;
38
+ now: number;
39
+ serverHintMs?: number;
40
+ busy?: boolean;
41
+ }): number {
42
+ const hint = input.serverHintMs ?? BEAT_FAST_MS;
43
+ if (input.busy || !input.health) return BEAT_FAST_MS;
44
+ const local = mqttBeatHealthy(input.health, input.now) ? BEAT_SLOW_MS : BEAT_FAST_MS;
45
+ return Math.min(local, hint);
46
+ }
@@ -0,0 +1,60 @@
1
+ import { useEffect, useRef } from "react";
2
+ import type { MqttBeatHealth } from "./beat-policy";
3
+ import { useLivestreamHost } from "../provider";
4
+ import {
5
+ DeviceAgent,
6
+ type DeviceAgentDeviceInfo,
7
+ type DeviceAgentTransport,
8
+ type DeviceControlChannel,
9
+ type DeviceHeartbeatExtras,
10
+ type RemoteStartConfig,
11
+ } from "./agent";
12
+
13
+ /**
14
+ * Bật device-agent: đăng ký thiết bị + heartbeat presence khi app mở, nhận
15
+ * lệnh start/stop livestream từ dashboard qua kênh điều khiển on-demand.
16
+ * Render bên trong PickleballLiveProvider (thường là child đầu tiên).
17
+ * Tách khỏi provider để app không dùng tính năng này không bundle convex.
18
+ */
19
+ export interface PickleballDeviceAgentProps {
20
+ transport: DeviceAgentTransport;
21
+ remoteStart: RemoteStartConfig;
22
+ deviceInfo?: DeviceAgentDeviceInfo;
23
+ onRemoteSessionStarted?: (sessionId: string) => void;
24
+ createControlChannel?: (convexUrl: string) => DeviceControlChannel;
25
+ /** Field bổ sung cho heartbeat (VD recentExits) — đọc qua ref nên đổi hàm không dựng lại agent. */
26
+ heartbeatExtras?: () => DeviceHeartbeatExtras | undefined;
27
+ /**
28
+ * Sức khoẻ kênh MQTT của app — nguồn để agent giãn nhịp beat khi kênh khoẻ
29
+ * VÀ server bật rollout. Bỏ trống = agent giữ nhịp cũ.
30
+ */
31
+ mqttHealth?: () => MqttBeatHealth | null;
32
+ }
33
+
34
+ export function PickleballDeviceAgent(props: PickleballDeviceAgentProps) {
35
+ const host = useLivestreamHost();
36
+
37
+ // Giữ tham chiếu mới nhất để agent (tạo 1 lần) luôn gọi callback hiện hành
38
+ const propsRef = useRef(props);
39
+ propsRef.current = props;
40
+
41
+ useEffect(() => {
42
+ const agent = new DeviceAgent({
43
+ engine: host.engine,
44
+ bootstrapInput: host.bootstrapInput,
45
+ deviceMetrics: host.deviceMetrics,
46
+ transport: propsRef.current.transport,
47
+ remoteStart: propsRef.current.remoteStart,
48
+ deviceInfo: propsRef.current.deviceInfo,
49
+ createControlChannel: propsRef.current.createControlChannel,
50
+ onRemoteSessionStarted: (sessionId) =>
51
+ propsRef.current.onRemoteSessionStarted?.(sessionId),
52
+ heartbeatExtras: () => propsRef.current.heartbeatExtras?.(),
53
+ mqttHealth: () => propsRef.current.mqttHealth?.() ?? null,
54
+ });
55
+ agent.start();
56
+ return () => agent.stop();
57
+ }, [host]);
58
+
59
+ return null;
60
+ }
@@ -0,0 +1,140 @@
1
+ import type { BootstrapInput } from "../contracts";
2
+ import type {
3
+ DeviceAgentDeviceInfo,
4
+ DeviceAgentTransport,
5
+ DeviceControlSessionGrant,
6
+ DeviceHeartbeatBody,
7
+ } from "./agent";
8
+ import type { ConvexDeviceRealtime } from "./convex-command-subscription";
9
+
10
+ /**
11
+ * Bọc transport HTTP để heartbeat đi đường WEBSOCKET (mutation deviceApp.beat,
12
+ * 1 function call) thay vì POST /devices/heartbeat (httpAction→runMutation,
13
+ * 2 call). Ở 1.000 máy nhịp 12s, đó là 216M call/tháng so với 108M — một nửa
14
+ * hoá đơn chỉ bằng cách đổi đường vận chuyển, không đổi ngữ nghĩa.
15
+ *
16
+ * register/pairing GIỮ HTTP: đó là lúc máy CHƯA có deviceToken (chưa auth WS
17
+ * được), và là việc hiếm — không đáng tối ưu.
18
+ *
19
+ * Luật fallback: mọi lỗi WS (mất mạng, token hỏng, server chưa deploy beat...)
20
+ * → rơi về HTTP NGAY trong nhịp đó, và nghỉ thử WS một quãng để mỗi beat
21
+ * không phải trả thêm một lần timeout khi WS đang hỏng kéo dài. Máy bản này chạy
22
+ * với server cũ chưa có `deviceApp.beat` vẫn sống bình thường qua HTTP.
23
+ */
24
+
25
+ /** WS hỏng thì nghỉ thử ngần này rồi mới thử lại — tránh mỗi beat +10s timeout. */
26
+ const WS_BEAT_RETRY_MS = 60_000;
27
+ /**
28
+ * Trần chờ một lượt beat WS. PHẢI có: ConvexClient.mutation KHÔNG tự timeout —
29
+ * mất mạng là nó xếp hàng chờ reconnect vô hạn, thiếu trần này thì heartbeat
30
+ * treo và không bao giờ rơi về HTTP. Mutation xếp hàng có thể vẫn chạy SAU khi
31
+ * mạng về → beat đúp — vô hại, heartbeat lặp là ngữ nghĩa bình thường.
32
+ */
33
+ const WS_BEAT_TIMEOUT_MS = 10_000;
34
+
35
+ export interface ConvexBeatOptions {
36
+ /** deviceToken hiện tại (đọc mỗi nhịp — token có thể vừa được cấp/đổi). */
37
+ getDeviceToken: () => string | null | undefined;
38
+ /**
39
+ * Cùng hook với createHttpDeviceAgentTransport.onData: đường WS phải đổ
40
+ * envelope y hệt để handleTransportData (claim/pairing/assignedCourt/
41
+ * activeSession/preview) không phân biệt beat đến từ đường nào.
42
+ */
43
+ onData?: (endpoint: "heartbeat", data: Record<string, unknown>) => void;
44
+ timeoutMs?: number;
45
+ /** Test hook. */
46
+ now?: () => number;
47
+ }
48
+
49
+ export function withConvexBeat(
50
+ base: DeviceAgentTransport,
51
+ realtime: ConvexDeviceRealtime,
52
+ options: ConvexBeatOptions,
53
+ ): DeviceAgentTransport {
54
+ const now = options.now ?? Date.now;
55
+ const timeoutMs = options.timeoutMs ?? WS_BEAT_TIMEOUT_MS;
56
+ let wsBrokenUntil = 0;
57
+
58
+ return {
59
+ register(input: BootstrapInput & DeviceAgentDeviceInfo) {
60
+ return base.register(input);
61
+ },
62
+ async heartbeat(input: BootstrapInput & DeviceHeartbeatBody) {
63
+ const deviceToken = options.getDeviceToken();
64
+ if (!deviceToken || now() < wsBrokenUntil) {
65
+ return base.heartbeat(input);
66
+ }
67
+ try {
68
+ const data = await withTimeout(
69
+ realtime.beat({
70
+ deviceToken,
71
+ // Server tra máy theo token — bootstrapInput (installationId…) chỉ
72
+ // đường HTTP cần. Chỉ gửi đúng phần mutation beat nhận.
73
+ agentState: input.agentState,
74
+ currentSessionId: input.currentSessionId,
75
+ ...(input.appVersion !== undefined ? { appVersion: input.appVersion } : {}),
76
+ ...(input.metrics !== undefined ? { metrics: input.metrics } : {}),
77
+ ...(input.recentExits !== undefined && input.recentExits.length > 0
78
+ ? { recentExits: input.recentExits }
79
+ : {}),
80
+ ...(input.powerState !== undefined ? { powerState: input.powerState } : {}),
81
+ }),
82
+ timeoutMs,
83
+ );
84
+ options.onData?.("heartbeat", data);
85
+ // Cùng lý do với http-transport: transport là nơi DUY NHẤT dữ liệu beat
86
+ // đi qua, không chuyển tiếp ở đây là agent vĩnh viễn không thấy hint.
87
+ return {
88
+ controlSession: parseControlSession(data.controlSession),
89
+ ...(typeof data.beatIntervalMs === "number" &&
90
+ Number.isFinite(data.beatIntervalMs)
91
+ ? { beatIntervalMs: data.beatIntervalMs }
92
+ : {}),
93
+ };
94
+ } catch {
95
+ // Không phân loại lỗi: nguyên nhân nào thì hành động cũng là dùng HTTP
96
+ // nhịp này và nghỉ thử WS một quãng.
97
+ wsBrokenUntil = now() + WS_BEAT_RETRY_MS;
98
+ return base.heartbeat(input);
99
+ }
100
+ },
101
+ };
102
+ }
103
+
104
+ /** Cùng luật parse với đường HTTP — grant lệch dạng coi như beat hỏng (throw). */
105
+ function parseControlSession(grant: unknown): DeviceControlSessionGrant | null {
106
+ if (grant === null || grant === undefined) return null;
107
+ if (
108
+ grant === null ||
109
+ typeof grant !== "object" ||
110
+ typeof (grant as Record<string, unknown>).id !== "string" ||
111
+ typeof (grant as Record<string, unknown>).token !== "string" ||
112
+ typeof (grant as Record<string, unknown>).convexUrl !== "string" ||
113
+ (grant as Record<string, unknown>).convexUrl === ""
114
+ ) {
115
+ throw new Error("Invalid controlSession in beat response");
116
+ }
117
+ const record = grant as Record<string, unknown>;
118
+ return {
119
+ id: record.id as string,
120
+ token: record.token as string,
121
+ convexUrl: record.convexUrl as string,
122
+ };
123
+ }
124
+
125
+ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
126
+ return new Promise<T>((resolve, reject) => {
127
+ const timer = setTimeout(() => reject(new Error("beat timeout")), ms);
128
+ (timer as unknown as { unref?: () => void }).unref?.();
129
+ promise.then(
130
+ (value) => {
131
+ clearTimeout(timer);
132
+ resolve(value);
133
+ },
134
+ (error) => {
135
+ clearTimeout(timer);
136
+ reject(error);
137
+ },
138
+ );
139
+ });
140
+ }
@@ -0,0 +1,101 @@
1
+ import { ConvexClient } from "convex/browser";
2
+ import { anyApi } from "convex/server";
3
+ import type { SdkTelemetryCredentials, SessionCommandRecord } from "../contracts";
4
+ import type { SessionCommandSubscription } from "../engine";
5
+
6
+ /**
7
+ * Kênh nhận lệnh device-rtmp qua WEBSOCKET (Convex subscription) thay poll HTTP
8
+ * 2 giây. Vì sao đổi: poll 2s là 2 function call mỗi 2 giây MỖI MÁY chạy suốt
9
+ * ngày đêm — ở 200 máy là ~518 triệu call/tháng, gấp 20 lần quota gói Pro.
10
+ * Subscription chỉ chạy lại (và tính tiền) KHI hàng lệnh của phiên đổi: lệnh về
11
+ * tức thì (<1s), còn lúc rảnh gần như 0.
12
+ *
13
+ * CHỈ dùng cho máy FIRST-PARTY nối thẳng Convex (có URL .convex.cloud). Máy đối
14
+ * tác đi qua proxy không có đường websocket trực tiếp → giữ nguyên poll HTTP.
15
+ *
16
+ * Auth: gửi telemetry token THÔ làm tham số query, backend hash server-side
17
+ * (sessionCommands.pendingForSession) — client không có crypto. Cùng lối với
18
+ * deviceControl.pendingCommands (cũng nhận token thô trong query).
19
+ *
20
+ * ConvexClient được dựng MỘT lần và tái dùng qua mọi phiên: engine chỉ
21
+ * subscribe/unsubscribe theo phiên, KHÔNG đóng client giữa chừng (đóng rồi mở
22
+ * lại là bắt tay websocket mới mỗi lần đổi độ phân giải/phiên).
23
+ */
24
+ export function createConvexCommandSubscription(
25
+ convexUrl: string,
26
+ ): SessionCommandSubscription {
27
+ return createConvexDeviceRealtime(convexUrl).commandSubscription;
28
+ }
29
+
30
+ /**
31
+ * Mọi kênh Convex realtime của máy first-party trên MỘT websocket duy nhất:
32
+ * subscription lệnh (đợt 2) + heartbeat 1-call (đợt 3). Convex client tự
33
+ * multiplex query/mutation trên cùng socket — dựng hai client là trả giá hai
34
+ * lần bắt tay + hai kết nối phải nuôi.
35
+ */
36
+ export interface ConvexDeviceRealtime {
37
+ commandSubscription: SessionCommandSubscription;
38
+ /**
39
+ * Heartbeat qua websocket = 1 function call (mutation `deviceApp.beat`),
40
+ * thay cho đường HTTP httpAction→runMutation = 2 call. Trả envelope `data`
41
+ * thô cùng hợp đồng với POST /devices/heartbeat. NÉM lỗi khi thất bại —
42
+ * caller (withConvexBeat) tự rơi về HTTP.
43
+ */
44
+ beat(args: Record<string, unknown>): Promise<Record<string, unknown>>;
45
+ dispose(): void;
46
+ }
47
+
48
+ export function createConvexDeviceRealtime(convexUrl: string): ConvexDeviceRealtime {
49
+ const client = new ConvexClient(convexUrl);
50
+ return {
51
+ commandSubscription: {
52
+ subscribe(
53
+ credentials: SdkTelemetryCredentials,
54
+ sessionId: string,
55
+ onCommands: (commands: SessionCommandRecord[]) => void,
56
+ onError?: (error: Error) => void,
57
+ ) {
58
+ return client.onUpdate(
59
+ anyApi.sessionCommands.pendingForSession,
60
+ { sessionId, token: credentials.token, after: 0 },
61
+ (value) => onCommands(toCommands(value)),
62
+ (error) => onError?.(error),
63
+ );
64
+ },
65
+ // Client dùng chung với beat — đóng ở dispose() của cả cụm, không ở đây.
66
+ dispose() {},
67
+ },
68
+ async beat(args) {
69
+ const result = await client.mutation(anyApi.deviceApp.beat, args);
70
+ return result !== null && typeof result === "object" && !Array.isArray(result)
71
+ ? (result as Record<string, unknown>)
72
+ : {};
73
+ },
74
+ dispose() {
75
+ void client.close();
76
+ },
77
+ };
78
+ }
79
+
80
+ /** Lọc payload query thành mảng lệnh hợp lệ — bỏ mọi thứ không đúng dạng. */
81
+ function toCommands(value: unknown): SessionCommandRecord[] {
82
+ if (value === null || typeof value !== "object") return [];
83
+ const commands = (value as { commands?: unknown }).commands;
84
+ if (!Array.isArray(commands)) return [];
85
+ const out: SessionCommandRecord[] = [];
86
+ for (const item of commands) {
87
+ if (item === null || typeof item !== "object") continue;
88
+ const record = item as Record<string, unknown>;
89
+ if (
90
+ typeof record.id === "string" &&
91
+ (record.action === "start" ||
92
+ record.action === "stop" ||
93
+ record.action === "end") &&
94
+ typeof record.issuedAt === "number" &&
95
+ Number.isFinite(record.issuedAt)
96
+ ) {
97
+ out.push({ id: record.id, action: record.action, issuedAt: record.issuedAt });
98
+ }
99
+ }
100
+ return out;
101
+ }
@@ -0,0 +1,88 @@
1
+ import { ConvexClient } from "convex/browser";
2
+ import { anyApi } from "convex/server";
3
+ import type { DeviceCommand, DeviceControlChannel } from "./agent";
4
+
5
+ /**
6
+ * Kênh điều khiển realtime mặc định: Convex websocket subscribe các function
7
+ * deviceControl (auth bằng connect-token nhận qua heartbeat — thiết bị không
8
+ * có account, pattern pairingCode). Chỉ mở khi dashboard yêu cầu "Kết nối",
9
+ * đóng ngay khi kênh kết thúc → không giữ websocket lúc idle.
10
+ */
11
+ export function createConvexControlChannel(convexUrl: string): DeviceControlChannel {
12
+ const client = new ConvexClient(convexUrl);
13
+ const deviceControl = anyApi.deviceControl;
14
+
15
+ function toCommands(value: unknown): DeviceCommand[] {
16
+ if (!Array.isArray(value)) return [];
17
+ const commands: DeviceCommand[] = [];
18
+ for (const item of value) {
19
+ if (item === null || typeof item !== "object") continue;
20
+ const record = item as Record<string, unknown>;
21
+ if (
22
+ typeof record.id !== "string" ||
23
+ (record.type !== "start_livestream" &&
24
+ record.type !== "stop_livestream" &&
25
+ record.type !== "publish_livestream" &&
26
+ record.type !== "unpublish_livestream") ||
27
+ typeof record.expiresAt !== "number"
28
+ ) {
29
+ continue;
30
+ }
31
+ const payload =
32
+ record.payload !== null &&
33
+ typeof record.payload === "object" &&
34
+ !Array.isArray(record.payload)
35
+ ? (record.payload as Record<string, unknown>)
36
+ : {};
37
+ commands.push({
38
+ id: record.id,
39
+ type: record.type,
40
+ payload: {
41
+ ...(typeof payload.title === "string" ? { title: payload.title } : {}),
42
+ ...(payload.visibility === "public" || payload.visibility === "private"
43
+ ? { visibility: payload.visibility }
44
+ : {}),
45
+ ...(typeof payload.courtRef === "string" && payload.courtRef.trim() !== ""
46
+ ? { courtRef: payload.courtRef }
47
+ : {}),
48
+ },
49
+ expiresAt: record.expiresAt,
50
+ });
51
+ }
52
+ return commands;
53
+ }
54
+
55
+ return {
56
+ async attach(controlSessionId, token) {
57
+ await client.mutation(deviceControl.deviceAttach, { controlSessionId, token });
58
+ },
59
+ onCommands(controlSessionId, token, callback) {
60
+ return client.onUpdate(
61
+ deviceControl.pendingCommands,
62
+ { controlSessionId, token },
63
+ (value) => callback(toCommands(value)),
64
+ );
65
+ },
66
+ onState(controlSessionId, token, callback) {
67
+ return client.onUpdate(
68
+ deviceControl.controlSessionState,
69
+ { controlSessionId, token },
70
+ (value) => {
71
+ const status =
72
+ value !== null &&
73
+ typeof value === "object" &&
74
+ typeof (value as Record<string, unknown>).status === "string"
75
+ ? ((value as Record<string, unknown>).status as string)
76
+ : "ended";
77
+ callback({ status });
78
+ },
79
+ );
80
+ },
81
+ async ack(input) {
82
+ await client.mutation(deviceControl.ackCommand, { ...input });
83
+ },
84
+ close() {
85
+ void client.close();
86
+ },
87
+ };
88
+ }
@@ -0,0 +1,164 @@
1
+ import type { BootstrapInput } from "../contracts";
2
+ import type {
3
+ DeviceAgentDeviceInfo,
4
+ DeviceAgentTransport,
5
+ DeviceControlSessionGrant,
6
+ DeviceHeartbeatBody,
7
+ } from "./agent";
8
+ import {
9
+ ExpoSdkConfigurationError,
10
+ ExpoSdkTimeoutError,
11
+ InvalidResponseError,
12
+ NetworkUnavailableError,
13
+ PickleballExpoError,
14
+ errorFromCode,
15
+ } from "../errors";
16
+
17
+ type Fetch = typeof globalThis.fetch;
18
+ type HeadersInput = Record<string, string>;
19
+
20
+ export interface HttpDeviceAgentTransportOptions {
21
+ /** Base URL của proxy đối tác (cùng proxy với session provider). */
22
+ baseUrl: string;
23
+ fetch?: Fetch;
24
+ headers?: HeadersInput | (() => HeadersInput | Promise<HeadersInput>);
25
+ timeoutMs?: number;
26
+ paths?: Partial<{ register: string; heartbeat: string }>;
27
+ /**
28
+ * Nhận envelope `data` thô sau khi transport validate xong. Validator phía
29
+ * dưới drop field ngoài contract (deviceToken/pairing/claimed/ownerName của
30
+ * đường first-party) — app device-streamer đọc chúng qua hook này; contract
31
+ * partner cũ không đổi.
32
+ */
33
+ onData?: (endpoint: "register" | "heartbeat", data: Record<string, unknown>) => void;
34
+ }
35
+
36
+ const defaultPaths = {
37
+ register: "/devices/register",
38
+ heartbeat: "/devices/heartbeat",
39
+ };
40
+
41
+ /** Transport HTTP cho DeviceAgent — mirror createHttpSessionProvider. */
42
+ export function createHttpDeviceAgentTransport(
43
+ options: HttpDeviceAgentTransportOptions,
44
+ ): DeviceAgentTransport {
45
+ const baseUrl = options.baseUrl.replace(/\/+$/, "");
46
+ if (baseUrl === "") {
47
+ throw new ExpoSdkConfigurationError("baseUrl must be a non-empty string");
48
+ }
49
+ const fetchImplementation = options.fetch ?? globalThis.fetch;
50
+ if (typeof fetchImplementation !== "function") {
51
+ throw new ExpoSdkConfigurationError("A Fetch API implementation is required");
52
+ }
53
+ const paths = { ...defaultPaths, ...options.paths };
54
+
55
+ async function post(path: string, body: unknown): Promise<unknown> {
56
+ const controller = new AbortController();
57
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 10_000);
58
+ try {
59
+ const extraHeaders =
60
+ typeof options.headers === "function"
61
+ ? await options.headers()
62
+ : (options.headers ?? {});
63
+ const response = await fetchImplementation(
64
+ `${baseUrl}/${path.replace(/^\/+/, "")}`,
65
+ {
66
+ method: "POST",
67
+ headers: { "Content-Type": "application/json", ...extraHeaders },
68
+ body: JSON.stringify(body),
69
+ signal: controller.signal,
70
+ redirect: "error",
71
+ },
72
+ );
73
+ const envelope = await response.json().catch(() => {
74
+ throw new InvalidResponseError();
75
+ });
76
+ if (!response.ok) throw parseProxyError(envelope);
77
+ if (!isRecord(envelope) || !("data" in envelope)) {
78
+ throw new InvalidResponseError();
79
+ }
80
+ return envelope.data;
81
+ } catch (error) {
82
+ if (controller.signal.aborted) throw new ExpoSdkTimeoutError();
83
+ if (error instanceof PickleballExpoError) throw error;
84
+ if (error instanceof TypeError) {
85
+ throw new NetworkUnavailableError(undefined, { cause: error });
86
+ }
87
+ throw error;
88
+ } finally {
89
+ clearTimeout(timeout);
90
+ }
91
+ }
92
+
93
+ return {
94
+ async register(input: BootstrapInput & DeviceAgentDeviceInfo) {
95
+ const data = await post(paths.register, input);
96
+ if (
97
+ !isRecord(data) ||
98
+ typeof data.deviceId !== "string" ||
99
+ typeof data.heartbeatIntervalMs !== "number" ||
100
+ !Number.isFinite(data.heartbeatIntervalMs)
101
+ ) {
102
+ throw new InvalidResponseError();
103
+ }
104
+ options.onData?.("register", data);
105
+ return {
106
+ deviceId: data.deviceId,
107
+ heartbeatIntervalMs: data.heartbeatIntervalMs,
108
+ };
109
+ },
110
+ async heartbeat(input: BootstrapInput & DeviceHeartbeatBody) {
111
+ const data = await post(paths.heartbeat, input);
112
+ if (!isRecord(data)) throw new InvalidResponseError();
113
+ options.onData?.("heartbeat", data);
114
+ // Nhịp server gợi ý cho lượt beat kế (adaptive heartbeat). Transport là
115
+ // NƠI DUY NHẤT dữ liệu beat đi qua, nên field nào không được chuyển tiếp
116
+ // ở đây thì agent vĩnh viễn không thấy — dù server trả đúng và app đã có
117
+ // code đọc. Đúng ca đã mất một buổi truy 02/09: server trả 60s, bundle
118
+ // app có code mới, mà máy vẫn beat 12s vì hint bị chặn ngay chỗ này.
119
+ const beatIntervalMs =
120
+ typeof data.beatIntervalMs === "number" && Number.isFinite(data.beatIntervalMs)
121
+ ? { beatIntervalMs: data.beatIntervalMs }
122
+ : {};
123
+ const grant = data.controlSession;
124
+ if (grant === null || grant === undefined) {
125
+ return { controlSession: null, ...beatIntervalMs };
126
+ }
127
+ if (
128
+ !isRecord(grant) ||
129
+ typeof grant.id !== "string" ||
130
+ typeof grant.token !== "string" ||
131
+ typeof grant.convexUrl !== "string" ||
132
+ grant.convexUrl === ""
133
+ ) {
134
+ throw new InvalidResponseError();
135
+ }
136
+ return {
137
+ controlSession: {
138
+ id: grant.id,
139
+ token: grant.token,
140
+ convexUrl: grant.convexUrl,
141
+ } satisfies DeviceControlSessionGrant,
142
+ ...beatIntervalMs,
143
+ };
144
+ },
145
+ };
146
+ }
147
+
148
+ function parseProxyError(envelope: unknown) {
149
+ if (
150
+ isRecord(envelope) &&
151
+ isRecord(envelope.error) &&
152
+ typeof envelope.error.code === "string"
153
+ ) {
154
+ return errorFromCode(
155
+ envelope.error.code,
156
+ typeof envelope.error.message === "string" ? envelope.error.message : undefined,
157
+ );
158
+ }
159
+ return new InvalidResponseError();
160
+ }
161
+
162
+ function isRecord(value: unknown): value is Record<string, unknown> {
163
+ return typeof value === "object" && value !== null && !Array.isArray(value);
164
+ }
@@ -0,0 +1,26 @@
1
+ export * from "./agent";
2
+ export * from "./http-transport";
3
+ export * from "./tournament";
4
+ export { createConvexControlChannel } from "./convex-control-channel";
5
+ export {
6
+ createConvexCommandSubscription,
7
+ createConvexDeviceRealtime,
8
+ type ConvexDeviceRealtime,
9
+ } from "./convex-command-subscription";
10
+ export { withConvexBeat, type ConvexBeatOptions } from "./convex-beat-transport";
11
+ export { PickleballDeviceAgent, type PickleballDeviceAgentProps } from "./component";
12
+ export { createMqttChannel } from "./mqtt-channel";
13
+ export type {
14
+ MqttChannel,
15
+ MqttChannelDeps,
16
+ MqttChannelHealth,
17
+ MqttChannelOptions,
18
+ MqttSocket,
19
+ } from "./mqtt-channel";
20
+ export {
21
+ BEAT_FAST_MS,
22
+ BEAT_SLOW_MS,
23
+ mqttBeatHealthy,
24
+ nextBeatDelayMs,
25
+ type MqttBeatHealth,
26
+ } from "./beat-policy";