serve-emul 0.0.4

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 (42) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/LICENSE +201 -0
  3. package/README.md +196 -0
  4. package/dist/ui/assets/index-Cm5-Tjhs.css +1 -0
  5. package/dist/ui/assets/index-CyUIa9dV.js +42 -0
  6. package/dist/ui/index.html +13 -0
  7. package/package.json +67 -0
  8. package/scripts/fetch-scrcpy.ts +28 -0
  9. package/scripts/release.ts +136 -0
  10. package/src/accessibility.ts +88 -0
  11. package/src/adb.ts +209 -0
  12. package/src/app-info.ts +114 -0
  13. package/src/app-management.ts +150 -0
  14. package/src/cli.ts +149 -0
  15. package/src/emulator.ts +229 -0
  16. package/src/input.ts +258 -0
  17. package/src/location.ts +135 -0
  18. package/src/route-playback.ts +359 -0
  19. package/src/scrcpy.ts +466 -0
  20. package/src/server.ts +1260 -0
  21. package/src/session-recorder.ts +149 -0
  22. package/src/ui/app.tsx +111 -0
  23. package/src/ui/components/accessibility-panel.tsx +113 -0
  24. package/src/ui/components/app-management-panel.tsx +256 -0
  25. package/src/ui/components/control-bar.tsx +24 -0
  26. package/src/ui/components/device-panel.tsx +532 -0
  27. package/src/ui/components/device-stream.tsx +142 -0
  28. package/src/ui/components/location-panel.tsx +584 -0
  29. package/src/ui/components/logcat-panel.tsx +100 -0
  30. package/src/ui/components/session-panel.tsx +127 -0
  31. package/src/ui/components/status-bar.tsx +19 -0
  32. package/src/ui/index.html +12 -0
  33. package/src/ui/lib/h264.ts +35 -0
  34. package/src/ui/lib/use-stream.ts +368 -0
  35. package/src/ui/main.tsx +7 -0
  36. package/src/ui/styles.css +708 -0
  37. package/src/ui/tsconfig.json +17 -0
  38. package/src/update-check.ts +93 -0
  39. package/vendor/scrcpy-server-v2.7 +0 -0
  40. package/vendor/scrcpy-server-v3.1 +0 -0
  41. package/vendor/scrcpy-server-v3.3.4 +0 -0
  42. package/vendor/scrcpy-server-v4.0 +0 -0
