@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
package/src/provider.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useRef, useSyncExternalStore, type ReactNode } from "react";
1
+ import React, { useEffect, useMemo, useRef, useSyncExternalStore, type ReactNode } from "react";
2
2
  import { AppState } from "react-native";
3
3
  import type { BootstrapInput, LivestreamSessionProvider, StartLivestreamInput, EndReason } from "./contracts";
4
4
  import { ExpoSdkConfigurationError, type PickleballExpoError } from "./errors";
@@ -10,9 +10,12 @@ import {
10
10
  type SdkNetwork,
11
11
  type SdkRuntime,
12
12
  type SdkStorage,
13
+ type SessionCommandChannel,
14
+ type SessionCommandSubscription,
13
15
  type TelemetrySink,
14
16
  } from "./engine";
15
17
  import {
18
+ createDefaultCommandChannel,
16
19
  createDefaultDeviceMetrics,
17
20
  createDefaultNetwork,
18
21
  createDefaultRoomFactory,
@@ -64,10 +67,33 @@ export interface PickleballLiveProviderProps {
64
67
  children?: ReactNode;
65
68
  sessionProvider: LivestreamSessionProvider;
66
69
  bootstrapInput: BootstrapInput;
70
+ /**
71
+ * Phiên phải SỐNG TIẾP khi app biến mất giữa chừng (sụp nguồn, bị vuốt tắt,
72
+ * component remount), để lần mở sau nhận lại đúng phiên cũ — giữ nguyên link
73
+ * xem và VOD liền một mạch.
74
+ *
75
+ * Mặc định false = hành vi cũ: mở app lại là dọn sạch phiên cũ. App gác sân bật
76
+ * cờ này khi máy đang được gán sân; máy không gán sân vẫn dọn như cũ để không
77
+ * để lại phiên treo trong các ca ngoài giải.
78
+ *
79
+ * Nhận boolean HOẶC hàm (được phép async). App nên truyền HÀM ĐỌC THẲNG ĐĨA:
80
+ * engine hỏi cờ này đúng lúc khởi động, thường TRƯỚC khi state React kịp
81
+ * restore — truyền boolean suy từ state là dính race keep=false oan (đã dính
82
+ * thật 18/08/2026, sinh phiên chờ thứ hai đè phiên live cũ).
83
+ */
84
+ keepSessionOnRestart?: boolean | (() => boolean | Promise<boolean>);
67
85
  runtime?: SdkRuntime;
68
86
  storage?: SdkStorage;
69
87
  network?: SdkNetwork;
70
88
  telemetry?: TelemetrySink;
89
+ /** Kênh poll lệnh cho mode device-rtmp — mặc định suy từ telemetry endpoint. */
90
+ commandChannel?: SessionCommandChannel;
91
+ /**
92
+ * Kênh nhận lệnh qua websocket (first-party nối thẳng Convex). Có thì lệnh về
93
+ * tức thì và poll tụt về nhịp reconcile chậm — cắt phần lớn function call của
94
+ * đường poll. Thiếu (máy đối tác qua proxy) thì chỉ dùng poll HTTP như cũ.
95
+ */
96
+ commandSubscription?: SessionCommandSubscription;
71
97
  roomFactory?: () => RoomAdapter;
72
98
  deviceMetrics?: EngineDependencies["deviceMetrics"];
73
99
  theme?: Partial<LivestreamTheme>;
@@ -75,6 +101,28 @@ export interface PickleballLiveProviderProps {
75
101
  onError?: (error: PickleballExpoError) => void;
76
102
  }
77
103
 
104
+ /**
105
+ * Context nội bộ cho các module opt-in (VD PickleballDeviceAgent) cần truy cập
106
+ * engine trực tiếp — export qua "./advanced", không thuộc API tích hợp cơ bản.
107
+ */
108
+ export interface LivestreamHostContextValue {
109
+ engine: LivestreamEngine;
110
+ bootstrapInput: BootstrapInput;
111
+ deviceMetrics: NonNullable<EngineDependencies["deviceMetrics"]>;
112
+ }
113
+
114
+ const LivestreamHostContext = React.createContext<LivestreamHostContextValue | null>(null);
115
+
116
+ export function useLivestreamHost(): LivestreamHostContextValue {
117
+ const value = React.use(LivestreamHostContext);
118
+ if (!value) {
119
+ throw new ExpoSdkConfigurationError(
120
+ "useLivestreamHost must be used inside PickleballLiveProvider",
121
+ );
122
+ }
123
+ return value;
124
+ }
125
+
78
126
  export interface LivestreamContextValue extends LivestreamSnapshot {
79
127
  locale: "vi" | "en";
80
128
  theme: LivestreamTheme;
@@ -99,8 +147,35 @@ const LivestreamContext = React.createContext<LivestreamContextValue | null>(nul
99
147
 
100
148
  export function PickleballLiveProvider(props: PickleballLiveProviderProps) {
101
149
  const engineRef = useRef<LivestreamEngine | null>(null);
150
+ const keepSessionRef = useRef(props.keepSessionOnRestart);
151
+ keepSessionRef.current = props.keepSessionOnRestart;
152
+ // dispose() phải quyết NGAY (sync) có giữ phiên hay không, còn prop dạng hàm
153
+ // thì async — resolve sẵn vào ref này. Khi prop là hàm mà chưa có kết quả,
154
+ // nghiêng về GIỮ: dọn oan là mất phiên vĩnh viễn (link đổi, VOD tách đôi),
155
+ // giữ oan thì lưới server (1 giờ live / 30 phút standby) dọn hộ.
156
+ const resolvedKeepRef = useRef(typeof props.keepSessionOnRestart === "function");
157
+ useEffect(() => {
158
+ const value = props.keepSessionOnRestart;
159
+ if (typeof value !== "function") {
160
+ resolvedKeepRef.current = value === true;
161
+ return;
162
+ }
163
+ resolvedKeepRef.current = true;
164
+ let cancelled = false;
165
+ void Promise.resolve()
166
+ .then(() => value())
167
+ .then((keep) => {
168
+ if (!cancelled) resolvedKeepRef.current = keep === true;
169
+ })
170
+ .catch(() => undefined);
171
+ return () => {
172
+ cancelled = true;
173
+ };
174
+ }, [props.keepSessionOnRestart]);
175
+ const hostRef = useRef<LivestreamHostContextValue | null>(null);
102
176
  const effectGenerationRef = useRef(0);
103
177
  if (!engineRef.current) {
178
+ const deviceMetrics = props.deviceMetrics ?? createDefaultDeviceMetrics();
104
179
  const dependencies: EngineDependencies = {
105
180
  sessionProvider: props.sessionProvider,
106
181
  bootstrapInput: props.bootstrapInput,
@@ -108,10 +183,26 @@ export function PickleballLiveProvider(props: PickleballLiveProviderProps) {
108
183
  storage: props.storage ?? createDefaultStorage(),
109
184
  network: props.network ?? createDefaultNetwork(),
110
185
  telemetry: props.telemetry ?? createDefaultTelemetry(),
111
- deviceMetrics: props.deviceMetrics ?? createDefaultDeviceMetrics(),
186
+ commandChannel: props.commandChannel ?? createDefaultCommandChannel(),
187
+ ...(props.commandSubscription
188
+ ? { commandSubscription: props.commandSubscription }
189
+ : {}),
190
+ deviceMetrics,
112
191
  roomFactory: props.roomFactory ?? createDefaultRoomFactory(),
192
+ // Đọc qua ref để engine luôn thấy giá trị MỚI NHẤT: engine chỉ dựng một lần
193
+ // (engineRef), còn prop thì đổi theo việc máy được gán/gỡ sân. Prop dạng
194
+ // hàm được gọi xuyên qua (engine tự await) — đường cho app đọc thẳng đĩa.
195
+ keepSessionOnRestart: () => {
196
+ const value = keepSessionRef.current;
197
+ return typeof value === "function" ? value() : value === true;
198
+ },
113
199
  };
114
200
  engineRef.current = new LivestreamEngine(dependencies);
201
+ hostRef.current = {
202
+ engine: engineRef.current,
203
+ bootstrapInput: props.bootstrapInput,
204
+ deviceMetrics,
205
+ };
115
206
  }
116
207
  const engine = engineRef.current;
117
208
  const snapshot = useSyncExternalStore(
@@ -132,7 +223,7 @@ export function PickleballLiveProvider(props: PickleballLiveProviderProps) {
132
223
  subscription.remove();
133
224
  queueMicrotask(() => {
134
225
  if (effectGenerationRef.current === effectGeneration) {
135
- void engine.dispose();
226
+ void engine.dispose({ keepSession: resolvedKeepRef.current });
136
227
  }
137
228
  });
138
229
  };
@@ -142,26 +233,42 @@ export function PickleballLiveProvider(props: PickleballLiveProviderProps) {
142
233
  if (snapshot.error) props.onError?.(snapshot.error);
143
234
  }, [props.onError, snapshot.error]);
144
235
 
145
- const value: LivestreamContextValue = {
146
- ...snapshot,
147
- locale: props.locale ?? "vi",
148
- theme: { ...defaultLivestreamTheme, ...props.theme },
149
- prepare: () => engine.prepare(),
150
- start: (input, options) => engine.start(input, options),
151
- startPublishing: () => engine.startPublishing(),
152
- stopPublishing: () => engine.stopPublishing(),
153
- stop: (reason) => engine.stop(reason),
154
- retry: () => engine.retry(),
155
- switchCamera: () => engine.switchCamera(),
156
- setMicrophoneEnabled: (enabled) => engine.setMicrophoneEnabled(enabled),
157
- setCaptureOrientation: (orientation) =>
158
- engine.setCaptureOrientation(orientation),
159
- selectCamera: (deviceId) => engine.selectCamera(deviceId),
160
- };
236
+ // Hành động ổn định theo engine (không dựng lại mỗi render): các effect phía
237
+ // trên (AutoResume, useStandbySession, app đối tác) đặt chúng vào dependency
238
+ // dựng lại mỗi render là effect huỷ/chạy lại theo nhịp render, timer thử
239
+ // lại không bao giờ kịp nổ.
240
+ const actions = useMemo(
241
+ () => ({
242
+ prepare: () => engine.prepare(),
243
+ start: (input: StartLivestreamInput, options?: { autoPublish?: boolean }) =>
244
+ engine.start(input, options),
245
+ startPublishing: () => engine.startPublishing(),
246
+ stopPublishing: () => engine.stopPublishing(),
247
+ stop: (reason?: EndReason) => engine.stop(reason),
248
+ retry: () => engine.retry(),
249
+ switchCamera: () => engine.switchCamera(),
250
+ setMicrophoneEnabled: (enabled: boolean) => engine.setMicrophoneEnabled(enabled),
251
+ setCaptureOrientation: (orientation: "portrait" | "landscape") =>
252
+ engine.setCaptureOrientation(orientation),
253
+ selectCamera: (deviceId: string) => engine.selectCamera(deviceId),
254
+ }),
255
+ [engine],
256
+ );
257
+ const locale = props.locale ?? "vi";
258
+ const theme = useMemo(
259
+ () => ({ ...defaultLivestreamTheme, ...props.theme }),
260
+ [props.theme],
261
+ );
262
+ const value = useMemo<LivestreamContextValue>(
263
+ () => ({ ...snapshot, locale, theme, ...actions }),
264
+ [snapshot, locale, theme, actions],
265
+ );
161
266
  return (
162
- <LivestreamContext.Provider value={value}>
163
- {props.children}
164
- </LivestreamContext.Provider>
267
+ <LivestreamHostContext.Provider value={hostRef.current}>
268
+ <LivestreamContext.Provider value={value}>
269
+ {props.children}
270
+ </LivestreamContext.Provider>
271
+ </LivestreamHostContext.Provider>
165
272
  );
166
273
  }
167
274
 
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Kênh lệnh realtime qua websocket Convex cho app dùng camera grant.
3
+ *
4
+ * Grant có `convexUrl` (origin .convex.cloud) → truyền
5
+ * `commandSubscription={createGrantCommandSubscription(() => grant?.convexUrl)}`
6
+ * vào PickleballLiveProvider: lệnh start/stop/end về tức thì và poll HTTP tụt
7
+ * về nhịp đối chiếu 30s (thay vì 2s/4s — ~3.600 call/giờ/máy).
8
+ *
9
+ * Subpath riêng vì kéo theo `convex`; root entry giữ gọn. `./device-agent` vẫn
10
+ * re-export cùng hàm cho app fleet.
11
+ */
12
+ import type { SdkTelemetryCredentials, SessionCommandRecord } from "../contracts";
13
+ import {
14
+ createConvexCommandSubscription,
15
+ createConvexDeviceRealtime,
16
+ type ConvexDeviceRealtime,
17
+ } from "../device-agent/convex-command-subscription";
18
+ import type { SessionCommandSubscription } from "../engine";
19
+
20
+ export { createConvexCommandSubscription, createConvexDeviceRealtime, type ConvexDeviceRealtime };
21
+
22
+ /**
23
+ * Subscription cho app dùng camera grant: URL Convex nằm TRONG grant
24
+ * (`grant.convexUrl`) nên chỉ biết sau khi start — đọc URL lúc engine subscribe
25
+ * (ngay sau khi vào phiên), dựng MỘT client mỗi origin và tái dùng qua các
26
+ * phiên. Không có URL (deployment không lộ origin chuẩn) → không subscribe,
27
+ * engine giữ poll HTTP như cũ.
28
+ *
29
+ * Dùng với createCameraGrantProvider: `() => provider.getActiveGrant()?.convexUrl`.
30
+ */
31
+ export function createGrantCommandSubscription(
32
+ getConvexUrl: () => string | null | undefined,
33
+ ): SessionCommandSubscription {
34
+ const clients = new Map<string, ConvexDeviceRealtime>();
35
+ return {
36
+ subscribe(
37
+ credentials: SdkTelemetryCredentials,
38
+ sessionId: string,
39
+ onCommands: (commands: SessionCommandRecord[]) => void,
40
+ onError?: (error: Error) => void,
41
+ ) {
42
+ const url = getConvexUrl();
43
+ if (!url) return () => undefined;
44
+ let realtime = clients.get(url);
45
+ if (!realtime) {
46
+ realtime = createConvexDeviceRealtime(url);
47
+ clients.set(url, realtime);
48
+ }
49
+ return realtime.commandSubscription.subscribe(credentials, sessionId, onCommands, onError);
50
+ },
51
+ dispose() {
52
+ for (const realtime of clients.values()) realtime.dispose();
53
+ clients.clear();
54
+ },
55
+ };
56
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Luật quyết định khi nối lại một phiên server còn giữ cho máy này.
3
+ *
4
+ * Chuyển từ apps/device-streamer (src/resume-policy.ts) vào SDK để app đối tác
5
+ * không phải học lại từng cái bẫy đã đo được trên fleet.
6
+ *
7
+ * Vì sao cần tách riêng: bản đầu coi MỌI thất bại là "phiên đã chết" và bỏ cuộc
8
+ * vĩnh viễn sau đúng một lần thử. Hệ quả: máy khởi động lại lúc wifi chưa kịp
9
+ * lên là mất luôn cơ hội nối lại — đúng ca hay gặp nhất, vì máy vừa mở nguồn
10
+ * thì wifi luôn lên chậm hơn app.
11
+ *
12
+ * Phân biệt hai loại thất bại là toàn bộ giá trị của module này:
13
+ * - "gone": server nói phiên không còn. Thử lại bao nhiêu cũng vô ích.
14
+ * - "retry": mạng chưa lên, server trục trặc, phản hồi méo. Phiên có thể vẫn sống.
15
+ *
16
+ * Nghi ngờ thì chọn "retry": đoán nhầm thành "gone" là mất phiên vĩnh viễn (link
17
+ * xem đổi, VOD tách đôi); đoán nhầm thành "retry" chỉ tốn vài request, và đã có
18
+ * lưới server dọn phiên mồ côi.
19
+ */
20
+
21
+ /** Phân loại phản hồi HTTP của endpoint refresh. */
22
+ export type ResumeVerdict = "ok" | "gone" | "retry";
23
+
24
+ /**
25
+ * 404 = phiên biến mất hẳn. 409 = phiên đã kết thúc (mọi namespace trả
26
+ * SESSION_CONFLICT cho thao tác trên phiên ended, và grant bị thay cũng là 409).
27
+ * 410 = Gone theo chuẩn HTTP. Mọi mã khác — kể cả 401/403 — đều là "retry":
28
+ * token có thể được cấp lại ở nhịp sau, và một lỗi xác thực tạm thời không phải
29
+ * bằng chứng phiên đã chết.
30
+ */
31
+ export function classifyResumeStatus(status: number): ResumeVerdict {
32
+ if (status === 404 || status === 409 || status === 410) return "gone";
33
+ if (status >= 200 && status < 300) return "ok";
34
+ return "retry";
35
+ }
36
+
37
+ /**
38
+ * Nhịp thử lại. Nhịp đầu 1 giây vì ca điển hình là "app mở trước khi wifi lên" —
39
+ * chờ vài giây là xong. Trần 15 giây để một máy chết hẳn không nện server mãi;
40
+ * số lần thì KHÔNG giới hạn: thà thử mãi còn hơn mất phiên, và lưới server
41
+ * (1 giờ cho live, 30 phút cho standby) mới là thứ quyết định lúc nào bỏ cuộc.
42
+ */
43
+ export const RESUME_RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 15_000];
44
+
45
+ export function resumeRetryDelayMs(attempt: number): number {
46
+ const index = Math.min(Math.max(attempt, 0), RESUME_RETRY_DELAYS_MS.length - 1);
47
+ return RESUME_RETRY_DELAYS_MS[index];
48
+ }
49
+
50
+ /**
51
+ * Bất kỳ ai định TẠO PHIÊN MỚI tự động (AutoTournament của fleet, useStandbySession
52
+ * của app đối tác) phải NHƯỜNG khi còn phiên chờ nhận lại — nhìn CẢ HAI nguồn.
53
+ *
54
+ * Bản đầu chỉ nhìn serverSession (heartbeat, về SAU vài giây); adoptableSessionId
55
+ * (dấu trên đĩa) có mặt ngay từ lúc engine khởi động xong. Khe hở vài giây đó là
56
+ * đủ để một lượt arm nổ súng trước AutoResume: hoặc đụng chốt marker (app đứng
57
+ * màn LỖI), hoặc tệ hơn — lách qua trong đúng cửa sổ race và tạo phiên chờ THỨ
58
+ * HAI đè phiên live cũ (đo thật đêm 18/08/2026).
59
+ */
60
+ export function resumeBlocksArm(input: {
61
+ adoptableSessionId: string | null;
62
+ serverSessionId: string | null;
63
+ engineSessionId: string | null;
64
+ }): boolean {
65
+ if (input.engineSessionId !== null) return false;
66
+ return input.adoptableSessionId !== null || input.serverSessionId !== null;
67
+ }
68
+
69
+ /** Quy ước externalSessionId của lượt start NHẬN LẠI — engine chỉ cho lượt này qua chốt marker. */
70
+ export function resumeExternalSessionId(sessionId: string): string {
71
+ return `resume-${sessionId}`;
72
+ }
73
+
74
+ /**
75
+ * Grant nối lại chỉ được tiêu thụ cho ĐÚNG phiên nó được cấp. AutoResume có thể
76
+ * bắn nhiều lượt (retry, đổi nguồn); không kiểm khớp thì một grant cũ còn sót có
77
+ * thể bị lượt start của phiên khác nuốt — engine nối vào nhầm phiên, đúng loại lỗi
78
+ * âm thầm khó truy nhất.
79
+ */
80
+ export function resumeGrantMatches(
81
+ externalSessionId: string | undefined,
82
+ grantSessionId: string,
83
+ ): boolean {
84
+ return externalSessionId === resumeExternalSessionId(grantSessionId);
85
+ }
package/src/resume.ts ADDED
@@ -0,0 +1,132 @@
1
+ import type {
2
+ LivestreamSessionProvider,
3
+ SdkSessionGrant,
4
+ StartLivestreamInput,
5
+ } from "./contracts";
6
+ import { isSdkRemoteConfig } from "./contracts";
7
+ import { classifyResumeStatus, resumeGrantMatches } from "./resume-policy";
8
+ import { isFiniteNumber, isRecord, isTelemetryCredentials, type Fetch } from "./session-grant";
9
+
10
+ /**
11
+ * Nhận lại phiên sau khi app bị kill/mở lại — phần PROVIDER.
12
+ *
13
+ * Kịch bản: máy đang phát, app biến mất (sụp nguồn, OEM giết, vuốt tắt). Server
14
+ * còn giữ phiên. Lần mở sau engine thấy dấu trên đĩa (adoptableSessionId) nhưng
15
+ * KHÔNG có grant trong bộ nhớ — phải hỏi server cấp lại credentials của đúng
16
+ * phiên đó rồi start bằng grant ấy thay vì tạo phiên mới. Link xem và VOD nhờ
17
+ * vậy liền một mạch.
18
+ */
19
+
20
+ export type ResumeAttempt =
21
+ | { kind: "grant"; grant: SdkSessionGrant; live: boolean }
22
+ | { kind: "gone" }
23
+ | { kind: "retry" };
24
+
25
+ export interface ResumableSessionProvider extends LivestreamSessionProvider {
26
+ /** Đặt grant cho lượt start kế tiếp có externalSessionId `resume-<id>`. */
27
+ adoptResumeGrant(grant: SdkSessionGrant): void;
28
+ /** Hỏi server cấp lại grant cho phiên còn sống. */
29
+ resume(sessionId: string): Promise<ResumeAttempt>;
30
+ }
31
+
32
+ /**
33
+ * Bọc provider: `start()` ưu tiên grant nhận lại nếu khớp phiên, còn lại tạo phiên
34
+ * mới như thường. Grant chờ nằm trong closure (không biến module) nên hai provider
35
+ * trong cùng app không dẫm nhau.
36
+ */
37
+ export function withResume(
38
+ provider: LivestreamSessionProvider,
39
+ resume: (sessionId: string) => Promise<ResumeAttempt>,
40
+ ): ResumableSessionProvider {
41
+ let pending: SdkSessionGrant | null = null;
42
+ return {
43
+ bootstrap: provider.bootstrap.bind(provider),
44
+ refresh: provider.refresh.bind(provider),
45
+ end: provider.end.bind(provider),
46
+ publish: provider.publish?.bind(provider),
47
+ unpublish: provider.unpublish?.bind(provider),
48
+ async start(input: StartLivestreamInput) {
49
+ const grant = pending;
50
+ if (grant && resumeGrantMatches(input.externalSessionId, grant.sessionId)) {
51
+ pending = null;
52
+ return grant;
53
+ }
54
+ return provider.start(input);
55
+ },
56
+ adoptResumeGrant(grant) {
57
+ pending = grant;
58
+ },
59
+ resume,
60
+ };
61
+ }
62
+
63
+ /** Biến phản hồi refresh (đầy đủ) thành grant; méo → null để caller coi là retry. */
64
+ export function grantFromRefreshPayload(data: unknown): SdkSessionGrant | null {
65
+ if (
66
+ !isRecord(data) ||
67
+ typeof data.sessionId !== "string" ||
68
+ typeof data.serverUrl !== "string" ||
69
+ typeof data.participantToken !== "string" ||
70
+ !isFiniteNumber(data.tokenExpiresAt) ||
71
+ !isTelemetryCredentials(data.telemetry) ||
72
+ !isSdkRemoteConfig(data.config)
73
+ ) {
74
+ return null;
75
+ }
76
+ return {
77
+ sessionId: data.sessionId,
78
+ serverUrl: data.serverUrl,
79
+ participantToken: data.participantToken,
80
+ playbackUrl: typeof data.playbackUrl === "string" ? data.playbackUrl : null,
81
+ tokenExpiresAt: data.tokenExpiresAt,
82
+ telemetry: data.telemetry,
83
+ config: data.config,
84
+ };
85
+ }
86
+
87
+ export interface HttpResumeFetcherOptions {
88
+ /** URL endpoint refresh của phiên — namespace nào cũng được, miễn trả grant đầy đủ + status. */
89
+ url(sessionId: string): string;
90
+ headers?: Record<string, string> | (() => Record<string, string> | Promise<Record<string, string>>);
91
+ fetch?: Fetch;
92
+ timeoutMs?: number;
93
+ }
94
+
95
+ /**
96
+ * Gọi endpoint refresh để dựng lại grant. Trả về PHÂN LOẠI chứ không phải
97
+ * null-cho-mọi-thứ (xem resume-policy): mất mạng/5xx/méo = retry, 404/409/410 =
98
+ * gone. Chỉ phát tiếp khi server xác nhận phiên ĐANG live — thiếu trường status
99
+ * coi như không phát: để camera tự lên sóng nhầm còn tệ hơn là chờ một lệnh nữa.
100
+ */
101
+ export function createHttpResumeFetcher(
102
+ options: HttpResumeFetcherOptions,
103
+ ): (sessionId: string) => Promise<ResumeAttempt> {
104
+ const fetchImplementation = options.fetch ?? globalThis.fetch;
105
+ const timeoutMs = options.timeoutMs ?? 10_000;
106
+ return async (sessionId) => {
107
+ const controller = new AbortController();
108
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
109
+ try {
110
+ const headers =
111
+ typeof options.headers === "function" ? await options.headers() : (options.headers ?? {});
112
+ const response = await fetchImplementation(options.url(sessionId), {
113
+ method: "POST",
114
+ headers: { "Content-Type": "application/json", ...headers },
115
+ body: JSON.stringify({}),
116
+ signal: controller.signal,
117
+ });
118
+ const verdict = classifyResumeStatus(response.status);
119
+ if (verdict !== "ok") return { kind: verdict };
120
+ const envelope = (await response.json().catch(() => null)) as { data?: unknown } | null;
121
+ const grant = grantFromRefreshPayload(envelope?.data);
122
+ if (!grant) return { kind: "retry" };
123
+ const status = (envelope?.data as { status?: unknown }).status;
124
+ return { kind: "grant", grant, live: status === "live" };
125
+ } catch {
126
+ // fetch ném = mất mạng. Đây CHÍNH LÀ ca hay gặp nhất khi máy vừa mở nguồn.
127
+ return { kind: "retry" };
128
+ } finally {
129
+ clearTimeout(timer);
130
+ }
131
+ };
132
+ }