@tinyweb_dev/oe-meeting-react 0.1.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/INTEGRATION.md +76 -0
- package/README.md +56 -0
- package/dist/index.cjs +387 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +167 -0
- package/dist/index.d.ts +167 -0
- package/dist/index.js +358 -0
- package/dist/index.js.map +1 -0
- package/docs/BACKEND-INTEGRATION.md +287 -0
- package/docs/REACT-INTEGRATION.md +295 -0
- package/docs/RELEASE.md +116 -0
- package/package.json +66 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/OeMeeting.tsx","../src/embed-protocol.ts","../src/buildEmbedUrl.ts","../src/useOeMeeting.ts","../src/parseEmbedEvent.ts"],"sourcesContent":["'use client';\n\nimport {\n forwardRef,\n useId,\n useMemo,\n type CSSProperties,\n} from 'react';\nimport { buildEmbedUrl } from './buildEmbedUrl';\nimport { DEFAULT_EMBED_ORIGIN } from './embed-protocol';\nimport type { OeMeetingHandle, OeMeetingProps } from './types';\nimport { useOeMeeting } from './useOeMeeting';\n\nexport const DEFAULT_IFRAME_ALLOW =\n 'camera; microphone; fullscreen; autoplay; display-capture; clipboard-write; encrypted-media';\n\nfunction toCssSize(value: number | string | undefined): string | undefined {\n if (value === undefined) return undefined;\n return typeof value === 'number' ? `${value}px` : value;\n}\n\nfunction getOrigin(value: string): string {\n return new URL(value).origin;\n}\n\nexport const OeMeeting = forwardRef<OeMeetingHandle, OeMeetingProps>(\n function OeMeeting(props, ref) {\n const {\n roomSlug,\n joinToken,\n userName,\n externalUserId,\n origin = DEFAULT_EMBED_ORIGIN,\n lang,\n autoJoin = true,\n hideLeave,\n title = 'OE Meeting room',\n width = '100%',\n height = 640,\n className,\n style,\n iframeClassName,\n iframeStyle,\n allow = DEFAULT_IFRAME_ALLOW,\n targetOrigin,\n onReady,\n onJoined,\n onLeft,\n onParticipantJoined,\n onParticipantLeft,\n onEnded,\n onError,\n onEvent,\n } = props;\n const reactId = useId();\n const instanceId = useMemo(\n () => `oem-${reactId.replace(/:/g, '')}`,\n [reactId],\n );\n const embedOrigin = useMemo(() => getOrigin(origin), [origin]);\n const resolvedTargetOrigin = targetOrigin ?? embedOrigin;\n const hostOrigin =\n typeof window === 'undefined' ? undefined : window.location.origin;\n const src = useMemo(\n () =>\n buildEmbedUrl({\n roomSlug,\n joinToken,\n userName,\n externalUserId,\n origin,\n lang,\n autoJoin,\n hideLeave,\n instanceId,\n hostOrigin,\n }),\n [\n autoJoin,\n externalUserId,\n hideLeave,\n hostOrigin,\n instanceId,\n joinToken,\n lang,\n origin,\n roomSlug,\n userName,\n ],\n );\n const { iframeRef } = useOeMeeting({\n instanceId,\n targetOrigin: resolvedTargetOrigin,\n handleRef: ref,\n onReady,\n onJoined,\n onLeft,\n onParticipantJoined,\n onParticipantLeft,\n onEnded,\n onError,\n onEvent,\n });\n const wrapperStyle: CSSProperties = {\n width: toCssSize(width),\n height: toCssSize(height),\n ...style,\n };\n const frameStyle: CSSProperties = {\n width: '100%',\n height: '100%',\n border: 0,\n display: 'block',\n ...iframeStyle,\n };\n\n return (\n <div\n className={className}\n style={wrapperStyle}\n data-oe-meeting-instance={instanceId}\n >\n <iframe\n ref={iframeRef}\n className={iframeClassName}\n style={frameStyle}\n src={src}\n title={title}\n allow={allow}\n allowFullScreen\n referrerPolicy=\"strict-origin-when-cross-origin\"\n />\n </div>\n );\n },\n);\n\nOeMeeting.displayName = 'OeMeeting';\n","export const EMBED_PROTOCOL_VERSION = 1 as const;\nexport const EMBED_MESSAGE_SOURCE = 'oemeeting' as const;\nexport const DEFAULT_EMBED_ORIGIN = 'https://meet.oceanedu.org';\n\nexport enum EmbedEventType {\n READY = 'oemeeting:ready',\n JOINED = 'oemeeting:joined',\n LEFT = 'oemeeting:left',\n PARTICIPANT_JOINED = 'oemeeting:participant-joined',\n PARTICIPANT_LEFT = 'oemeeting:participant-left',\n ENDED = 'oemeeting:ended',\n ERROR = 'oemeeting:error',\n}\n\nexport enum EmbedCommandType {\n COMMAND = 'oemeeting:command',\n}\n\nexport enum EmbedCommandAction {\n LEAVE = 'leave',\n TOGGLE_MIC = 'toggleMic',\n TOGGLE_CAMERA = 'toggleCamera',\n}\n\nexport enum EmbedErrorCode {\n TOKEN_INVALID = 'TOKEN_INVALID',\n TOKEN_EXPIRED = 'TOKEN_EXPIRED',\n ROOM_NOT_FOUND = 'ROOM_NOT_FOUND',\n ROOM_ACCESS_DENIED = 'ROOM_ACCESS_DENIED',\n CONNECTION_FAILED = 'CONNECTION_FAILED',\n PROTOCOL_MISMATCH = 'PROTOCOL_MISMATCH',\n UNKNOWN = 'UNKNOWN',\n}\n\nexport enum EmbedQueryParam {\n INSTANCE_ID = 'instanceId',\n HOST_ORIGIN = 'hostOrigin',\n JOIN_TOKEN = 'joinToken',\n USER_NAME = 'userName',\n EXTERNAL_USER_ID = 'externalUserId',\n LANG = 'lang',\n AUTO_JOIN = 'autoJoin',\n HIDE_LEAVE = 'hideLeave',\n}\n","import {\n DEFAULT_EMBED_ORIGIN,\n EmbedQueryParam,\n} from './embed-protocol';\nimport type { BuildEmbedUrlOptions } from './types';\n\nconst QUERY_FLAG_ENABLED = '1';\nconst QUERY_FLAG_DISABLED = '0';\n\nfunction toFlag(value: boolean | undefined): string | undefined {\n if (value === undefined) return undefined;\n return value ? QUERY_FLAG_ENABLED : QUERY_FLAG_DISABLED;\n}\n\nfunction normalizeOrigin(origin: string): string {\n return origin.replace(/\\/+$/, '');\n}\n\nexport function buildEmbedUrl(options: BuildEmbedUrlOptions): string {\n const roomSlug = options.roomSlug.trim();\n const joinToken = options.joinToken.trim();\n if (!roomSlug) {\n throw new Error('OE Meeting embed requires a non-empty roomSlug.');\n }\n if (!joinToken) {\n throw new Error('OE Meeting embed requires a non-empty joinToken.');\n }\n\n const origin = normalizeOrigin(options.origin ?? DEFAULT_EMBED_ORIGIN);\n const url = new URL(`${origin}/embed/${encodeURIComponent(roomSlug)}`);\n const entries: Array<[EmbedQueryParam, string | undefined]> = [\n [EmbedQueryParam.INSTANCE_ID, options.instanceId],\n [EmbedQueryParam.HOST_ORIGIN, options.hostOrigin],\n [EmbedQueryParam.JOIN_TOKEN, joinToken],\n [EmbedQueryParam.USER_NAME, options.userName],\n [EmbedQueryParam.EXTERNAL_USER_ID, options.externalUserId],\n [EmbedQueryParam.LANG, options.lang],\n [EmbedQueryParam.AUTO_JOIN, toFlag(options.autoJoin)],\n [EmbedQueryParam.HIDE_LEAVE, toFlag(options.hideLeave)],\n ];\n\n for (const [key, value] of entries) {\n if (value !== undefined && value !== '') {\n url.searchParams.set(key, value);\n }\n }\n return url.toString();\n}\n","import {\n useCallback,\n useEffect,\n useImperativeHandle,\n useMemo,\n useRef,\n type Ref,\n} from 'react';\nimport {\n EMBED_MESSAGE_SOURCE,\n EMBED_PROTOCOL_VERSION,\n EmbedCommandAction,\n EmbedCommandType,\n EmbedEventType,\n} from './embed-protocol';\nimport { parseEmbedEvent } from './parseEmbedEvent';\nimport type {\n EmbedCommandMessage,\n OeMeetingHandle,\n OeMeetingProps,\n} from './types';\n\ntype UseOeMeetingOptions = Pick<\n OeMeetingProps,\n | 'onReady'\n | 'onJoined'\n | 'onLeft'\n | 'onParticipantJoined'\n | 'onParticipantLeft'\n | 'onEnded'\n | 'onError'\n | 'onEvent'\n> & {\n instanceId: string;\n targetOrigin: string;\n handleRef?: Ref<OeMeetingHandle>;\n};\n\ninterface PostCommandOptions {\n iframe: HTMLIFrameElement | null;\n instanceId: string;\n action: EmbedCommandAction;\n targetOrigin: string;\n}\n\nfunction postCommand(options: PostCommandOptions): void {\n const frameWindow = options.iframe?.contentWindow;\n if (!frameWindow) return;\n const message: EmbedCommandMessage = {\n source: EMBED_MESSAGE_SOURCE,\n version: EMBED_PROTOCOL_VERSION,\n instanceId: options.instanceId,\n type: EmbedCommandType.COMMAND,\n action: options.action,\n };\n frameWindow.postMessage(message, options.targetOrigin);\n}\n\nexport function useOeMeeting(options: UseOeMeetingOptions) {\n const iframeRef = useRef<HTMLIFrameElement | null>(null);\n const callbacksRef = useRef(options);\n\n useEffect(() => {\n callbacksRef.current = options;\n }, [options]);\n\n useEffect(() => {\n const onMessage = (event: MessageEvent<unknown>) => {\n const frameWindow = iframeRef.current?.contentWindow;\n if (\n !frameWindow ||\n event.source !== frameWindow ||\n event.origin !== options.targetOrigin\n ) {\n return;\n }\n const embedEvent = parseEmbedEvent(event.data);\n if (!embedEvent || embedEvent.instanceId !== options.instanceId) return;\n\n const callbacks = callbacksRef.current;\n callbacks.onEvent?.(embedEvent);\n switch (embedEvent.type) {\n case EmbedEventType.READY:\n callbacks.onReady?.(embedEvent);\n break;\n case EmbedEventType.JOINED:\n callbacks.onJoined?.(embedEvent);\n break;\n case EmbedEventType.LEFT:\n callbacks.onLeft?.(embedEvent);\n break;\n case EmbedEventType.PARTICIPANT_JOINED:\n callbacks.onParticipantJoined?.(embedEvent);\n break;\n case EmbedEventType.PARTICIPANT_LEFT:\n callbacks.onParticipantLeft?.(embedEvent);\n break;\n case EmbedEventType.ENDED:\n callbacks.onEnded?.(embedEvent);\n break;\n case EmbedEventType.ERROR:\n callbacks.onError?.(embedEvent);\n break;\n }\n };\n\n window.addEventListener('message', onMessage);\n return () => window.removeEventListener('message', onMessage);\n }, [options.instanceId, options.targetOrigin]);\n\n const send = useCallback(\n (action: EmbedCommandAction) =>\n postCommand({\n iframe: iframeRef.current,\n instanceId: options.instanceId,\n action,\n targetOrigin: options.targetOrigin,\n }),\n [options.instanceId, options.targetOrigin],\n );\n const leave = useCallback(() => send(EmbedCommandAction.LEAVE), [send]);\n const toggleMic = useCallback(\n () => send(EmbedCommandAction.TOGGLE_MIC),\n [send],\n );\n const toggleCamera = useCallback(\n () => send(EmbedCommandAction.TOGGLE_CAMERA),\n [send],\n );\n const getIframe = useCallback(() => iframeRef.current, []);\n const handle = useMemo<OeMeetingHandle>(\n () => ({ leave, toggleMic, toggleCamera, getIframe }),\n [getIframe, leave, toggleCamera, toggleMic],\n );\n\n useImperativeHandle(options.handleRef, () => handle, [handle]);\n return { iframeRef, handle };\n}\n","import {\n EMBED_MESSAGE_SOURCE,\n EMBED_PROTOCOL_VERSION,\n EmbedEventType,\n} from './embed-protocol';\nimport type { EmbedEvent } from './types';\n\nconst EVENT_TYPES = new Set<string>(Object.values(EmbedEventType));\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null;\n}\n\nfunction isOptionalString(value: unknown): boolean {\n return value === undefined || typeof value === 'string';\n}\n\nfunction hasValidPayload(\n type: EmbedEventType,\n payload: Record<string, unknown>,\n): boolean {\n switch (type) {\n case EmbedEventType.READY:\n return typeof payload.roomSlug === 'string';\n case EmbedEventType.JOINED:\n return (\n typeof payload.roomSlug === 'string' &&\n typeof payload.identity === 'string' &&\n isOptionalString(payload.name) &&\n typeof payload.joinedAt === 'string'\n );\n case EmbedEventType.LEFT:\n return (\n typeof payload.roomSlug === 'string' &&\n typeof payload.identity === 'string' &&\n typeof payload.leftAt === 'string' &&\n (payload.durationMs === undefined ||\n typeof payload.durationMs === 'number')\n );\n case EmbedEventType.PARTICIPANT_JOINED:\n return (\n typeof payload.identity === 'string' &&\n isOptionalString(payload.name) &&\n typeof payload.participantCount === 'number'\n );\n case EmbedEventType.PARTICIPANT_LEFT:\n return (\n typeof payload.identity === 'string' &&\n typeof payload.participantCount === 'number'\n );\n case EmbedEventType.ENDED:\n return (\n typeof payload.roomSlug === 'string' &&\n typeof payload.endedAt === 'string'\n );\n case EmbedEventType.ERROR:\n return (\n typeof payload.code === 'string' &&\n typeof payload.message === 'string'\n );\n }\n}\n\nexport function parseEmbedEvent(data: unknown): EmbedEvent | null {\n if (!isRecord(data)) return null;\n if (\n data.source !== EMBED_MESSAGE_SOURCE ||\n data.version !== EMBED_PROTOCOL_VERSION ||\n typeof data.instanceId !== 'string' ||\n data.instanceId.length === 0 ||\n typeof data.type !== 'string' ||\n !EVENT_TYPES.has(data.type) ||\n !isRecord(data.payload)\n ) {\n return null;\n }\n\n const type = data.type as EmbedEventType;\n if (!hasValidPayload(type, data.payload)) return null;\n return data as unknown as EmbedEvent;\n}\n"],"mappings":"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","names":["useMemo","EmbedEventType","EmbedCommandType","EmbedCommandAction","EmbedErrorCode","EmbedQueryParam","OeMeeting","useMemo"]}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# Tích hợp backend OE.School với OE Meeting
|
|
2
|
+
|
|
3
|
+
## 1. Chuẩn bị credentials
|
|
4
|
+
|
|
5
|
+
OE Meeting cần cấp cho OE.School một application credential gồm:
|
|
6
|
+
|
|
7
|
+
- `clientId`.
|
|
8
|
+
- `clientSecret`.
|
|
9
|
+
- Scope `rooms:create` để tạo phòng.
|
|
10
|
+
- Scope `rooms:retrieve` để đọc phòng và cấp join token.
|
|
11
|
+
- Tùy nhu cầu quản trị: `rooms:update`, `rooms:delete`.
|
|
12
|
+
|
|
13
|
+
Cấu hình ở backend OE.School, ví dụ:
|
|
14
|
+
|
|
15
|
+
```dotenv
|
|
16
|
+
OE_MEETING_API_URL=https://meet.oceanedu.org
|
|
17
|
+
OE_MEETING_CLIENT_ID=oe-school
|
|
18
|
+
OE_MEETING_CLIENT_SECRET=<secret-from-secret-manager>
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Không dùng biến public như `NEXT_PUBLIC_*` hoặc `VITE_*` cho client secret.
|
|
22
|
+
Production nên lấy secret từ secret manager hoặc Kubernetes Secret, không commit
|
|
23
|
+
vào repository.
|
|
24
|
+
|
|
25
|
+
## 2. External API
|
|
26
|
+
|
|
27
|
+
Base URL production:
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
https://meet.oceanedu.org/external-api/v1.0
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Mọi request server-to-server phải gửi:
|
|
34
|
+
|
|
35
|
+
```http
|
|
36
|
+
X-Client-Id: <client-id>
|
|
37
|
+
X-Client-Secret: <client-secret>
|
|
38
|
+
Content-Type: application/json
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### 2.1. Tạo phòng cho buổi học
|
|
42
|
+
|
|
43
|
+
```http
|
|
44
|
+
POST /external-api/v1.0/rooms
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Request:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"name": "Lesson 98765 - 2026-08-17",
|
|
52
|
+
"slug": "oe-school-lesson-98765",
|
|
53
|
+
"access_level": "restricted",
|
|
54
|
+
"configuration": {}
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
| Field | Bắt buộc | Mô tả |
|
|
59
|
+
|---|---:|---|
|
|
60
|
+
| `name` | Có | Tên hiển thị, tối đa 500 ký tự. |
|
|
61
|
+
| `slug` | Không | Khóa ổn định của phòng, tối đa 100 ký tự. Nếu bỏ trống, server sinh từ `name`. |
|
|
62
|
+
| `access_level` | Không | Nên luôn dùng `restricted`; external API mặc định là `restricted`. |
|
|
63
|
+
| `configuration` | Không | Cấu hình mở rộng của phòng. |
|
|
64
|
+
|
|
65
|
+
Response mẫu:
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"id": "c4ac4f15-8417-4e19-a3dd-39de5697c19f",
|
|
70
|
+
"name": "Lesson 98765 - 2026-08-17",
|
|
71
|
+
"slug": "oe-school-lesson-98765",
|
|
72
|
+
"accessLevel": "restricted",
|
|
73
|
+
"configuration": {},
|
|
74
|
+
"pinCode": null
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
API idempotent theo `slug`: gọi lại cùng slug trả về phòng đã tồn tại. OE.School
|
|
79
|
+
nên lưu `room.id` và `room.slug` vào bản ghi buổi học sau lần tạo đầu tiên.
|
|
80
|
+
|
|
81
|
+
Khuyến nghị slug:
|
|
82
|
+
|
|
83
|
+
```text
|
|
84
|
+
oe-school-lesson-<lessonId>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Không dùng tên học sinh, email hoặc dữ liệu cá nhân trong slug vì slug xuất hiện
|
|
88
|
+
trong URL.
|
|
89
|
+
|
|
90
|
+
### 2.2. Cấp join token
|
|
91
|
+
|
|
92
|
+
Chỉ gọi endpoint này **sau khi backend OE.School kiểm tra roster**.
|
|
93
|
+
|
|
94
|
+
```http
|
|
95
|
+
POST /external-api/v1.0/application/token
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Request:
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
{
|
|
102
|
+
"room": "oe-school-lesson-98765",
|
|
103
|
+
"identity": "oe-school:user:12345",
|
|
104
|
+
"name": "Nguyen Van A"
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
| Field | Bắt buộc | Mô tả |
|
|
109
|
+
|---|---:|---|
|
|
110
|
+
| `room` | Có | Room slug đã tạo, tối đa 255 ký tự. |
|
|
111
|
+
| `identity` | Có | Định danh ổn định và duy nhất của user, tối đa 255 ký tự. |
|
|
112
|
+
| `name` | Không | Tên hiển thị trong phòng, tối đa 255 ký tự. |
|
|
113
|
+
|
|
114
|
+
Response:
|
|
115
|
+
|
|
116
|
+
```json
|
|
117
|
+
{
|
|
118
|
+
"token": "<livekit-join-token>"
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Nên dùng identity có namespace, ví dụ `oe-school:user:<userId>`, để tránh trùng
|
|
123
|
+
identity giữa các hệ thống tích hợp. Không dùng token phiên đăng nhập OE.School
|
|
124
|
+
làm identity.
|
|
125
|
+
|
|
126
|
+
### 2.3. Các endpoint quản trị khác
|
|
127
|
+
|
|
128
|
+
| Method | Endpoint | Scope |
|
|
129
|
+
|---|---|---|
|
|
130
|
+
| `GET` | `/external-api/v1.0/rooms/:idOrSlug` | `rooms:retrieve` |
|
|
131
|
+
| `PATCH` | `/external-api/v1.0/rooms/:idOrSlug` | `rooms:update` |
|
|
132
|
+
| `DELETE` | `/external-api/v1.0/rooms/:idOrSlug` | `rooms:delete` |
|
|
133
|
+
|
|
134
|
+
## 3. Service mẫu ở backend OE.School
|
|
135
|
+
|
|
136
|
+
Ví dụ dưới đây dùng `fetch` tích hợp sẵn trong Node.js 18+.
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
const RoomAccessLevel = {
|
|
140
|
+
RESTRICTED: 'restricted',
|
|
141
|
+
} as const;
|
|
142
|
+
|
|
143
|
+
interface OeMeetingRoom {
|
|
144
|
+
id: string;
|
|
145
|
+
name: string;
|
|
146
|
+
slug: string;
|
|
147
|
+
accessLevel: (typeof RoomAccessLevel)[keyof typeof RoomAccessLevel];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
interface JoinTokenResponse {
|
|
151
|
+
token: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
interface MeetingCredentials {
|
|
155
|
+
apiUrl: string;
|
|
156
|
+
clientId: string;
|
|
157
|
+
clientSecret: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export class OeMeetingApi {
|
|
161
|
+
constructor(private readonly credentials: MeetingCredentials) {}
|
|
162
|
+
|
|
163
|
+
private async request<T>(path: string, init: RequestInit): Promise<T> {
|
|
164
|
+
const response = await fetch(`${this.credentials.apiUrl}${path}`, {
|
|
165
|
+
...init,
|
|
166
|
+
headers: {
|
|
167
|
+
'Content-Type': 'application/json',
|
|
168
|
+
'X-Client-Id': this.credentials.clientId,
|
|
169
|
+
'X-Client-Secret': this.credentials.clientSecret,
|
|
170
|
+
...init.headers,
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
if (!response.ok) {
|
|
175
|
+
throw new Error(`OE Meeting API failed with status ${response.status}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return (await response.json()) as T;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
createLessonRoom(input: {
|
|
182
|
+
lessonId: string;
|
|
183
|
+
lessonName: string;
|
|
184
|
+
}): Promise<OeMeetingRoom> {
|
|
185
|
+
return this.request('/external-api/v1.0/rooms', {
|
|
186
|
+
method: 'POST',
|
|
187
|
+
body: JSON.stringify({
|
|
188
|
+
name: input.lessonName,
|
|
189
|
+
slug: `oe-school-lesson-${input.lessonId}`,
|
|
190
|
+
access_level: RoomAccessLevel.RESTRICTED,
|
|
191
|
+
}),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
createJoinToken(input: {
|
|
196
|
+
roomSlug: string;
|
|
197
|
+
userId: string;
|
|
198
|
+
displayName: string;
|
|
199
|
+
}): Promise<JoinTokenResponse> {
|
|
200
|
+
return this.request('/external-api/v1.0/application/token', {
|
|
201
|
+
method: 'POST',
|
|
202
|
+
body: JSON.stringify({
|
|
203
|
+
room: input.roomSlug,
|
|
204
|
+
identity: `oe-school:user:${input.userId}`,
|
|
205
|
+
name: input.displayName,
|
|
206
|
+
}),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Trong code production, validate response bằng schema validator hiện có của
|
|
213
|
+
OE.School thay vì chỉ type assertion như ví dụ rút gọn trên.
|
|
214
|
+
|
|
215
|
+
## 4. Kiểm tra roster và cấp quyền
|
|
216
|
+
|
|
217
|
+
Backend OE.School phải là nơi quyết định quyền truy cập. Luồng đề xuất:
|
|
218
|
+
|
|
219
|
+
```typescript
|
|
220
|
+
async function getLessonMeetingAccess(input: {
|
|
221
|
+
lessonId: string;
|
|
222
|
+
currentUserId: string;
|
|
223
|
+
}) {
|
|
224
|
+
const lesson = await lessonRepository.findByIdOrFail(input.lessonId);
|
|
225
|
+
const attendance = await attendanceRepository.findByLessonAndUser(
|
|
226
|
+
input.lessonId,
|
|
227
|
+
input.currentUserId,
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const canJoinAsTeacher = lesson.teacherId === input.currentUserId;
|
|
231
|
+
const canJoinAsStudent =
|
|
232
|
+
attendance?.isEnrolled === true && attendance.isAbsent !== true;
|
|
233
|
+
|
|
234
|
+
if (!canJoinAsTeacher && !canJoinAsStudent) {
|
|
235
|
+
throw new ForbiddenException('User cannot join this lesson');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const room = await getOrCreateMeetingRoom(lesson);
|
|
239
|
+
const { token } = await oeMeetingApi.createJoinToken({
|
|
240
|
+
roomSlug: room.slug,
|
|
241
|
+
userId: input.currentUserId,
|
|
242
|
+
displayName: attendance?.displayName ?? lesson.teacherName,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
return { roomSlug: room.slug, joinToken: token };
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Các trạng thái roster phải dùng enum/constant của OE.School. Ví dụ trên dùng
|
|
250
|
+
boolean chỉ để minh họa. Nếu học sinh bị đánh dấu nghỉ sau khi đã nhận token,
|
|
251
|
+
backend phải ngừng cấp token mới; để thu hồi ngay một token đã cấp cần bổ sung
|
|
252
|
+
cơ chế remove participant/token TTL ở phiên bản tiếp theo.
|
|
253
|
+
|
|
254
|
+
## 5. API OE.School dành cho frontend
|
|
255
|
+
|
|
256
|
+
Nên tạo một endpoint cùng origin với frontend OE.School:
|
|
257
|
+
|
|
258
|
+
```http
|
|
259
|
+
GET /api/lessons/:lessonId/meeting-access
|
|
260
|
+
Authorization: Bearer <oe-school-session>
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Response:
|
|
264
|
+
|
|
265
|
+
```json
|
|
266
|
+
{
|
|
267
|
+
"roomSlug": "oe-school-lesson-98765",
|
|
268
|
+
"joinToken": "<livekit-join-token>"
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Endpoint này phải:
|
|
273
|
+
|
|
274
|
+
1. Xác thực session OE.School.
|
|
275
|
+
2. Tìm lesson theo `lessonId`.
|
|
276
|
+
3. Kiểm tra giáo viên/học sinh có quyền vào đúng buổi học.
|
|
277
|
+
4. Tạo phòng nếu chưa tồn tại.
|
|
278
|
+
5. Cấp join token cho chính user đang đăng nhập.
|
|
279
|
+
6. Không cache response token trong shared cache/CDN.
|
|
280
|
+
|
|
281
|
+
Khuyến nghị response headers:
|
|
282
|
+
|
|
283
|
+
```http
|
|
284
|
+
Cache-Control: no-store
|
|
285
|
+
Pragma: no-cache
|
|
286
|
+
```
|
|
287
|
+
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# Tích hợp React SDK vào OE.School
|
|
2
|
+
|
|
3
|
+
## 1. Cài đặt
|
|
4
|
+
|
|
5
|
+
Package yêu cầu React 17 trở lên. Trong giai đoạn package chưa publish lên
|
|
6
|
+
registry, OE.School cài bằng local path hoặc workspace.
|
|
7
|
+
|
|
8
|
+
### Package registry (sau khi publish)
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install @tinyweb_dev/oe-meeting-react
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
### Local package
|
|
15
|
+
|
|
16
|
+
Build package tại repository OE Meeting:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
cd src/sdk/react
|
|
20
|
+
npm install
|
|
21
|
+
npm run build
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Sau đó cài package local trong dự án OE.School:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install ../oe-meeting/src/sdk/react
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Nếu hai dự án thuộc cùng monorepo, có thể khai báo
|
|
31
|
+
`"@tinyweb_dev/oe-meeting-react": "workspace:*"`.
|
|
32
|
+
|
|
33
|
+
## 2. Nhúng phòng bằng React
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
'use client';
|
|
37
|
+
|
|
38
|
+
import { useEffect, useState } from 'react';
|
|
39
|
+
import {
|
|
40
|
+
EmbedErrorCode,
|
|
41
|
+
OeMeeting,
|
|
42
|
+
type OeMeetingProps,
|
|
43
|
+
} from '@tinyweb_dev/oe-meeting-react';
|
|
44
|
+
|
|
45
|
+
interface MeetingAccess {
|
|
46
|
+
roomSlug: string;
|
|
47
|
+
joinToken: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function OnlineLesson({ lessonId }: { lessonId: string }) {
|
|
51
|
+
const [access, setAccess] = useState<MeetingAccess | null>(null);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const controller = new AbortController();
|
|
55
|
+
|
|
56
|
+
void fetch(`/api/lessons/${lessonId}/meeting-access`, {
|
|
57
|
+
credentials: 'include',
|
|
58
|
+
cache: 'no-store',
|
|
59
|
+
signal: controller.signal,
|
|
60
|
+
})
|
|
61
|
+
.then(async (response) => {
|
|
62
|
+
if (!response.ok) {
|
|
63
|
+
throw new Error(`Cannot get meeting access: ${response.status}`);
|
|
64
|
+
}
|
|
65
|
+
return (await response.json()) as MeetingAccess;
|
|
66
|
+
})
|
|
67
|
+
.then(setAccess)
|
|
68
|
+
.catch((error: unknown) => {
|
|
69
|
+
if (error instanceof DOMException && error.name === 'AbortError') return;
|
|
70
|
+
console.error(error);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return () => controller.abort();
|
|
74
|
+
}, [lessonId]);
|
|
75
|
+
|
|
76
|
+
if (!access) return <div>Đang tải phòng học…</div>;
|
|
77
|
+
|
|
78
|
+
const onError: NonNullable<OeMeetingProps['onError']> = (event) => {
|
|
79
|
+
switch (event.payload.code) {
|
|
80
|
+
case EmbedErrorCode.TOKEN_EXPIRED:
|
|
81
|
+
case EmbedErrorCode.TOKEN_INVALID:
|
|
82
|
+
// Gọi lại meeting-access để lấy token mới.
|
|
83
|
+
break;
|
|
84
|
+
case EmbedErrorCode.ROOM_ACCESS_DENIED:
|
|
85
|
+
// Hiển thị thông báo không thuộc danh sách tham dự.
|
|
86
|
+
break;
|
|
87
|
+
default:
|
|
88
|
+
console.error(event.payload.message);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<OeMeeting
|
|
94
|
+
roomSlug={access.roomSlug}
|
|
95
|
+
joinToken={access.joinToken}
|
|
96
|
+
origin="https://meet.oceanedu.org"
|
|
97
|
+
width="100%"
|
|
98
|
+
height="calc(100vh - 64px)"
|
|
99
|
+
hideLeave
|
|
100
|
+
onReady={(event) => console.info('Meeting ready', event.payload)}
|
|
101
|
+
onJoined={(event) => console.info('User joined', event.payload)}
|
|
102
|
+
onLeft={(event) => console.info('User left', event.payload)}
|
|
103
|
+
onError={onError}
|
|
104
|
+
/>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`origin` có giá trị mặc định là `https://meet.oceanedu.org`, nên có thể bỏ prop
|
|
110
|
+
này ở production. Khi chạy local, truyền URL frontend OE Meeting local, ví dụ
|
|
111
|
+
`http://localhost:5173`.
|
|
112
|
+
|
|
113
|
+
## 3. Props của `OeMeeting`
|
|
114
|
+
|
|
115
|
+
| Prop | Type | Mặc định | Mô tả |
|
|
116
|
+
|---|---|---|---|
|
|
117
|
+
| `roomSlug` | `string` | Bắt buộc | Slug phòng do backend trả về. |
|
|
118
|
+
| `joinToken` | `string` | Bắt buộc | Token cấp riêng cho user hiện tại. |
|
|
119
|
+
| `origin` | `string` | `https://meet.oceanedu.org` | Origin của OE Meeting. |
|
|
120
|
+
| `autoJoin` | `boolean` | `true` | Phiên bản hiện tại chỉ hỗ trợ `true`. |
|
|
121
|
+
| `hideLeave` | `boolean` | `false` | Ẩn nút rời phòng trong iframe. |
|
|
122
|
+
| `userName` | `string` | — | Thông tin tùy chọn truyền vào embed URL. |
|
|
123
|
+
| `externalUserId` | `string` | — | External identity tùy chọn. |
|
|
124
|
+
| `lang` | `string` | — | Ngôn ngữ giao diện. |
|
|
125
|
+
| `title` | `string` | `OE Meeting room` | Accessible title của iframe. |
|
|
126
|
+
| `width` | `number \| string` | `100%` | Chiều rộng wrapper. Number được đổi sang px. |
|
|
127
|
+
| `height` | `number \| string` | `640` | Chiều cao wrapper. Number được đổi sang px. |
|
|
128
|
+
| `className` | `string` | — | Class của wrapper. |
|
|
129
|
+
| `style` | `CSSProperties` | — | Style của wrapper. |
|
|
130
|
+
| `iframeClassName` | `string` | — | Class của iframe. |
|
|
131
|
+
| `iframeStyle` | `CSSProperties` | — | Style của iframe. |
|
|
132
|
+
| `allow` | `string` | Camera/microphone/fullscreen… | Iframe Permissions Policy. |
|
|
133
|
+
| `targetOrigin` | `string` | Origin suy ra từ `origin` | Chỉ override khi có proxy đặc biệt. |
|
|
134
|
+
| `onReady` | callback | — | Embed bridge đã sẵn sàng. |
|
|
135
|
+
| `onJoined` | callback | — | Local user đã vào phòng. |
|
|
136
|
+
| `onLeft` | callback | — | Local user đã rời phòng. |
|
|
137
|
+
| `onParticipantJoined` | callback | — | Một participant khác vào phòng. |
|
|
138
|
+
| `onParticipantLeft` | callback | — | Một participant khác rời phòng. |
|
|
139
|
+
| `onEnded` | callback | — | Phòng kết thúc. |
|
|
140
|
+
| `onError` | callback | — | Lỗi typed từ embed. |
|
|
141
|
+
| `onEvent` | callback | — | Nhận tất cả event protocol. |
|
|
142
|
+
|
|
143
|
+
Không đặt `targetOrigin="*"`. SDK và iframe đều kiểm tra origin, source và
|
|
144
|
+
`instanceId` trước khi xử lý message.
|
|
145
|
+
|
|
146
|
+
## 4. Events
|
|
147
|
+
|
|
148
|
+
Mọi callback nhận object có envelope:
|
|
149
|
+
|
|
150
|
+
```typescript
|
|
151
|
+
interface EmbedEventEnvelope<TPayload> {
|
|
152
|
+
source: 'oemeeting';
|
|
153
|
+
version: 1;
|
|
154
|
+
instanceId: string;
|
|
155
|
+
type: string;
|
|
156
|
+
payload: TPayload;
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
| Event | Payload chính | Trạng thái v1 |
|
|
161
|
+
|---|---|---|
|
|
162
|
+
| `READY` | `roomSlug` | Đã emit |
|
|
163
|
+
| `JOINED` | `roomSlug`, `identity`, `name?`, `joinedAt` | Đã emit |
|
|
164
|
+
| `LEFT` | `roomSlug`, `identity`, `leftAt`, `durationMs?` | Đã emit |
|
|
165
|
+
| `PARTICIPANT_JOINED` | `identity`, `name?`, `participantCount` | Đã emit |
|
|
166
|
+
| `PARTICIPANT_LEFT` | `identity`, `participantCount` | Đã emit |
|
|
167
|
+
| `ERROR` | `code`, `message` | Đã emit |
|
|
168
|
+
| `ENDED` | `roomSlug`, `endedAt` | Đã khai báo, chưa có nguồn emit |
|
|
169
|
+
|
|
170
|
+
Có thể dùng `onEvent` để gửi analytics tập trung, nhưng không log join token,
|
|
171
|
+
iframe URL đầy đủ hoặc dữ liệu nhạy cảm.
|
|
172
|
+
|
|
173
|
+
## 5. Điều khiển phòng từ trang cha
|
|
174
|
+
|
|
175
|
+
```tsx
|
|
176
|
+
import { useRef } from 'react';
|
|
177
|
+
import {
|
|
178
|
+
OeMeeting,
|
|
179
|
+
type OeMeetingHandle,
|
|
180
|
+
} from '@tinyweb_dev/oe-meeting-react';
|
|
181
|
+
|
|
182
|
+
export function MeetingWithControls(props: {
|
|
183
|
+
roomSlug: string;
|
|
184
|
+
joinToken: string;
|
|
185
|
+
}) {
|
|
186
|
+
const meetingRef = useRef<OeMeetingHandle>(null);
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
<>
|
|
190
|
+
<button onClick={() => meetingRef.current?.toggleMic()}>
|
|
191
|
+
Bật/tắt micro
|
|
192
|
+
</button>
|
|
193
|
+
<button onClick={() => meetingRef.current?.toggleCamera()}>
|
|
194
|
+
Bật/tắt camera
|
|
195
|
+
</button>
|
|
196
|
+
<button onClick={() => meetingRef.current?.leave()}>
|
|
197
|
+
Rời phòng
|
|
198
|
+
</button>
|
|
199
|
+
|
|
200
|
+
<OeMeeting
|
|
201
|
+
ref={meetingRef}
|
|
202
|
+
roomSlug={props.roomSlug}
|
|
203
|
+
joinToken={props.joinToken}
|
|
204
|
+
hideLeave
|
|
205
|
+
/>
|
|
206
|
+
</>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
`OeMeetingHandle` cung cấp:
|
|
212
|
+
|
|
213
|
+
- `leave()`.
|
|
214
|
+
- `toggleMic()`.
|
|
215
|
+
- `toggleCamera()`.
|
|
216
|
+
- `getIframe()`.
|
|
217
|
+
|
|
218
|
+
## 6. Error codes
|
|
219
|
+
|
|
220
|
+
| Code | Ý nghĩa | Xử lý đề xuất | Trạng thái v1 |
|
|
221
|
+
|---|---|---|---|
|
|
222
|
+
| `CONNECTION_FAILED` | Không kết nối được LiveKit/config/token. | Lấy token mới một lần; nếu vẫn lỗi thì cho retry với backoff. | Đã emit |
|
|
223
|
+
| `UNKNOWN` | Command từ trang cha thất bại. | Ghi telemetry đã loại dữ liệu nhạy cảm. | Đã emit |
|
|
224
|
+
| `TOKEN_INVALID` | Token không hợp lệ. | Xóa token hiện tại, gọi lại API meeting-access. | Đã khai báo, chưa phân loại riêng |
|
|
225
|
+
| `TOKEN_EXPIRED` | Token hết hạn. | Xin token mới sau khi kiểm tra roster lại. | Đã khai báo, chưa phân loại riêng |
|
|
226
|
+
| `ROOM_NOT_FOUND` | Phòng không tồn tại. | Tạo/đồng bộ lại phòng của lesson. | Đã khai báo, chưa phân loại riêng |
|
|
227
|
+
| `ROOM_ACCESS_DENIED` | User không có quyền. | Không retry tự động; hiển thị thông báo phù hợp. | Đã khai báo, chưa phân loại riêng |
|
|
228
|
+
| `PROTOCOL_MISMATCH` | SDK và embed khác version. | Đồng bộ phiên bản SDK/OE Meeting. | Đã khai báo, chưa emit |
|
|
229
|
+
|
|
230
|
+
## 7. Local development
|
|
231
|
+
|
|
232
|
+
Giả sử OE Meeting frontend chạy tại `http://localhost:5173`:
|
|
233
|
+
|
|
234
|
+
```tsx
|
|
235
|
+
<OeMeeting
|
|
236
|
+
origin="http://localhost:5173"
|
|
237
|
+
roomSlug={roomSlug}
|
|
238
|
+
joinToken={joinToken}
|
|
239
|
+
/>
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
External API local phải trỏ vào backend OE Meeting local. Credentials local và
|
|
243
|
+
production phải tách biệt. Theo quy ước dự án:
|
|
244
|
+
|
|
245
|
+
- `.env`: local.
|
|
246
|
+
- `.env.dev`: dev.
|
|
247
|
+
- `.env.prod`: production.
|
|
248
|
+
|
|
249
|
+
Khi test iframe, hai ứng dụng có thể chạy trên hai port khác nhau; SDK tự truyền
|
|
250
|
+
`window.location.origin` của OE.School làm `hostOrigin` và bridge chỉ gửi message
|
|
251
|
+
về đúng origin đó.
|
|
252
|
+
|
|
253
|
+
## 8. Checklist trước khi release
|
|
254
|
+
|
|
255
|
+
### Backend OE.School
|
|
256
|
+
|
|
257
|
+
- [ ] Client secret chỉ tồn tại ở server/secret manager.
|
|
258
|
+
- [ ] Application có đủ `rooms:create` và `rooms:retrieve`.
|
|
259
|
+
- [ ] Room slug ổn định theo lesson và không chứa PII.
|
|
260
|
+
- [ ] Phòng được tạo với access level `restricted`.
|
|
261
|
+
- [ ] Mỗi lần cấp token đều kiểm tra session và roster.
|
|
262
|
+
- [ ] Học sinh nghỉ/không thuộc buổi học nhận HTTP 403.
|
|
263
|
+
- [ ] API meeting-access dùng `Cache-Control: no-store`.
|
|
264
|
+
- [ ] Không log token hoặc URL iframe có token.
|
|
265
|
+
|
|
266
|
+
### Frontend OE.School
|
|
267
|
+
|
|
268
|
+
- [ ] Chỉ render `OeMeeting` sau khi nhận `roomSlug` và `joinToken`.
|
|
269
|
+
- [ ] Production dùng origin `https://meet.oceanedu.org`.
|
|
270
|
+
- [ ] Có trạng thái loading, forbidden, token expired và connection failed.
|
|
271
|
+
- [ ] Không dùng `targetOrigin="*"`.
|
|
272
|
+
- [ ] Camera/microphone hoạt động trong iframe.
|
|
273
|
+
- [ ] Event join/leave được gửi analytics nếu nghiệp vụ yêu cầu.
|
|
274
|
+
|
|
275
|
+
### Kiểm thử nghiệp vụ
|
|
276
|
+
|
|
277
|
+
- [ ] Giáo viên tạo/mở phòng từ trang buổi học.
|
|
278
|
+
- [ ] Học sinh có trong roster vào được phòng.
|
|
279
|
+
- [ ] Học sinh nghỉ hoặc không thuộc roster không nhận được token.
|
|
280
|
+
- [ ] Người có link nhưng không có token không vào được phòng.
|
|
281
|
+
- [ ] Reload trang cấp token mới và kết nối lại thành công.
|
|
282
|
+
- [ ] Nhiều lần tạo cùng lesson không sinh phòng trùng.
|
|
283
|
+
- [ ] Nút rời phòng, micro và camera từ trang cha hoạt động.
|
|
284
|
+
|
|
285
|
+
## 9. Giới hạn phiên bản hiện tại
|
|
286
|
+
|
|
287
|
+
- Join token đang được truyền trong iframe query string. Không log URL đầy đủ và
|
|
288
|
+
không đưa URL này vào analytics. Iframe dùng referrer policy hạn chế rò rỉ URL.
|
|
289
|
+
- OE Meeting chưa đồng bộ roster vào database; OE.School chịu trách nhiệm duyệt
|
|
290
|
+
quyền trước mỗi lần cấp token.
|
|
291
|
+
- Chưa có API thu hồi token đã cấp hoặc kick participant theo lesson attendance.
|
|
292
|
+
- Protocol v1 chưa có recording events và token handshake qua `postMessage`.
|
|
293
|
+
- `autoJoin={false}` chưa có prejoin flow; phiên bản hiện tại yêu cầu auto join.
|
|
294
|
+
- Các lỗi token/access chưa được phân loại riêng; frontend có thể nhận
|
|
295
|
+
`CONNECTION_FAILED` cho các lỗi kết nối liên quan token.
|