@stackbilt/aegis-core 0.7.0 → 0.8.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.
@@ -0,0 +1,14 @@
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.0" />
6
+ <meta name="theme-color" content="#101317" />
7
+ <title>AEGIS</title>
8
+ <script type="module" crossorigin src="/assets/index-CTKpNJEr.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-CQHn03rW.css">
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ </body>
14
+ </html>
@@ -0,0 +1,38 @@
1
+ import { routeAgentRequest } from 'agents';
2
+ import type { Env } from './types.js';
3
+
4
+ export async function routeAegisAgentRequest(
5
+ request: Request,
6
+ env: Env,
7
+ ): Promise<Response | undefined> {
8
+ const { pathname } = new URL(request.url);
9
+ if (!pathname.startsWith('/agents/')) {
10
+ return undefined;
11
+ }
12
+
13
+ const token = extractBearer(request.headers.get('Authorization'))
14
+ ?? getCookie(request.headers.get('Cookie') ?? '', 'aegis_token')
15
+ ?? new URL(request.url).searchParams.get('token');
16
+
17
+ if (!token || token !== env.AEGIS_TOKEN) {
18
+ return new Response('Unauthorized', { status: 401 });
19
+ }
20
+
21
+ try {
22
+ const response = await routeAgentRequest(request, env);
23
+ return response ?? new Response('Agent route not found', { status: 404 });
24
+ } catch (err) {
25
+ const message = err instanceof Error ? err.message : String(err);
26
+ return new Response(`Agent route failed: ${message}`, { status: 503 });
27
+ }
28
+ }
29
+
30
+ function extractBearer(header: string | null): string | null {
31
+ if (!header?.startsWith('Bearer ')) return null;
32
+ return header.slice(7);
33
+ }
34
+
35
+ function getCookie(cookieHeader: string, name: string): string | null {
36
+ const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
37
+ return match?.[1] ?? null;
38
+ }
package/src/assets.ts ADDED
@@ -0,0 +1,6 @@
1
+ export async function serveSpaIndex(request: Request, assets: Fetcher): Promise<Response> {
2
+ const url = new URL(request.url);
3
+ url.pathname = '/index.html';
4
+ url.search = '';
5
+ return assets.fetch(new Request(url, request));
6
+ }
package/src/auth.ts CHANGED
@@ -7,6 +7,7 @@ export async function bearerAuth(c: Context<{ Bindings: Env }>, next: Next): Pro
7
7
  c.req.path === '/health' ||
8
8
  c.req.path === '/pulse' ||
9
9
  ((c.req.path === '/' || c.req.path === '/chat' || c.req.path === '/manifest.json' || c.req.path === '/sw.js') && c.req.method === 'GET') ||
10
+ (c.req.method === 'GET' && (c.req.path.startsWith('/assets/') || c.req.path === '/favicon.svg')) ||
10
11
  c.req.path.startsWith('/tech') ||
11
12
  c.req.path === '/api/feedback' ||
12
13
  c.req.path === '/observe' ||
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  import { Hono } from 'hono';
10
10
  import OAuthProvider from '@cloudflare/workers-oauth-provider';
11
11
  import { bearerAuth } from './auth.js';
12
+ import { routeAegisAgentRequest } from './agent-routing.js';
12
13
  import { runScheduledTasks } from './kernel/scheduled/index.js';
13
14
  import type { Env } from './types.js';
14
15
  import { handleMcpRequest } from './mcp-server.js';
@@ -84,10 +85,15 @@ const oauthProvider = new OAuthProvider<Env>({
84
85
  });
85
86
 
86
87
  export default {
87
- fetch: oauthProvider.fetch.bind(oauthProvider),
88
+ async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
89
+ const agentResponse = await routeAegisAgentRequest(request, env);
90
+ if (agentResponse) return agentResponse;
91
+ return oauthProvider.fetch(request, env, ctx);
92
+ },
88
93
  async scheduled(_event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
89
94
  ctx.waitUntil(runScheduledTasks(buildEdgeEnv(env)));
90
95
  },
91
96
  };
92
97
 