@@ -0,0 +1,127 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ type SessionEvent = {
4
+ id: number;
5
+ at: string;
6
+ delayMs: number;
7
+ source: string;
8
+ kind: "gesture" | "location";
9
+ gesture?: { type: string };
10
+ location?: { latitude: number; longitude: number };
11
+ };
12
+
13
+ type SessionSnapshot = {
14
+ events: SessionEvent[];
15
+ recording: boolean;
16
+ replaying: boolean;
17
+ lastError: string | null;
18
+ };
19
+
20
+ function labelForEvent(event: SessionEvent): string {
21
+ if (event.kind === "gesture") return `${event.gesture?.type ?? "gesture"} • ${event.source}`;
22
+ const lat = event.location?.latitude.toFixed(5) ?? "?";
23
+ const lng = event.location?.longitude.toFixed(5) ?? "?";
24
+ return `location ${lat}, ${lng}`;
25
+ }
26
+
27
+ export function SessionPanel() {
28
+ const [session, setSession] = useState<SessionSnapshot | null>(null);
29
+ const [multiplier, setMultiplier] = useState("1");
30
+ const [status, setStatus] = useState("Ready");
31
+
32
+ const refresh = () => {
33
+ fetch("/api/session")
34
+ .then((r) => r.json() as Promise<SessionSnapshot>)
35
+ .then(setSession)
36
+ .catch(() => setStatus("Session unavailable"));
37
+ };
38
+
39
+ useEffect(() => {
40
+ refresh();
41
+ const timer = setInterval(refresh, 1000);
42
+ return () => clearInterval(timer);
43
+ }, []);
44
+
45
+ const replay = async () => {
46
+ const rate = Number(multiplier);
47
+ if (!Number.isFinite(rate) || rate <= 0) {
48
+ setStatus("Rate must be positive");
49
+ return;
50
+ }
51
+ const res = await fetch("/api/session/replay", {
52
+ method: "POST",
53
+ headers: { "Content-Type": "application/json" },
54
+ body: JSON.stringify({ multiplier: rate }),
55
+ });
56
+ const data = await res.json() as { ok?: boolean; error?: string; session?: SessionSnapshot };
57
+ if (!res.ok || !data.ok) {
58
+ setStatus(data.error ?? "Replay failed");
59
+ return;
60
+ }
61
+ setSession(data.session ?? null);
62
+ setStatus("Replaying");
63
+ };
64
+
65
+ const stopReplay = async () => {
66
+ const res = await fetch("/api/session/replay/stop", { method: "POST" });
67
+ const data = await res.json() as { session?: SessionSnapshot };
68
+ setSession(data.session ?? null);
69
+ setStatus("Replay stopped");
70
+ };
71
+
72
+ const clear = async () => {
73
+ const res = await fetch("/api/session", { method: "DELETE" });
74
+ const data = await res.json() as { session?: SessionSnapshot };
75
+ setSession(data.session ?? null);
76
+ setStatus("Cleared");
77
+ };
78
+
79
+ const copy = async () => {
80
+ await navigator.clipboard.writeText(JSON.stringify(session?.events ?? [], null, 2));
81
+ setStatus("Copied");
82
+ };
83
+
84
+ const recent = session?.events.slice(-6).reverse() ?? [];
85
+
86
+ return (
87
+ <section className="tool-panel session-panel">
88
+ <div className="panel-heading">
89
+ <h2>Session</h2>
90
+ <div className="location-status">
91
+ {session?.replaying ? "Replaying" : status} • {session?.events.length ?? 0}
92
+ </div>
93
+ </div>
94
+ <div className="coordinate-grid">
95
+ <label>
96
+ Rate
97
+ <input
98
+ inputMode="decimal"
99
+ onChange={(e) => setMultiplier(e.currentTarget.value)}
100
+ value={multiplier}
101
+ />
102
+ </label>
103
+ <label>
104
+ Mode
105
+ <input readOnly value={session?.recording ? "Recording" : "Paused"} />
106
+ </label>
107
+ </div>
108
+ <div className="panel-actions">
109
+ <button onClick={() => void replay()}>Replay</button>
110
+ <button onClick={() => void stopReplay()}>Stop</button>
111
+ <button onClick={() => void clear()}>Clear</button>
112
+ <button onClick={() => void copy()}>Copy</button>
113
+ </div>
114
+ <div className="session-list">
115
+ {recent.length
116
+ ? recent.map((event) => (
117
+ <div key={event.id}>
118
+ <span>+{Math.round(event.delayMs)}ms</span>
119
+ {labelForEvent(event)}
120
+ </div>
121
+ ))
122
+ : <div>No recorded events</div>}
123
+ </div>
124
+ {session?.lastError && <div className="route-meta">{session.lastError}</div>}
125
+ </section>
126
+ );
127
+ }
@@ -0,0 +1,19 @@
1
+ import type { DeviceSize } from "../lib/use-stream";
2
+
3
+ type Props = {
4
+ status: string;
5
+ deviceSize: DeviceSize | null;
6
+ fps: number;
7
+ };
8
+
9
+ export function StatusBar({ status, deviceSize, fps }: Props) {
10
+ const meta =
11
+ status +
12
+ (deviceSize ? ` • ${deviceSize.width}×${deviceSize.height} • ${fps} fps` : "");
13
+ return (
14
+ <header>
15
+ <h1>serve-emul</h1>
16
+ <div className="meta">{meta}</div>
17
+ </header>
18
+ );
19
+ }
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>serve-emul</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/main.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,35 @@
1
+ export function buildCodecString(spsHeaderAndPayload: Uint8Array): string {
2
+ const profile = spsHeaderAndPayload[1].toString(16).padStart(2, "0");
3
+ const constraints = spsHeaderAndPayload[2].toString(16).padStart(2, "0");
4
+ const level = spsHeaderAndPayload[3].toString(16).padStart(2, "0");
5
+ return `avc1.${profile}${constraints}${level}`;
6
+ }
7
+
8
+ export type ScanResult = {
9
+ isKey: boolean;
10
+ spsBytes: Uint8Array | null;
11
+ };
12
+
13
+ export function scanAU(buf: Uint8Array): ScanResult {
14
+ let isKey = false;
15
+ let spsBytes: Uint8Array | null = null;
16
+ const len = buf.length;
17
+ let i = 0;
18
+ while (i + 2 < len) {
19
+ if (buf[i] === 0 && buf[i + 1] === 0) {
20
+ let codeLen = 0;
21
+ if (buf[i + 2] === 1) codeLen = 3;
22
+ else if (i + 3 < len && buf[i + 2] === 0 && buf[i + 3] === 1) codeLen = 4;
23
+ if (codeLen) {
24
+ const headerByte = buf[i + codeLen];
25
+ const nalType = headerByte & 0x1f;
26
+ if (nalType === 7 && !spsBytes) spsBytes = buf.subarray(i + codeLen);
27
+ if (nalType === 5) isKey = true;
28
+ i += codeLen + 1;
29
+ continue;
30
+ }
31
+ }
32
+ i++;
33
+ }
34
+ return { isKey, spsBytes };
35
+ }
@@ -0,0 +1,368 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import type { RefObject } from "react";
3
+ import { buildCodecString, scanAU } from "./h264";
4
+
5
+ export type DeviceSize = { width: number; height: number };
6
+
7
+ export type StreamState = {
8
+ status: string;
9
+ fps: number;
10
+ deviceSize: DeviceSize | null;
11
+ };
12
+
13
+ export type Sender = (msg: Record<string, unknown>, ack?: boolean) => void;
14
+
15
+ type ApiInfo = {
16
+ size: DeviceSize;
17
+ status?: "streaming" | "stopped" | "error";
18
+ lastFrameAt?: string | null;
19
+ lastError?: string | null;
20
+ };
21
+
22
+ const SOFT_DECODE_QUEUE_SIZE = 4;
23
+ const DECODER_RECOVERY_COOLDOWN_MS = 1500;
24
+ const KEYFRAME_REQUEST_COOLDOWN_MS = 1500;
25
+ const FRAME_QUEUE_SIZE = 2;
26
+ const FRAME_META_MAGIC = 0x53454d55; // "SEMU"
27
+ const FRAME_META_VERSION = 1;
28
+ const FRAME_META_HEADER_BYTES = 16;
29
+ const FRAME_FLAG_KEY = 1 << 0;
30
+
31
+ type FramePacket = {
32
+ data: Uint8Array;
33
+ isKey: boolean | null;
34
+ timestamp: number | null;
35
+ };
36
+
37
+ function parseFramePacket(raw: ArrayBuffer | Uint8Array): FramePacket {
38
+ const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw);
39
+ if (bytes.byteLength > FRAME_META_HEADER_BYTES) {
40
+ const view = new DataView(bytes.buffer, bytes.byteOffset, FRAME_META_HEADER_BYTES);
41
+ if (view.getUint32(0, false) === FRAME_META_MAGIC && view.getUint8(4) === FRAME_META_VERSION) {
42
+ const pts = view.getBigUint64(8, false);
43
+ return {
44
+ data: bytes.subarray(FRAME_META_HEADER_BYTES),
45
+ isKey: (view.getUint8(5) & FRAME_FLAG_KEY) !== 0,
46
+ timestamp: pts <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(pts) : null,
47
+ };
48
+ }
49
+ }
50
+ return { data: bytes, isKey: null, timestamp: null };
51
+ }
52
+
53
+ export function useStream(canvasRef: RefObject<HTMLCanvasElement>) {
54
+ const [state, setState] = useState<StreamState>({
55
+ status: "connecting…",
56
+ fps: 0,
57
+ deviceSize: null,
58
+ });
59
+ const wsRef = useRef<WebSocket | null>(null);
60
+
61
+ const send = useCallback<Sender>((msg, ack = true) => {
62
+ const ws = wsRef.current;
63
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
64
+ ws.send(JSON.stringify(ack ? msg : { ...msg, ack: false }));
65
+ }, []);
66
+
67
+ useEffect(() => {
68
+ const canDecode = "VideoDecoder" in globalThis && "EncodedVideoChunk" in globalThis;
69
+ if (!canDecode) {
70
+ setState((s) => ({ ...s, status: "WebCodecs unsupported" }));
71
+ return;
72
+ }
73
+
74
+ let cancelled = false;
75
+ let reconnectDelay = 500;
76
+ let retryTimer: ReturnType<typeof setTimeout> | null = null;
77
+ let decoder: VideoDecoder | null = null;
78
+ let sawKeyframe = false;
79
+ let frameIdx = 0;
80
+ let fpsCount = 0;
81
+ let fpsTimer = performance.now();
82
+ let frameQueue: (VideoFrame | null)[] = new Array(FRAME_QUEUE_SIZE).fill(null);
83
+ let frameQueueHead = 0;
84
+ let frameQueueCount = 0;
85
+ let renderRaf = 0;
86
+ let lastDecoderRecoveryAt = 0;
87
+ let lastKeyframeRequestAt = 0;
88
+ let droppingUntilKeyframe = false;
89
+ let healthTimer: ReturnType<typeof setInterval> | null = null;
90
+
91
+ const setStatus = (s: string) =>
92
+ setState((prev) => (prev.status === s ? prev : { ...prev, status: s }));
93
+
94
+ const clearFrameQueue = () => {
95
+ if (renderRaf) {
96
+ cancelAnimationFrame(renderRaf);
97
+ renderRaf = 0;
98
+ }
99
+ for (let i = 0; i < FRAME_QUEUE_SIZE; i++) {
100
+ frameQueue[i]?.close();
101
+ frameQueue[i] = null;
102
+ }
103
+ frameQueueHead = 0;
104
+ frameQueueCount = 0;
105
+ };
106
+
107
+ const closeDecoder = () => {
108
+ if (!decoder) return;
109
+ try {
110
+ if (decoder.state !== "closed") decoder.close();
111
+ } catch {}
112
+ decoder = null;
113
+ };
114
+
115
+ const beginDecoderRecovery = () => {
116
+ const now = performance.now();
117
+ if (now - lastDecoderRecoveryAt < DECODER_RECOVERY_COOLDOWN_MS && droppingUntilKeyframe) return;
118
+ lastDecoderRecoveryAt = now;
119
+ closeDecoder();
120
+ clearFrameQueue();
121
+ sawKeyframe = false;
122
+ frameIdx = 0;
123
+ droppingUntilKeyframe = true;
124
+ requestKeyframe();
125
+ setStatus("recovering video");
126
+ };
127
+
128
+ const requestKeyframe = () => {
129
+ const ws = wsRef.current;
130
+ const now = performance.now();
131
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
132
+ if (now - lastKeyframeRequestAt < KEYFRAME_REQUEST_COOLDOWN_MS) return;
133
+ lastKeyframeRequestAt = now;
134
+ ws.send(JSON.stringify({ type: "reset-video", ack: false }));
135
+ };
136
+
137
+ const renderFromQueue = (canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) => {
138
+ renderRaf = 0;
139
+ if (frameQueueCount === 0) return;
140
+
141
+ const tail = (frameQueueHead - frameQueueCount + FRAME_QUEUE_SIZE) % FRAME_QUEUE_SIZE;
142
+ const frame = frameQueue[tail]!;
143
+ frameQueue[tail] = null;
144
+ frameQueueCount--;
145
+
146
+ if (canvas.width !== frame.displayWidth || canvas.height !== frame.displayHeight) {
147
+ canvas.width = frame.displayWidth;
148
+ canvas.height = frame.displayHeight;
149
+ }
150
+ ctx.drawImage(frame, 0, 0);
151
+ frame.close();
152
+
153
+ fpsCount++;
154
+ const now = performance.now();
155
+ if (now - fpsTimer >= 1000) {
156
+ const fps = Math.round((fpsCount * 1000) / (now - fpsTimer));
157
+ fpsCount = 0;
158
+ fpsTimer = now;
159
+ setState((s) => (s.fps === fps ? s : { ...s, fps }));
160
+ }
161
+
162
+ if (frameQueueCount > 0) {
163
+ renderRaf = requestAnimationFrame(() => renderFromQueue(canvas, ctx));
164
+ }
165
+ };
166
+
167
+ const ensureDecoder = (spsBytes: Uint8Array): boolean => {
168
+ if (decoder?.state === "configured") return true;
169
+ closeDecoder();
170
+ const canvas = canvasRef.current;
171
+ const ctx = canvas?.getContext("2d", { alpha: false, desynchronized: true });
172
+ if (!canvas || !ctx) return false;
173
+ const codec = buildCodecString(spsBytes);
174
+ let dec: VideoDecoder;
175
+ dec = new VideoDecoder({
176
+ output: (frame) => {
177
+ if (decoder !== dec) {
178
+ frame.close();
179
+ return;
180
+ }
181
+ if (frameQueueCount >= FRAME_QUEUE_SIZE) {
182
+ const tail = (frameQueueHead - frameQueueCount + FRAME_QUEUE_SIZE) % FRAME_QUEUE_SIZE;
183
+ frameQueue[tail]?.close();
184
+ frameQueue[tail] = null;
185
+ frameQueueCount--;
186
+ }
187
+ frameQueue[frameQueueHead] = frame;
188
+ frameQueueHead = (frameQueueHead + 1) % FRAME_QUEUE_SIZE;
189
+ frameQueueCount++;
190
+ if (!renderRaf) {
191
+ renderRaf = requestAnimationFrame(() => renderFromQueue(canvas, ctx));
192
+ }
193
+ },
194
+ error: (e) => {
195
+ console.error("VideoDecoder error", e);
196
+ setStatus("decoder error");
197
+ if (decoder === dec) beginDecoderRecovery();
198
+ },
199
+ });
200
+ try {
201
+ dec.configure({ codec, optimizeForLatency: true });
202
+ decoder = dec;
203
+ console.log("VideoDecoder configured:", codec);
204
+ return true;
205
+ } catch (e) {
206
+ console.error("VideoDecoder configure failed", e);
207
+ try {
208
+ dec.close();
209
+ } catch {}
210
+ setStatus("decoder config failed");
211
+ requestKeyframe();
212
+ return false;
213
+ }
214
+ };
215
+
216
+ const feedFrame = (raw: ArrayBuffer | Uint8Array) => {
217
+ const packet = parseFramePacket(raw);
218
+ const needsScan =
219
+ packet.isKey === null ||
220
+ (packet.isKey && (!decoder || decoder.state !== "configured" || droppingUntilKeyframe));
221
+ const scanned = needsScan ? scanAU(packet.data) : null;
222
+ const isKey = packet.isKey ?? scanned?.isKey ?? false;
223
+ const spsBytes = scanned?.spsBytes ?? null;
224
+ if (spsBytes && !ensureDecoder(spsBytes)) return;
225
+
226
+ if (droppingUntilKeyframe) {
227
+ if (!isKey) return;
228
+ if (!decoder || decoder.state !== "configured") {
229
+ requestKeyframe();
230
+ return;
231
+ }
232
+ droppingUntilKeyframe = false;
233
+ }
234
+
235
+ if (!decoder || decoder.state !== "configured") {
236
+ if (!isKey) requestKeyframe();
237
+ return;
238
+ }
239
+
240
+ if (decoder.decodeQueueSize > SOFT_DECODE_QUEUE_SIZE) {
241
+ beginDecoderRecovery();
242
+ return;
243
+ }
244
+
245
+ if (!sawKeyframe) {
246
+ if (!isKey) {
247
+ requestKeyframe();
248
+ return;
249
+ }
250
+ sawKeyframe = true;
251
+ setStatus("streaming");
252
+ }
253
+ try {
254
+ decoder.decode(
255
+ new EncodedVideoChunk({
256
+ type: isKey ? "key" : "delta",
257
+ timestamp: packet.timestamp ?? Math.round((frameIdx * 1_000_000) / 60),
258
+ data: packet.data,
259
+ }),
260
+ );
261
+ frameIdx++;
262
+ } catch (e) {
263
+ console.error("decode failed", e);
264
+ beginDecoderRecovery();
265
+ }
266
+ };
267
+
268
+ const connect = () => {
269
+ if (cancelled) return;
270
+ const proto = location.protocol === "https:" ? "wss:" : "ws:";
271
+ const ws = new WebSocket(`${proto}//${location.host}/ws?frame-meta=1`);
272
+ ws.binaryType = "arraybuffer";
273
+ wsRef.current = ws;
274
+ ws.onopen = () => {
275
+ reconnectDelay = 500;
276
+ setStatus("streaming");
277
+ };
278
+ ws.onerror = () => setStatus("connection error");
279
+ ws.onclose = () => {
280
+ if (cancelled) return;
281
+ const retryIn = reconnectDelay;
282
+ setStatus(`disconnected — retrying in ${Math.round(retryIn / 1000)}s`);
283
+ try {
284
+ decoder?.close();
285
+ } catch {}
286
+ decoder = null;
287
+ frameIdx = 0;
288
+ sawKeyframe = false;
289
+ reconnectDelay = Math.min(Math.round(reconnectDelay * 1.6), 5000);
290
+ retryTimer = setTimeout(connect, retryIn);
291
+ };
292
+ ws.onmessage = (e) => {
293
+ if (typeof e.data === "string") {
294
+ try {
295
+ const msg = JSON.parse(e.data) as { type?: string; size?: DeviceSize };
296
+ if (
297
+ msg.type === "video-session" &&
298
+ msg.size &&
299
+ Number.isFinite(msg.size.width) &&
300
+ Number.isFinite(msg.size.height)
301
+ ) {
302
+ closeDecoder();
303
+ clearFrameQueue();
304
+ frameIdx = 0;
305
+ sawKeyframe = false;
306
+ droppingUntilKeyframe = true;
307
+ setState((s) => ({ ...s, deviceSize: msg.size! }));
308
+ requestKeyframe();
309
+ }
310
+ } catch {}
311
+ return;
312
+ }
313
+ feedFrame(e.data);
314
+ };
315
+ };
316
+
317
+ connect();
318
+
319
+ const applyServerStatus = (d: ApiInfo) => {
320
+ const lastFrameAgeMs = d.lastFrameAt ? Date.now() - Date.parse(d.lastFrameAt) : Infinity;
321
+ setState((s) => ({
322
+ ...s,
323
+ deviceSize: d.size,
324
+ status:
325
+ d.status && d.status !== "streaming"
326
+ ? d.lastError || d.status
327
+ : lastFrameAgeMs > 3000
328
+ ? "stream stalled"
329
+ : s.status,
330
+ }));
331
+ };
332
+
333
+ fetch("/health")
334
+ .then((r) => r.json() as Promise<ApiInfo>)
335
+ .then((d) => {
336
+ if (cancelled) return;
337
+ applyServerStatus(d);
338
+ })
339
+ .catch(() => {
340
+ if (!cancelled) setStatus("metadata unavailable");
341
+ });
342
+
343
+ healthTimer = setInterval(() => {
344
+ fetch("/health")
345
+ .then((r) => r.json() as Promise<ApiInfo>)
346
+ .then((d) => {
347
+ if (!cancelled) applyServerStatus(d);
348
+ })
349
+ .catch(() => {
350
+ if (!cancelled) setStatus("metadata unavailable");
351
+ });
352
+ }, 1500);
353
+
354
+ return () => {
355
+ cancelled = true;
356
+ if (retryTimer) clearTimeout(retryTimer);
357
+ if (healthTimer) clearInterval(healthTimer);
358
+ try {
359
+ wsRef.current?.close();
360
+ } catch {}
361
+ clearFrameQueue();
362
+ closeDecoder();
363
+ wsRef.current = null;
364
+ };
365
+ }, [canvasRef]);
366
+
367
+ return { state, send };
368
+ }
@@ -0,0 +1,7 @@
1
+ import { createRoot } from "react-dom/client";
2
+ import { App } from "./app";
3
+ import "./styles.css";
4
+
5
+ const root = document.getElementById("root");
6
+ if (!root) throw new Error("missing #root");
7
+ createRoot(root).render(<App />);