@jokio/sdk 0.0.1
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/.prettierrc +12 -0
- package/dist/auth.service.d.ts +19 -0
- package/dist/common/playAudioFromBuffer.d.ts +1 -0
- package/dist/crypto.service.d.ts +9 -0
- package/dist/edgeTts.service.d.ts +13 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.html +63 -0
- package/dist/jokio.sdk.es.js +8495 -0
- package/dist/jokio.sdk.umd.js +39 -0
- package/dist/nats.service.d.ts +36 -0
- package/dist/tts.service.d.ts +11 -0
- package/package.json +22 -0
- package/src/auth.service.ts +109 -0
- package/src/common/playAudioFromBuffer.ts +15 -0
- package/src/crypto.service.ts +100 -0
- package/src/edgeTts.service.ts +153 -0
- package/src/index.ts +37 -0
- package/src/nats.service.ts +325 -0
- package/src/tts.service.ts +94 -0
- package/test/index.html +63 -0
- package/tsconfig.json +14 -0
- package/vite.config.ts +23 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import {
|
|
2
|
+
connect,
|
|
3
|
+
DebugEvents,
|
|
4
|
+
Events,
|
|
5
|
+
JSONCodec,
|
|
6
|
+
type NatsConnection,
|
|
7
|
+
} from 'nats.ws'
|
|
8
|
+
|
|
9
|
+
const jsonCodec = JSONCodec()
|
|
10
|
+
|
|
11
|
+
export class NatsService<TApi> {
|
|
12
|
+
onStatusChange?: (isConnected: boolean) => void
|
|
13
|
+
|
|
14
|
+
private nc?: NatsConnection
|
|
15
|
+
private sessionId: string = ''
|
|
16
|
+
private userId: string = ''
|
|
17
|
+
|
|
18
|
+
private globalPrefix = ''
|
|
19
|
+
|
|
20
|
+
private resolveConnected = (_: unknown) => {}
|
|
21
|
+
isConnected = new Promise(
|
|
22
|
+
resolve => (this.resolveConnected = resolve),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
async connect(
|
|
26
|
+
natsServerUrls: string[],
|
|
27
|
+
user: IdentityUser,
|
|
28
|
+
prefix?: string,
|
|
29
|
+
) {
|
|
30
|
+
this.nc = await connect({
|
|
31
|
+
servers: natsServerUrls,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
this.userId = user.userId
|
|
35
|
+
this.sessionId = user.sessionId
|
|
36
|
+
|
|
37
|
+
this.globalPrefix = prefix ?? ''
|
|
38
|
+
|
|
39
|
+
this.resolveConnected(true)
|
|
40
|
+
|
|
41
|
+
this.onStatusChange?.(true)
|
|
42
|
+
console.log(
|
|
43
|
+
'[nats] connected.',
|
|
44
|
+
this.globalPrefix ? `(prefix: ${this.globalPrefix})` : '',
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
// Monitor connection status
|
|
48
|
+
;(async () => {
|
|
49
|
+
for await (const status of this.nc!.status()) {
|
|
50
|
+
if (status.type !== DebugEvents.PingTimer) {
|
|
51
|
+
console.log('[nats] connection status:', status.type)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
switch (status.type) {
|
|
55
|
+
case Events.Reconnect:
|
|
56
|
+
this.onStatusChange?.(true)
|
|
57
|
+
break
|
|
58
|
+
|
|
59
|
+
case Events.Disconnect:
|
|
60
|
+
case Events.Error:
|
|
61
|
+
case DebugEvents.Reconnecting:
|
|
62
|
+
this.onStatusChange?.(false)
|
|
63
|
+
break
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
})()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async disconnect() {
|
|
70
|
+
await this.nc?.drain()
|
|
71
|
+
await this.nc?.close()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async publish<TSubject extends keyof TApi & string>(
|
|
75
|
+
subject: TSubject,
|
|
76
|
+
data: InferReturnType<TApi, TSubject>,
|
|
77
|
+
) {
|
|
78
|
+
await this.isConnected
|
|
79
|
+
|
|
80
|
+
if (!this.nc) {
|
|
81
|
+
throw new Error('[nats] not connected')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const finalSubject = this.buildSubject(subject)
|
|
86
|
+
|
|
87
|
+
console.debug('[nats]>>', finalSubject)
|
|
88
|
+
|
|
89
|
+
this.nc.publish(finalSubject, jsonCodec.encode(data))
|
|
90
|
+
} catch (error) {
|
|
91
|
+
console.error('[nats] publish failed:', error)
|
|
92
|
+
throw error
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async call<TSubject extends keyof TApi & string>(
|
|
97
|
+
subject: TSubject,
|
|
98
|
+
data: InferProps<TApi, TSubject>,
|
|
99
|
+
opts?: { timeoutInMs?: number },
|
|
100
|
+
): Promise<InferReturnType<TApi, TSubject>> {
|
|
101
|
+
await this.isConnected
|
|
102
|
+
|
|
103
|
+
if (!this.nc) {
|
|
104
|
+
throw new Error('[nats] not connected')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const finalSubject = this.buildSubject(subject)
|
|
109
|
+
|
|
110
|
+
console.debug('[nats]>>', finalSubject)
|
|
111
|
+
|
|
112
|
+
const response = await this.nc.request(
|
|
113
|
+
finalSubject,
|
|
114
|
+
jsonCodec.encode(data),
|
|
115
|
+
{ timeout: opts?.timeoutInMs ?? 5000 },
|
|
116
|
+
)
|
|
117
|
+
const result:
|
|
118
|
+
| { ok: true; data: any }
|
|
119
|
+
| { ok: false; message: string } = jsonCodec.decode(
|
|
120
|
+
response.data,
|
|
121
|
+
) as any
|
|
122
|
+
|
|
123
|
+
if (!result.ok) {
|
|
124
|
+
throw new Error(result.message)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return result.data
|
|
128
|
+
} catch (error) {
|
|
129
|
+
console.error('[nats] request failed:', error)
|
|
130
|
+
throw new Error('Api call failed.')
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async on<TSubject extends keyof TApi & string>(
|
|
135
|
+
subject: TSubject,
|
|
136
|
+
cb: (
|
|
137
|
+
data: InferReturnType<TApi, TSubject>,
|
|
138
|
+
ctx: {
|
|
139
|
+
subject: string
|
|
140
|
+
userId: string
|
|
141
|
+
sessionId: string
|
|
142
|
+
},
|
|
143
|
+
) => void,
|
|
144
|
+
) {
|
|
145
|
+
await this.isConnected
|
|
146
|
+
|
|
147
|
+
if (!this.nc) {
|
|
148
|
+
throw new Error('[nats] not connected')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const prefix = this.globalPrefix ? `${this.globalPrefix}.` : ''
|
|
152
|
+
const finalSubject = prefix + subject + '.>'
|
|
153
|
+
|
|
154
|
+
const subscription = this.nc!.subscribe(finalSubject)
|
|
155
|
+
|
|
156
|
+
;(async () => {
|
|
157
|
+
for await (const message of subscription) {
|
|
158
|
+
try {
|
|
159
|
+
console.debug('[nats]<<', message.subject)
|
|
160
|
+
|
|
161
|
+
const subjectParts = message.subject.split('.')
|
|
162
|
+
|
|
163
|
+
// -- process prefix --
|
|
164
|
+
if (
|
|
165
|
+
this.globalPrefix &&
|
|
166
|
+
subjectParts[0] !== this.globalPrefix
|
|
167
|
+
) {
|
|
168
|
+
console.log('[nats]', 'Skipped - ' + message.subject)
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (subjectParts[0] === 'dev') {
|
|
173
|
+
// remove the first element / prefix
|
|
174
|
+
subjectParts.shift()
|
|
175
|
+
}
|
|
176
|
+
// -- process prefix --
|
|
177
|
+
|
|
178
|
+
const subject = subjectParts
|
|
179
|
+
.slice(0, subjectParts.length - 2)
|
|
180
|
+
.join('.')
|
|
181
|
+
const userId = subjectParts[subjectParts.length - 2]
|
|
182
|
+
const sessionId = subjectParts[subjectParts.length - 1]
|
|
183
|
+
|
|
184
|
+
const decodedMessage = jsonCodec.decode(message.data)
|
|
185
|
+
|
|
186
|
+
cb(decodedMessage as any, {
|
|
187
|
+
subject,
|
|
188
|
+
userId,
|
|
189
|
+
sessionId,
|
|
190
|
+
})
|
|
191
|
+
} catch (err) {
|
|
192
|
+
console.error('[nats] subscribe error', err)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
})()
|
|
196
|
+
|
|
197
|
+
return () => subscription.unsubscribe()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async handler<TSubject extends keyof TApi & string>(
|
|
201
|
+
subject: TSubject,
|
|
202
|
+
cb: (
|
|
203
|
+
data: InferProps<TApi, TSubject>,
|
|
204
|
+
ctx: {
|
|
205
|
+
subject: string
|
|
206
|
+
userId: string
|
|
207
|
+
sessionId: string
|
|
208
|
+
},
|
|
209
|
+
) => Promise<InferReturnType<TApi, TSubject>>,
|
|
210
|
+
) {
|
|
211
|
+
await this.isConnected
|
|
212
|
+
|
|
213
|
+
if (!this.nc) {
|
|
214
|
+
throw new Error('[nats] not connected')
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const prefix = this.globalPrefix ? `${this.globalPrefix}.` : ''
|
|
218
|
+
const finalSubject = prefix + subject + '.>'
|
|
219
|
+
|
|
220
|
+
const subscription = this.nc!.subscribe(finalSubject)
|
|
221
|
+
|
|
222
|
+
;(async () => {
|
|
223
|
+
for await (const message of subscription) {
|
|
224
|
+
try {
|
|
225
|
+
console.debug('[nats]<<', message.subject)
|
|
226
|
+
|
|
227
|
+
const subjectParts = message.subject.split('.')
|
|
228
|
+
|
|
229
|
+
// -- process prefix --
|
|
230
|
+
if (
|
|
231
|
+
this.globalPrefix &&
|
|
232
|
+
subjectParts[0] !== this.globalPrefix
|
|
233
|
+
) {
|
|
234
|
+
console.log('[nats]', 'Skipped - ' + message.subject)
|
|
235
|
+
continue
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (subjectParts[0] === 'dev') {
|
|
239
|
+
// remove the first element / prefix
|
|
240
|
+
subjectParts.shift()
|
|
241
|
+
}
|
|
242
|
+
// -- process prefix --
|
|
243
|
+
|
|
244
|
+
const subject = subjectParts
|
|
245
|
+
.slice(0, subjectParts.length - 2)
|
|
246
|
+
.join('.')
|
|
247
|
+
const userId = subjectParts[subjectParts.length - 2]
|
|
248
|
+
const sessionId = subjectParts[subjectParts.length - 1]
|
|
249
|
+
|
|
250
|
+
const decodedMessage = jsonCodec.decode(message.data)
|
|
251
|
+
|
|
252
|
+
cb(decodedMessage as any, {
|
|
253
|
+
subject,
|
|
254
|
+
userId,
|
|
255
|
+
sessionId,
|
|
256
|
+
})
|
|
257
|
+
.then(res => {
|
|
258
|
+
if (message.reply) {
|
|
259
|
+
message.respond(
|
|
260
|
+
jsonCodec.encode({
|
|
261
|
+
ok: true,
|
|
262
|
+
data: res,
|
|
263
|
+
}),
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
})
|
|
267
|
+
.catch(err => {
|
|
268
|
+
console.error('[nats] handle', err)
|
|
269
|
+
|
|
270
|
+
if (message.reply) {
|
|
271
|
+
message.respond(
|
|
272
|
+
jsonCodec.encode({
|
|
273
|
+
ok: false,
|
|
274
|
+
message: err.message,
|
|
275
|
+
}),
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
})
|
|
279
|
+
} catch (err: any) {
|
|
280
|
+
console.error('[nats] handle', err)
|
|
281
|
+
|
|
282
|
+
if (message.reply) {
|
|
283
|
+
message.respond(
|
|
284
|
+
jsonCodec.encode({
|
|
285
|
+
ok: false,
|
|
286
|
+
message: err.message,
|
|
287
|
+
}),
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
})()
|
|
293
|
+
|
|
294
|
+
return () => subscription.unsubscribe()
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private buildSubject(subject: string) {
|
|
298
|
+
return [this.globalPrefix, subject, this.userId, this.sessionId]
|
|
299
|
+
.filter(x => x)
|
|
300
|
+
.join('.')
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export const nats = new NatsService()
|
|
305
|
+
|
|
306
|
+
type IdentityUser = {
|
|
307
|
+
jwt: string
|
|
308
|
+
seed: string
|
|
309
|
+
userId: string
|
|
310
|
+
sessionId: string
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
type InferProps<
|
|
314
|
+
TApi,
|
|
315
|
+
TSubject extends keyof TApi,
|
|
316
|
+
> = TApi[TSubject] extends (args: infer TProps) => unknown
|
|
317
|
+
? TProps
|
|
318
|
+
: {}
|
|
319
|
+
|
|
320
|
+
type InferReturnType<
|
|
321
|
+
TApi,
|
|
322
|
+
TSubject extends keyof TApi,
|
|
323
|
+
> = TApi[TSubject] extends (args: any) => infer TResult
|
|
324
|
+
? TResult
|
|
325
|
+
: TApi[TSubject]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { playAudioFromBuffer } from './common/playAudioFromBuffer'
|
|
2
|
+
|
|
3
|
+
type Config = {
|
|
4
|
+
voicevoxUrl: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export class VoicevoxTtsService {
|
|
8
|
+
constructor(private config: Config) {}
|
|
9
|
+
|
|
10
|
+
async getAudioBuffer(text: string, voice: string) {
|
|
11
|
+
const audioQueryResponse = await fetch(
|
|
12
|
+
`${
|
|
13
|
+
this.config.voicevoxUrl
|
|
14
|
+
}/audio_query?text=${encodeURIComponent(text)}&speaker=${
|
|
15
|
+
voice || 3
|
|
16
|
+
}`,
|
|
17
|
+
{
|
|
18
|
+
method: 'POST',
|
|
19
|
+
headers: {
|
|
20
|
+
Accept: 'application/json',
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if (!audioQueryResponse.ok) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`Audio query failed: ${audioQueryResponse.statusText}`,
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const audioQuery = await audioQueryResponse.json()
|
|
32
|
+
|
|
33
|
+
// Step 2: Synthesize the speech
|
|
34
|
+
const synthesisResponse = await fetch(
|
|
35
|
+
`${this.config.voicevoxUrl}/synthesis?speaker=${voice}`,
|
|
36
|
+
{
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: {
|
|
39
|
+
Accept: 'audio/wav',
|
|
40
|
+
'Content-Type': 'application/json',
|
|
41
|
+
},
|
|
42
|
+
body: JSON.stringify(audioQuery),
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if (!synthesisResponse.body) {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const reader = synthesisResponse.body.getReader()
|
|
51
|
+
const chunks = []
|
|
52
|
+
|
|
53
|
+
// Step 1: Read all the chunks
|
|
54
|
+
while (true) {
|
|
55
|
+
const { done, value } = await reader.read()
|
|
56
|
+
if (done) break
|
|
57
|
+
chunks.push(value)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Step 2: Concatenate into one Uint8Array
|
|
61
|
+
const totalLength = chunks.reduce(
|
|
62
|
+
(acc, chunk) => acc + chunk.length,
|
|
63
|
+
0,
|
|
64
|
+
)
|
|
65
|
+
const audioData = new Uint8Array(totalLength)
|
|
66
|
+
let offset = 0
|
|
67
|
+
for (const chunk of chunks) {
|
|
68
|
+
audioData.set(chunk, offset)
|
|
69
|
+
offset += chunk.length
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return audioData.buffer
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async getAudio(text: string, voice: string) {
|
|
76
|
+
return this.getAudioBuffer(text, voice).then(x =>
|
|
77
|
+
x ? playAudioFromBuffer(x) : null,
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async getVoices(): Promise<any[]> {
|
|
82
|
+
const speakers = await fetch(
|
|
83
|
+
`${this.config.voicevoxUrl}/speakers`,
|
|
84
|
+
).then(x => x.json())
|
|
85
|
+
|
|
86
|
+
return speakers.flatMap((x: any) =>
|
|
87
|
+
x.styles.map((y: any) => ({
|
|
88
|
+
id: y.id.toString(),
|
|
89
|
+
locale: 'ja-JP',
|
|
90
|
+
description: `${x.name} - ${y.name}`,
|
|
91
|
+
})),
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
}
|
package/test/index.html
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="UTF-8" />
|
|
6
|
+
<title>MyLibrary Test</title>
|
|
7
|
+
</head>
|
|
8
|
+
|
|
9
|
+
<body>
|
|
10
|
+
<h1>Library Test</h1>
|
|
11
|
+
|
|
12
|
+
<button onclick="playAudio()">Edge TTS</button>
|
|
13
|
+
|
|
14
|
+
<button onclick="playAudio2()">Voicevox TTS</button>
|
|
15
|
+
|
|
16
|
+
<script type="module">
|
|
17
|
+
import { jok } from './dist/jokio.sdk.es.js';
|
|
18
|
+
window.jok = jok
|
|
19
|
+
// jok.voicevoxTts.getVoices().then(console.log)
|
|
20
|
+
// document.body.innerHTML += `<p>${}</p>`;
|
|
21
|
+
|
|
22
|
+
window.playAudio = async function () {
|
|
23
|
+
const audio = await jok.edgeTts.getAudio("Hello there, how are you?", "en-US-AvaNeural")
|
|
24
|
+
|
|
25
|
+
audio.play()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
window.playAudio2 = async function () {
|
|
30
|
+
const audio = await jok.voicevoxTts.getAudio("こんにちわ、おげんきですか?あなたはあたまがいいですね。", 3)
|
|
31
|
+
|
|
32
|
+
audio.play()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
window.p2pChat = async function () {
|
|
37
|
+
const k1 = await jok.crypto.createSessionKeyPair()
|
|
38
|
+
const k2 = await jok.crypto.createSessionKeyPair()
|
|
39
|
+
|
|
40
|
+
const p1 = await jok.crypto.exportPublicKey(k1.publicKey)
|
|
41
|
+
const p2 = await jok.crypto.exportPublicKey(k2.publicKey)
|
|
42
|
+
|
|
43
|
+
console.log({ p1, p2 })
|
|
44
|
+
|
|
45
|
+
const shared1 = await jok.crypto.deriveAESKey(k1.privateKey, p2)
|
|
46
|
+
const shared2 = await jok.crypto.deriveAESKey(k2.privateKey, p1)
|
|
47
|
+
|
|
48
|
+
const originalText = "Hello World"
|
|
49
|
+
|
|
50
|
+
const encrypted = await jok.crypto.encrypt(originalText, shared1)
|
|
51
|
+
const decrypted = await jok.crypto.decrypt(encrypted, shared2)
|
|
52
|
+
|
|
53
|
+
console.log({
|
|
54
|
+
match: originalText === decrypted,
|
|
55
|
+
originalText,
|
|
56
|
+
decrypted,
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
</script>
|
|
60
|
+
|
|
61
|
+
</body>
|
|
62
|
+
|
|
63
|
+
</html>
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"declaration": true,
|
|
6
|
+
"emitDeclarationOnly": true,
|
|
7
|
+
"outDir": "dist",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"moduleResolution": "Node",
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src"]
|
|
14
|
+
}
|
package/vite.config.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import path from 'path'
|
|
2
|
+
import { defineConfig } from 'vite'
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
publicDir: "test",
|
|
6
|
+
server: {
|
|
7
|
+
open: '/index.html' // or 'test/index.html' if kept in /test
|
|
8
|
+
},
|
|
9
|
+
build: {
|
|
10
|
+
lib: {
|
|
11
|
+
entry: path.resolve(__dirname, 'src/index.ts'),
|
|
12
|
+
name: '@jokio/sdk',
|
|
13
|
+
fileName: format => `jokio.sdk.${format}.js`,
|
|
14
|
+
},
|
|
15
|
+
// rollupOptions: {
|
|
16
|
+
// output: {
|
|
17
|
+
// globals: {
|
|
18
|
+
// // Example: react: 'React'
|
|
19
|
+
// },
|
|
20
|
+
// },
|
|
21
|
+
// },
|
|
22
|
+
},
|
|
23
|
+
})
|