@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.
- package/CHANGELOG.md +65 -0
- package/README.md +163 -40
- package/dist/advanced-DcbHBCGb.d.ts +300 -0
- package/dist/advanced-DeQwg7MK.d.cts +300 -0
- package/dist/advanced.cjs +47 -0
- package/dist/advanced.d.cts +3 -0
- package/dist/advanced.d.ts +3 -0
- package/dist/advanced.js +47 -0
- package/dist/camera-controls-By8tHigY.d.cts +97 -0
- package/dist/camera-controls-rCBUGGPC.d.ts +97 -0
- package/dist/chunk-2JHULJZ7.js +265 -0
- package/dist/chunk-62YV3PCD.cjs +139 -0
- package/dist/chunk-6F3HN6US.js +50 -0
- package/dist/chunk-6YE5BL3A.cjs +50 -0
- package/dist/chunk-7OX2DSKI.cjs +33 -0
- package/dist/chunk-7Z7TN5N3.js +318 -0
- package/dist/chunk-DHDX3ZOO.js +800 -0
- package/dist/chunk-GXQYMPHE.cjs +9 -0
- package/dist/chunk-IUIRVKJI.cjs +2290 -0
- package/dist/chunk-KFQGP6VL.js +33 -0
- package/dist/chunk-LCWGNGEN.cjs +265 -0
- package/dist/chunk-TBUEXRFQ.cjs +318 -0
- package/dist/chunk-XGP2MN5R.js +2290 -0
- package/dist/chunk-XL4CISVQ.js +9 -0
- package/dist/chunk-YGSQ25UA.cjs +800 -0
- package/dist/chunk-ZSKSXOCZ.js +139 -0
- package/dist/convex-command-subscription-Cabh9tND.d.ts +41 -0
- package/dist/convex-command-subscription-DUgpu75l.d.cts +41 -0
- package/dist/device-agent/index.cjs +1427 -0
- package/dist/device-agent/index.d.cts +493 -0
- package/dist/device-agent/index.d.ts +493 -0
- package/dist/device-agent/index.js +1427 -0
- package/dist/engine-BSvSdCSE.d.cts +741 -0
- package/dist/engine-BSvSdCSE.d.ts +741 -0
- package/dist/index.cjs +426 -6174
- package/dist/index.d.cts +299 -1400
- package/dist/index.d.ts +299 -1400
- package/dist/index.js +413 -6063
- package/dist/match/index.cjs +2456 -0
- package/dist/match/index.d.cts +253 -0
- package/dist/match/index.d.ts +253 -0
- package/dist/match/index.js +2456 -0
- package/dist/match/remote/index.cjs +21 -0
- package/dist/match/remote/index.d.cts +67 -0
- package/dist/match/remote/index.d.ts +67 -0
- package/dist/match/remote/index.js +21 -0
- package/dist/match/voice/index.cjs +23 -0
- package/dist/match/voice/index.d.cts +114 -0
- package/dist/match/voice/index.d.ts +114 -0
- package/dist/match/voice/index.js +23 -0
- package/dist/plugin.cjs +52 -71
- package/dist/plugin.d.cts +28 -1
- package/dist/plugin.d.ts +28 -1
- package/dist/plugin.js +26 -23
- package/dist/realtime/index.cjs +31 -0
- package/dist/realtime/index.d.cts +15 -0
- package/dist/realtime/index.d.ts +15 -0
- package/dist/realtime/index.js +31 -0
- package/dist/types-CrmcX408.d.cts +176 -0
- package/dist/types-CrmcX408.d.ts +176 -0
- package/dist/types-DiKWjwvN.d.cts +96 -0
- package/dist/types-DiKWjwvN.d.ts +96 -0
- package/dist/ui/index.cjs +1297 -0
- package/dist/ui/index.d.cts +493 -0
- package/dist/ui/index.d.ts +493 -0
- package/dist/ui/index.js +1297 -0
- package/dist/version.cjs +4 -32
- package/dist/version.js +2 -1
- package/package.json +95 -15
- package/src/advanced.ts +49 -0
- package/src/auto-resume.tsx +259 -0
- package/src/camera-controls.ts +99 -0
- package/src/camera-grant-provider.ts +392 -0
- package/src/camera-settings.ts +133 -0
- package/src/contracts.ts +73 -0
- package/src/device-agent/agent.ts +696 -0
- package/src/device-agent/beat-policy.ts +46 -0
- package/src/device-agent/component.tsx +60 -0
- package/src/device-agent/convex-beat-transport.ts +140 -0
- package/src/device-agent/convex-command-subscription.ts +101 -0
- package/src/device-agent/convex-control-channel.ts +88 -0
- package/src/device-agent/http-transport.ts +164 -0
- package/src/device-agent/index.ts +26 -0
- package/src/device-agent/mqtt-channel.ts +379 -0
- package/src/device-agent/mqtt-codec.ts +347 -0
- package/src/device-agent/tournament.ts +352 -0
- package/src/engine.ts +601 -20
- package/src/errors.ts +16 -0
- package/src/http-session-provider.ts +21 -183
- package/src/index.ts +117 -14
- package/src/local-video-quality.ts +43 -0
- package/src/match/index.ts +29 -0
- package/src/match/provider.tsx +4 -2
- package/src/match/remote/hid-gesture.ts +10 -2
- package/src/match/remote/types.ts +6 -4
- package/src/match/voice/scheduler.ts +6 -1
- package/src/match/voice/service.ts +16 -4
- package/src/native-runtime.ts +283 -77
- package/src/plugin.ts +54 -20
- package/src/provider.tsx +129 -22
- package/src/realtime/index.ts +56 -0
- package/src/resume-policy.ts +85 -0
- package/src/resume.ts +132 -0
- package/src/rtmp-room-adapter.ts +348 -0
- package/src/session-grant.ts +256 -0
- package/src/ui/camera-screen.tsx +484 -0
- package/src/ui/controls-sheet.tsx +445 -0
- package/src/ui/copy.ts +326 -0
- package/src/ui/helpers.ts +105 -0
- package/src/ui/hooks.ts +59 -0
- package/src/ui/index.ts +42 -0
- package/src/ui/preview-view.tsx +39 -0
- package/src/ui/primitives.tsx +304 -0
- package/src/uplink-check.ts +173 -0
- package/dist/chunk-VHLUJQG5.js +0 -17
- package/src/livekit-room-adapter.ts +0 -353
- 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
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
engine.
|
|
159
|
-
|
|
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
|
-
<
|
|
163
|
-
{
|
|
164
|
-
|
|
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
|
+
}
|