93
98
  export { ChatSession } from './durable-objects/chat-session.js';
99
+ export { AegisVoiceAdapter } from './adapters/voice/cloudflare-agent.js';
@@ -3,6 +3,7 @@ import { bodyLimit } from 'hono/body-limit';
3
3
  import { createIntent, dispatch, dispatchStream } from '../kernel/dispatch.js';
4
4
  import { askGroq } from '../groq.js';
5
5
  import type { Env, MessageMetadata } from '../types.js';
6
+ import type { Executor } from '../kernel/types.js';
6
7
  import { buildEdgeEnv } from '../edge-env.js';
7
8
 
8
9
  // 100 KB — generous for chat text, blocks payload abuse
@@ -47,7 +48,7 @@ async function generateConversationTitle(
47
48
 
48
49
  // ─── Send Message (edge-native kernel) ───────────────────────
49
50
  messages.post('/api/message', bodyLimit({ maxSize: MESSAGE_BODY_LIMIT }), async (c) => {
50
- const body = await c.req.json<{ text: string; conversationId?: string }>();
51
+ const body = await c.req.json<{ text: string; conversationId?: string; executor?: Executor }>();
51
52
  if (!body.text?.trim()) {
52
53
  return c.json({ error: 'text is required' }, 400);
53
54
  }
@@ -80,7 +81,7 @@ messages.post('/api/message', bodyLimit({ maxSize: MESSAGE_BODY_LIMIT }), async
80
81
  ).bind(userMessageId, conversationId, 'user', text).run();
81
82
 
82
83
  // Fire-and-forget title generation for new conversations (#21)
83
- if (isNewConversation && c.executionCtx) {
84
+ if (isNewConversation && c.executionCtx && c.env.GROQ_API_KEY) {
84
85
  c.executionCtx.waitUntil(
85
86
  generateConversationTitle(c.env.DB, conversationId, text, c.env.GROQ_API_KEY, c.env.GROQ_MODEL || 'llama-3.3-70b-versatile', groqBaseUrl(c.env)),
86
87
  );
@@ -88,7 +89,9 @@ messages.post('/api/message', bodyLimit({ maxSize: MESSAGE_BODY_LIMIT }), async
88
89
 
89
90
  // Dispatch through edge kernel
90
91
  const edgeEnv = buildEdgeEnv(c.env, c.executionCtx);
91
- const intent = createIntent(conversationId, text);
92
+ const intent = createIntent(conversationId, text, {
93
+ forcedExecutor: body.executor,
94
+ });
92
95
 
93
96
  try {
94
97
  const result = await dispatch(intent, edgeEnv);
@@ -146,7 +149,7 @@ messages.post('/api/message', bodyLimit({ maxSize: MESSAGE_BODY_LIMIT }), async
146
149
 
147
150
  // ─── Streaming Message (SSE) ─────────────────────────────────
148
151
  messages.post('/api/message/stream', bodyLimit({ maxSize: MESSAGE_BODY_LIMIT }), async (c) => {
149
- const body = await c.req.json<{ text: string; conversationId?: string }>();
152
+ const body = await c.req.json<{ text: string; conversationId?: string; executor?: Executor }>();
150
153
  if (!body.text?.trim()) {
151
154
  return c.json({ error: 'text is required' }, 400);
152
155
  }
@@ -169,14 +172,16 @@ messages.post('/api/message/stream', bodyLimit({ maxSize: MESSAGE_BODY_LIMIT }),
169
172
  await c.env.DB.prepare('INSERT INTO messages (id, conversation_id, role, content) VALUES (?, ?, ?, ?)').bind(userMessageId, conversationId, 'user', text).run();
170
173
 
171
174
  // Fire-and-forget title generation for new conversations (#21)
172
- if (isNewConvStream && c.executionCtx) {
175
+ if (isNewConvStream && c.executionCtx && c.env.GROQ_API_KEY) {
173
176
  c.executionCtx.waitUntil(
174
177
  generateConversationTitle(c.env.DB, conversationId, text, c.env.GROQ_API_KEY, c.env.GROQ_MODEL || 'llama-3.3-70b-versatile', groqBaseUrl(c.env)),
175
178
  );
176
179
  }
177
180
 
178
181
  const edgeEnv = buildEdgeEnv(c.env, c.executionCtx);
179
- const intent = createIntent(conversationId, text);
182
+ const intent = createIntent(conversationId, text, {
183
+ forcedExecutor: body.executor,
184
+ });
180
185
 
181
186
  const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>();
182
187
  const writer = writable.getWriter();
@@ -3,6 +3,7 @@
3
3
  import { Hono } from 'hono';
4
4
  import { bodyLimit } from 'hono/body-limit';
5
5
  import type { Env } from '../types.js';
6
+ import { serveSpaIndex } from '../assets.js';
6
7
  import { chatPage } from '../ui.js';
7
8
  import { landingPage } from '../landing.js';
8
9
  import { dashboardPage, getDashboardData } from '../dashboard.js';
@@ -15,6 +16,9 @@ const pages = new Hono<{ Bindings: Env }>();
15
16
  // ─── Landing ────────────────────────────────────────────────
16
17
 
17
18
  pages.get('/', (c) => {
19
+ if (c.env.ASSETS) {
20
+ return serveSpaIndex(c.req.raw, c.env.ASSETS);
21
+ }
18
22
  return c.html(chatPage());
19
23
  });
20
24
 
@@ -25,6 +29,9 @@ pages.get('/about', (c) => {
25
29
  // ─── Chat ───────────────────────────────────────────────────
26
30
 
27
31
  pages.get('/chat', (c) => {
32
+ if (c.env.ASSETS) {
33
+ return serveSpaIndex(c.req.raw, c.env.ASSETS);
34
+ }
28
35
  return c.html(chatPage());
29
36
  });
30
37
 
package/src/types.ts CHANGED
@@ -4,7 +4,9 @@ export interface Env {
4
4
  DB: D1Database;
5
5
  AI: Ai;
6
6
  AEGIS_TOKEN: string;
7
+ ASSETS?: Fetcher;
7
8
  CHAT_SESSION?: DurableObjectNamespace;
9
+ AegisVoiceAdapter?: DurableObjectNamespace;
8
10
 
9
11
  // OAuth 2.1 (injected by OAuthProvider wrapper at runtime)
10
12
  OAUTH_PROVIDER: OAuthHelpers;
@@ -0,0 +1,13 @@
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.0" />
6
+ <meta name="theme-color" content="#101317" />
7
+ <title>AEGIS</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/main.tsx"></script>
12
+ </body>
13
+ </html>
@@ -0,0 +1,356 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { useVoiceAgent } from '@cloudflare/voice/react';
4
+ import './styles.css';
5
+
6
+ type Conversation = {
7
+ id: string;
8
+ title: string | null;
9
+ created_at?: string;
10
+ updated_at?: string;
11
+ };
12
+
13
+ type Message = {
14
+ id: string;
15
+ role: 'user' | 'assistant' | string;
16
+ content: string;
17
+ metadata?: Record<string, unknown> | null;
18
+ };
19
+
20
+ type HealthPayload = {
21
+ status: string;
22
+ version: string;
23
+ mode: string;
24
+ kernel?: Record<string, number>;
25
+ tasks_24h?: Array<{ task_name: string; runs: number; ok: number; errors: number }>;
26
+ docs_sync_status?: { status: string; lastSyncAge: number | null };
27
+ };
28
+
29
+ const TOKEN_KEY = 'aegis_token';
30
+
31
+ function App() {
32
+ const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) ?? '');
33
+ const [tokenDraft, setTokenDraft] = useState(token);
34
+ const [conversations, setConversations] = useState<Conversation[]>([]);
35
+ const [conversationId, setConversationId] = useState<string | null>(null);
36
+ const [messages, setMessages] = useState<Message[]>([]);
37
+ const [prompt, setPrompt] = useState('');
38
+ const [status, setStatus] = useState('Ready');
39
+ const [health, setHealth] = useState<HealthPayload | null>(null);
40
+ const [voiceText, setVoiceText] = useState('');
41
+ const messagesRef = useRef<HTMLDivElement | null>(null);
42
+
43
+ const voice = useVoiceAgent({
44
+ agent: 'AegisVoiceAdapter',
45
+ name: 'operator',
46
+ query: token ? { token } : undefined,
47
+ });
48
+
49
+ const authHeaders = useMemo<Record<string, string>>(
50
+ () => {
51
+ const headers: Record<string, string> = {};
52
+ if (token) headers.Authorization = `Bearer ${token}`;
53
+ return headers;
54
+ },
55
+ [token],
56
+ );
57
+
58
+ useEffect(() => {
59
+ document.cookie = token
60
+ ? `aegis_token=${encodeURIComponent(token)};path=/;max-age=31536000;SameSite=Strict;Secure`
61
+ : 'aegis_token=;path=/;max-age=0;SameSite=Strict;Secure';
62
+ }, [token]);
63
+
64
+ useEffect(() => {
65
+ void refreshHealth();
66
+ if (token) void refreshConversations();
67
+ }, [token]);
68
+
69
+ useEffect(() => {
70
+ messagesRef.current?.scrollTo({ top: messagesRef.current.scrollHeight });
71
+ }, [messages]);
72
+
73
+ async function refreshHealth() {
74
+ const res = await fetch('/health?format=json', {
75
+ headers: { Accept: 'application/json' },
76
+ });
77
+ if (res.ok) {
78
+ setHealth(await res.json());
79
+ }
80
+ }
81
+
82
+ async function refreshConversations() {
83
+ const res = await fetch('/api/conversations', { headers: authHeaders });
84
+ if (!res.ok) {
85
+ setStatus(res.status === 401 ? 'Add your AEGIS token' : `Conversations failed: ${res.status}`);
86
+ return;
87
+ }
88
+ const data = await res.json() as { conversations: Conversation[] };
89
+ setConversations(data.conversations);
90
+ }
91
+
92
+ async function loadConversation(id: string) {
93
+ setConversationId(id);
94
+ const res = await fetch(`/api/conversations/${encodeURIComponent(id)}/messages`, {
95
+ headers: authHeaders,
96
+ });
97
+ if (!res.ok) {
98
+ setStatus(`History failed: ${res.status}`);
99
+ return;
100
+ }
101
+ const data = await res.json() as { messages: Message[] };
102
+ setMessages(data.messages);
103
+ }
104
+
105
+ function saveToken() {
106
+ const next = tokenDraft.trim();
107
+ localStorage.setItem(TOKEN_KEY, next);
108
+ setToken(next);
109
+ setStatus(next ? 'Token saved' : 'Token cleared');
110
+ }
111
+
112
+ async function sendMessage(text = prompt.trim()) {
113
+ if (!text) return;
114
+ if (!token) {
115
+ setStatus('Add your AEGIS token');
116
+ return;
117
+ }
118
+
119
+ const optimisticUser: Message = { id: crypto.randomUUID(), role: 'user', content: text };
120
+ const assistantId = crypto.randomUUID();
121
+ setMessages((current) => [
122
+ ...current,
123
+ optimisticUser,
124
+ { id: assistantId, role: 'assistant', content: '' },
125
+ ]);
126
+ setPrompt('');
127
+ setStatus('Streaming');
128
+
129
+ const res = await fetch('/api/message/stream', {
130
+ method: 'POST',
131
+ headers: {
132
+ ...authHeaders,
133
+ 'Content-Type': 'application/json',
134
+ },
135
+ body: JSON.stringify({ text, conversationId, executor: 'workers_ai' }),
136
+ });
137
+
138
+ if (!res.ok || !res.body) {
139
+ setStatus(`Message failed: ${res.status}`);
140
+ return;
141
+ }
142
+
143
+ let assistantText = '';
144
+ for await (const frame of readSse(res.body)) {
145
+ if (frame.type === 'start' && typeof frame.conversationId === 'string') {
146
+ setConversationId(frame.conversationId);
147
+ }
148
+ if (frame.type === 'delta' && typeof frame.text === 'string') {
149
+ assistantText += frame.text;
150
+ setMessages((current) => current.map((message) => (
151
+ message.id === assistantId ? { ...message, content: assistantText } : message
152
+ )));
153
+ }
154
+ if (frame.type === 'error') {
155
+ setStatus(typeof frame.error === 'string' ? frame.error : 'Stream error');
156
+ }
157
+ if (frame.type === 'done') {
158
+ setStatus('Ready');
159
+ void refreshConversations();
160
+ }
161
+ }
162
+ }
163
+
164
+ function sendVoiceText() {
165
+ const text = voiceText.trim();
166
+ if (!text) return;
167
+ voice.sendText(text);
168
+ setVoiceText('');
169
+ }
170
+
171
+ const activeConversation = conversations.find((conversation) => conversation.id === conversationId);
172
+ const taskErrors = health?.tasks_24h?.reduce((sum, task) => sum + Number(task.errors ?? 0), 0) ?? 0;
173
+
174
+ return (
175
+ <main className="shell">
176
+ <aside className="rail" aria-label="AEGIS navigation">
177
+ <section className="brand">
178
+ <div className="brand-mark" aria-hidden="true">A</div>
179
+ <div>
180
+ <h1>AEGIS</h1>
181
+ <p>Edge-native operator console</p>
182
+ </div>
183
+ </section>
184
+
185
+ <section className="token-panel" aria-label="Access token">
186
+ <label htmlFor="token">Access token</label>
187
+ <div className="token-row">
188
+ <input
189
+ id="token"
190
+ type="password"
191
+ value={tokenDraft}
192
+ onChange={(event) => setTokenDraft(event.target.value)}
193
+ autoComplete="off"
194
+ />
195
+ <button type="button" onClick={saveToken} aria-label="Save token">OK</button>
196
+ </div>
197
+ </section>
198
+
199
+ <section className="health-panel" aria-label="Health dashboard">
200
+ <div className="panel-title">
201
+ <span>Health</span>
202
+ <button type="button" onClick={refreshHealth} aria-label="Refresh health">R</button>
203
+ </div>
204
+ <dl className="health-grid">
205
+ <div>
206
+ <dt>Status</dt>
207
+ <dd>{health?.status ?? 'unknown'}</dd>
208
+ </div>
209
+ <div>
210
+ <dt>Version</dt>
211
+ <dd>{health?.version ?? '-'}</dd>
212
+ </div>
213
+ <div>
214
+ <dt>Kernel</dt>
215
+ <dd>{health?.kernel ? Object.values(health.kernel).reduce((a, b) => a + b, 0) : '-'}</dd>
216
+ </div>
217
+ <div>
218
+ <dt>Task errors</dt>
219
+ <dd>{taskErrors}</dd>
220
+ </div>
221
+ </dl>
222
+ </section>
223
+
224
+ <section className="conversation-panel" aria-label="Conversations">
225
+ <div className="panel-title">
226
+ <span>Conversations</span>
227
+ <button type="button" onClick={refreshConversations} aria-label="Refresh conversations">R</button>
228
+ </div>
229
+ <button
230
+ type="button"
231
+ className="new-chat"
232
+ onClick={() => {
233
+ setConversationId(null);
234
+ setMessages([]);
235
+ }}
236
+ >
237
+ New session
238
+ </button>
239
+ <div className="conversation-list">
240
+ {conversations.map((conversation) => (
241
+ <button
242
+ type="button"
243
+ key={conversation.id}
244
+ className={conversation.id === conversationId ? 'conversation active' : 'conversation'}
245
+ onClick={() => loadConversation(conversation.id)}
246
+ >
247
+ <span>{conversation.title || 'Untitled'}</span>
248
+ <small>{conversation.updated_at ? new Date(conversation.updated_at).toLocaleString() : 'recent'}</small>
249
+ </button>
250
+ ))}
251
+ </div>
252
+ </section>
253
+ </aside>
254
+
255
+ <section className="workspace">
256
+ <header className="topbar">
257
+ <div>
258
+ <p className="eyebrow">Self-sufficient deploy</p>
259
+ <h2>{activeConversation?.title || 'Operator session'}</h2>
260
+ </div>
261
+ <div className="runtime-status" aria-live="polite">
262
+ <span className={token ? 'dot online' : 'dot'} aria-hidden="true" />
263
+ {status}
264
+ </div>
265
+ </header>
266
+
267
+ <section className="message-surface" ref={messagesRef} aria-label="Messages">
268
+ {messages.length === 0 ? (
269
+ <div className="empty-state">
270
+ <p>Ask the agent to inspect memory, explain its health, or plan the next deployment step.</p>
271
+ </div>
272
+ ) : messages.map((message) => (
273
+ <article key={message.id} className={`message ${message.role}`}>
274
+ <div className="message-role">{message.role}</div>
275
+ <div className="message-content">{message.content || '...'}</div>
276
+ </article>
277
+ ))}
278
+ </section>
279
+
280
+ <form
281
+ className="composer"
282
+ onSubmit={(event) => {
283
+ event.preventDefault();
284
+ void sendMessage();
285
+ }}
286
+ >
287
+ <textarea
288
+ value={prompt}
289
+ onChange={(event) => setPrompt(event.target.value)}
290
+ placeholder="Message AEGIS"
291
+ rows={3}
292
+ />
293
+ <button type="submit" aria-label="Send message">Send</button>
294
+ </form>
295
+ </section>
296
+
297
+ <aside className="voice-panel" aria-label="Voice call">
298
+ <div className="panel-title">
299
+ <span>Voice</span>
300
+ <span className="voice-state">{voice.status}</span>
301
+ </div>
302
+ <div className="meter" aria-label="Audio level">
303
+ <span style={{ width: `${Math.round(voice.audioLevel * 100)}%` }} />
304
+ </div>
305
+ <div className="voice-actions">
306
+ <button type="button" onClick={() => voice.connected ? voice.endCall() : void voice.startCall()}>
307
+ {voice.connected ? 'End call' : 'Start call'}
308
+ </button>
309
+ <button type="button" onClick={voice.toggleMute} disabled={!voice.connected}>
310
+ {voice.isMuted ? 'Unmute' : 'Mute'}
311
+ </button>
312
+ </div>
313
+ {voice.error ? <p className="error">{voice.error}</p> : null}
314
+ <div className="voice-transcript">
315
+ {voice.transcript.slice(-8).map((entry, index) => (
316
+ <p key={`${entry.role}-${index}`}>
317
+ <strong>{entry.role}</strong>
318
+ <span>{entry.text}</span>
319
+ </p>
320
+ ))}
321
+ {voice.interimTranscript ? <p><strong>interim</strong><span>{voice.interimTranscript}</span></p> : null}
322
+ </div>
323
+ <div className="voice-text">
324
+ <input
325
+ value={voiceText}
326
+ onChange={(event) => setVoiceText(event.target.value)}
327
+ placeholder="Send text to voice agent"
328
+ />
329
+ <button type="button" onClick={sendVoiceText} disabled={!voice.connected}>Send</button>
330
+ </div>
331
+ </aside>
332
+ </main>
333
+ );
334
+ }
335
+
336
+ async function* readSse(body: ReadableStream<Uint8Array>): AsyncGenerator<Record<string, unknown>> {
337
+ const reader = body.getReader();
338
+ const decoder = new TextDecoder();
339
+ let buffer = '';
340
+
341
+ while (true) {
342
+ const { value, done } = await reader.read();
343
+ if (done) break;
344
+ buffer += decoder.decode(value, { stream: true });
345
+ const frames = buffer.split('\n\n');
346
+ buffer = frames.pop() ?? '';
347
+
348
+ for (const frame of frames) {
349
+ const dataLine = frame.split('\n').find((line) => line.startsWith('data: '));
350
+ if (!dataLine) continue;
351
+ yield JSON.parse(dataLine.slice(6));
352
+ }
353
+ }
354
+ }
355
+
356
+ createRoot(document.getElementById('root') as HTMLElement).render(<App />);